Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
805f4594d4 | ||
|
|
7a0defc3d4 | ||
|
|
fe6957bf83 | ||
|
|
049291c109 | ||
|
|
53673d0f9b | ||
|
|
9d55f521a0 | ||
|
|
35c03ef35e | ||
|
|
bb6c586d99 | ||
|
|
4fb57dfd67 | ||
|
|
6b49d035c2 | ||
|
|
6bb6226e06 | ||
|
|
9242b87677 | ||
|
|
20c95e73b3 | ||
|
|
5837e4b0d9 | ||
|
|
1fb5d57da1 | ||
|
|
8abc70dcf1 | ||
|
|
f2e5a9e58b | ||
|
|
5ae5421c5c | ||
|
|
c169703a17 | ||
|
|
07da45467b | ||
|
|
4b9cc13163 | ||
|
|
f1046a6598 | ||
|
|
5731eda438 | ||
|
|
17bddd3adf | ||
|
|
2ad3ad7367 | ||
|
|
b31124e1e4 | ||
|
|
a29deb8bb1 | ||
|
|
17345daa81 | ||
|
|
2c96562b66 | ||
|
|
70cf9616c1 | ||
|
|
cfe5512ec7 | ||
|
|
8f11df99ea | ||
|
|
b7d29d1028 | ||
|
|
5ed082631e | ||
|
|
6e422ec3dc | ||
|
|
bf3c67f405 | ||
|
|
3a7c7b0908 | ||
|
|
897fac4f15 | ||
|
|
1fde4e30fc |
@@ -0,0 +1,23 @@
|
||||
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
|
||||
# docker compose بهطور خودکار این فایل را میخواند؛ بدون آن استک بالا نمیآید.
|
||||
|
||||
# --- رمزهای اصلی (اجباری) ---
|
||||
# تولید کلید تصادفی: python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
SECRET_KEY=change-me-to-a-long-random-string
|
||||
POSTGRES_PASSWORD=change-me-db-password
|
||||
MINIO_ROOT_USER=aramesh
|
||||
MINIO_ROOT_PASSWORD=change-me-minio-password
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-admin-password
|
||||
|
||||
# --- اختیاری ---
|
||||
# پورت ورود Traefik در توسعه (پیشفرض 5173)
|
||||
TRAEFIK_HTTP_PORT=5173
|
||||
# نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example
|
||||
SITE_URL=
|
||||
# آدرسهای مجاز CORS (لیست JSON) — پیشفرض فقط لوکالهاست
|
||||
# CORS_ORIGINS=["http://localhost:5173"]
|
||||
|
||||
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
|
||||
# DOMAIN=aramesh.example
|
||||
# ACME_EMAIL=admin@aramesh.example
|
||||
@@ -9,6 +9,9 @@ env/
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# پشتیبانگیری محلی
|
||||
backups/
|
||||
|
||||
# Node / Vite
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -23,4 +26,6 @@ Thumbs.db
|
||||
# Database
|
||||
*.sqlite3
|
||||
*.db
|
||||
frontend/public/images/originals/
|
||||
|
||||
# تصاویر محلی — از پنل/کنسول MinIO مدیریت میشوند
|
||||
frontend/public/images/
|
||||
|
||||
@@ -2,35 +2,78 @@
|
||||
|
||||
مونوریپوی سایت گروه ساختمانی آرامش شامل:
|
||||
|
||||
- **`frontend/`** — سایت React + Vite + Tailwind (همه محتوا از API خوانده میشود)
|
||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT
|
||||
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متنها و تصاویر از API خوانده میشود)
|
||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT + MinIO
|
||||
|
||||
## معماری
|
||||
|
||||
```
|
||||
Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
|
||||
├── / → Caddy (فایلهای استاتیک SPA)
|
||||
├── /api → FastAPI (بکاند)
|
||||
└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی)
|
||||
```
|
||||
|
||||
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانتاند هاردکد نشده؛
|
||||
با یک درخواست `GET /api/site` بارگذاری و از پنل مدیریت ویرایش میشود
|
||||
- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره میشوند و با مسیر نسبی
|
||||
`/media/...` برگردانده میشوند — بکاند ذخیرهسازی بدون تغییر محتوای دیتابیس قابل تعویض است
|
||||
- مسیریابی همهچیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
|
||||
|
||||
## ساختار
|
||||
|
||||
```
|
||||
├── frontend/ # سایت React
|
||||
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
|
||||
│ ├── Dockerfile # بیلد Node + سرو با nginx
|
||||
│ └── nginx.conf # پروکسی /api به بکاند
|
||||
│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy
|
||||
│ └── Caddyfile # فقط سرو فایلهای استاتیک (مسیریابی روی Traefik است)
|
||||
├── backend/ # بکاند FastAPI
|
||||
│ ├── app/ # کد منبع
|
||||
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
|
||||
│ ├── tests/ # تستهای API (pytest)
|
||||
│ ├── requirements.txt
|
||||
│ ├── Dockerfile
|
||||
│ └── .env.example
|
||||
└── docker-compose.yml # اجرای کامل با یک دستور
|
||||
├── scripts/backup.sh # پشتیبانگیری دیتابیس و تصاویر
|
||||
├── docker-compose.yml # اجرای کامل با یک دستور
|
||||
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
|
||||
```
|
||||
|
||||
## اجرا با Docker (سادهترین راه)
|
||||
|
||||
پیشنیاز: Docker Desktop. بیلد فرانتاند روی هاست انجام میشود (کانتینر فقط فایلهای
|
||||
`dist/` را سرو میکند)، پس اولین بار باید یکبار `npm run build` بزنید:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمیآید
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
- سایت: http://localhost:5173
|
||||
- مستندات API: http://localhost:8000/docs
|
||||
- دیتابیس PostgreSQL در کانتینر جدا (دادهها در volume ذخیره میشوند)
|
||||
- بکاند هنگام شروع، خودش جداول و دادههای اولیه سایت را seed میکند
|
||||
- nginx درخواستهای `/api` را خودش به بکاند پروکسی میکند — نیازی به تنظیم CORS نیست
|
||||
سایت: http://localhost:5173 — ورود مدیریت: `/admin/login` (کاربر/رمز از `ADMIN_*` در `.env`)
|
||||
|
||||
- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی)
|
||||
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env`
|
||||
- دیتابیس PostgreSQL و آبجکتاستور MinIO هر دو در کانتینر جدا با volume دائمی
|
||||
- بکاند هنگام شروع، خودش جداول را میسازد و **فقط جدولهای خالی** را seed میکند —
|
||||
ارتقای نسخه دادههای فعلی را از بین نمیبرد
|
||||
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را میسازد، خواندن عمومی را
|
||||
فعال میکند و تصاویر داخل `seed-media/` را در باکت میریزد — پس یک کلون تازه هم
|
||||
همان تصاویر سایت را دارد
|
||||
|
||||
> نکته برای تازهواردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
|
||||
> فایلهای `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
|
||||
|
||||
## انتشار واقعی (TLS)
|
||||
|
||||
برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید:
|
||||
|
||||
```bash
|
||||
# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
- Traefik گواهی Let's Encrypt میگیرد و تمدید میکند؛ همه درخواستهای HTTP به HTTPS هدایت میشوند
|
||||
- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرسهای OG مطلق شوند
|
||||
|
||||
## اجرای محلی (توسعه)
|
||||
|
||||
@@ -52,34 +95,108 @@ uvicorn app.main:app --reload
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
npm run dev
|
||||
```
|
||||
|
||||
آدرس بکاند در `frontend/src/config.ts` از `VITE_API_URL` خوانده میشود؛
|
||||
اگر تنظیم نشود پیشفرض `http://localhost:8000` است.
|
||||
در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند؛
|
||||
پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `frontend/src/config.ts` از
|
||||
`VITE_API_URL` خوانده میشود — پیشفرض خالی یعنی درخواستهای هممبدأ.
|
||||
|
||||
## امکانات بکاند
|
||||
|
||||
- **API عمومی**: برند، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم، تایملاین، آمار و...
|
||||
- **API عمومی**: برند، منو، هیرو، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم،
|
||||
تایملاین، آمار، دفاتر، مدلهای سرمایهگذاری و... — همه در دیتابیس
|
||||
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
|
||||
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`) و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
||||
- همه محتوا در یک درخواست: `GET /api/site`
|
||||
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`)
|
||||
و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
||||
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
|
||||
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد میشود
|
||||
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
|
||||
ذخیره و URL نسبی `/media/...` برمیگردد
|
||||
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسالشده عوض میشوند
|
||||
- **ترتیببندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
|
||||
- همه محتوا در یک درخواست: `GET /api/site` (مقالههای پیشنویس در آن نمیآیند)
|
||||
- **تستها**: `cd backend && pip install -r requirements-dev.txt && pytest tests`
|
||||
|
||||
> در محیط واقعی حتماً `SECRET_KEY` و `ADMIN_PASSWORD` را تغییر دهید.
|
||||
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید
|
||||
> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
|
||||
|
||||
## ویرایش زنده سایت (دوقلوی دیجیتال)
|
||||
|
||||
ادمینها نیازی به داشبورد جدا ندارند — خود سایت ویرایشگر است:
|
||||
|
||||
1. به `/admin/login` بروید و وارد شوید
|
||||
2. نوار شناور پایین صفحه ظاهر میشود؛ **حالت ویرایش** را روشن کنید
|
||||
3. روی هر متن کلیک کنید و ویرایش کنید — **خروج از کادر، بلافاصله ذخیره میشود**
|
||||
(Enter ذخیره، Esc لغو)؛ وضعیت ذخیره در نوار پایین دیده میشود
|
||||
4. آیتمهای مجموعهها (خدمات، پروژهها، مقالات، نظرات، تیم و...) با هاور، دکمههای
|
||||
ویرایش / حذف / جابهجایی ترتیب نشان میدهند؛ دکمه «افزودن» هم انتهای هر لیست هست
|
||||
5. تصویرها از همان کشوها به MinIO آپلود میشوند
|
||||
|
||||
- در حالت ویرایش، لینکها کلیک نمیشوند تا ویرایش متن ممکن باشد
|
||||
- بازدیدکنندههای عادی هیچ اثری از این لایه نمیبینند (بدون توکن، همه درخواستهای
|
||||
مدیریتی `401` برمیگردند)
|
||||
- تمام تغییرها بلافاصله در PostgreSQL ثبت میشوند — با Ctrl+F5 هم میمانند
|
||||
|
||||
## وبلاگ — نوشتن با ویرایشگر WYSIWYG
|
||||
|
||||
مقالات با ویرایشگر غنی **TipTap** نوشته میشوند (همان تجربه Medium):
|
||||
|
||||
- در حالت ویرایش، روی هر مقاله در صفحه وبلاگ دکمه ویرایش را بزنید — کشوی نوشتن باز میشود
|
||||
- تیتر (H2/H3)، بولد/ایتالیک/زیرخط، لیست نقطهای و شمارهای، نقلقول، لینک و واگرد
|
||||
- **درج تصویر داخل متن**: دکمه تصویر، یا چسباندن/کشیدن تصویر داخل نوشته — فایل مستقیم
|
||||
در MinIO ذخیره و در متن قرار میگیرد
|
||||
- راستبهچپ کامل با فونت و تم سایت
|
||||
- ذخیره خودکار: هر مکث کوتاه در تایپ، تغییرها را ذخیره میکند؛ بستن کشو هم ذخیره میکند
|
||||
|
||||
**پیشنویس و انتشار**: مقاله تازه همیشه **پیشنویس** شروع میشود — در سایت عمومی دیده
|
||||
نمیشود و تا انتشار، تغییرهای خودکار آن چیزی را خراب نمیکند. با دکمه «انتشار در سایت»
|
||||
عمومی میشود؛ دوباره میتوان آن را به پیشنویس برگرداند. پیشنویسها فقط برای ادمین
|
||||
(در صفحه وبلاگ، با برچسب «پیشنویس») دیده میشوند و در `GET /api/site` هرگز نمیآیند.
|
||||
|
||||
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
|
||||
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل میکند، پس فقط تگهای شناختهشده
|
||||
(پاراگراف، تیتر، لیست، نقلقول، تصویر، لینک) تولید میشوند. مقالات قدیمی (لیست پاراگراف)
|
||||
در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند.
|
||||
|
||||
## سئو
|
||||
|
||||
- **بررسی سئو در صفحه نوشتن**: فهرست سبز/نارنجی/قرمز به سبک یوست ولی سازگار با فارسی —
|
||||
طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیترها)، طول چکیده، تعداد واژه،
|
||||
تیتربندی H2، متن جایگزین تصاویر، پیوندها و نشانی (slug) — کلمه کلیدی هدف در دیتابیس
|
||||
ذخیره میشود (فیلد `seo_keyphrase` مقاله)
|
||||
- **سئوی هر صفحه**: در حالت ویرایش، دکمه سئو روی هیرو هر صفحه (و کنار هیرو صفحه اصلی) —
|
||||
عنوان تب و توضیح متای هر صفحه از دیتابیس خوانده و اعمال میشود؛ اگر ردیف سئوی صفحهای
|
||||
هنوز ساخته نشده باشد، دکمه + همانجا آن را میسازد
|
||||
- صفحه مقاله و پروژه بهطور خودکار عنوان و توضیح متا را از محتوای خودشان میگیرند
|
||||
- **تگهای OG و توییتر و canonical** برای همه صفحات از `src/seo.ts` ست میشود و
|
||||
مقالهها اسکیمای JSON-LD نوع Article میگیرند
|
||||
- **robots.txt و sitemap.xml** از بکاند سرو میشوند و مقاله/پروژههای منتشرشده را
|
||||
شامل میشوند (پیشنویسها نمیآیند) — برای آدرس مطلق، `SITE_URL` را در `.env` بگذارید
|
||||
- **متا برای خزندههای بدون جاوااسکریپت** (تلگرام، واتساپ و...): بکاند صفحات
|
||||
`/blog`، `/blog/{slug}`، `/projects` و `/projects/{slug}` را با همان `index.html`
|
||||
فرانتاند سرو میکند و پیش از ارسال، title / description / OG / JSON-LD را از
|
||||
دیتابیس داخل آن تزریق میکند (`app/seo_pages.py`) — بازدیدکنندههای عادی همان SPA
|
||||
همیشگی را میبینند
|
||||
|
||||
## پشتیبانگیری
|
||||
|
||||
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است — هر دو را با یک دستور پشتیبان بگیرید:
|
||||
|
||||
```bash
|
||||
bash scripts/backup.sh # در backups/ — نگهداری پیشفرض ۱۴ روز
|
||||
```
|
||||
|
||||
- خروجی: `backups/db-<زمان>.sql.gz` (فشرده) و `backups/images-<زمان>/` (آینه باکت)
|
||||
- زمانبندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
|
||||
- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza`
|
||||
|
||||
## نکات امنیتی
|
||||
|
||||
- فایل `.env` هرگز commit نمیشود (در `.gitignore` است)
|
||||
- رمز عبور ادمین با bcrypt هش میشود
|
||||
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml`
|
||||
با سینتکس `${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید
|
||||
- رمز عبور ادمین با bcrypt هش میشود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
|
||||
- ورود با محدودیت تلاش محافظت میشود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
|
||||
- توکنهای JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
|
||||
|
||||
## تغییرات اخیر
|
||||
|
||||
### بهبود سرعت (کامیت e0ebae4)
|
||||
- **حذف کامل وابستگیهای خارجی**: همه عکسهای سایت بهصورت محلی در `frontend/public/images/` قرار گرفتند (بدون وابستگی به سرورهای خارجی مثل postimg و housedesign)
|
||||
- **حذف فونتهای خارجی** (Google Fonts و fontcdn که رندر صفحه را قفل میکردند) — استفاده از فونت سیستمی سریع
|
||||
- **favicon واقعی** اضافه شد (قبلاً خراب بود)
|
||||
- **کش ۳۰ روزه** برای تصاویر و فایلهای استاتیک در nginx
|
||||
- نتیجه: صفحهها در چند میلیثانیه لود میشوند و سایت هیچ درخواست خارجی ندارد
|
||||
|
||||
> اگر نسخه قبلی سایت را اجرا کردهاید، برای دریافت نسخه جدید یک بار `docker compose down -v` و سپس `docker compose up --build -d` را اجرا کنید.
|
||||
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
|
||||
- مقالههای پیشنویس در هیچ انتهای عمومی نمیآیند و در HTML تزریقشده سئو هم افشا نمیشوند
|
||||
|
||||
@@ -15,3 +15,15 @@ CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
|
||||
# مشخصات ادمین پیشفرض که هنگام seed ساخته میشود
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=aramesh123
|
||||
|
||||
# Object Storage (MinIO) برای آپلود تصاویر پنل مدیریت
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY=aramesh
|
||||
S3_SECRET_KEY=change-me-minio-secret
|
||||
S3_BUCKET=aramesh-media
|
||||
|
||||
# پیشوند عمومی مسیر تصاویر — پروکسی Reverse باید آن را به باکت برساند
|
||||
MEDIA_PUBLIC_BASE=/media
|
||||
|
||||
# حداکثر حجم آپلود تصویر (مگابایت)
|
||||
MEDIA_MAX_UPLOAD_MB=10
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# بکاند گروه ساختمانی آرامش (Aramesh Building Group API)
|
||||
|
||||
بکاند سایت گروه ساختمانی آرامش — ساختهشده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT**.
|
||||
بکاند سایت گروه ساختمانی آرامش — ساختهشده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT** + **MinIO** (ذخیره تصاویر) + **Traefik** (مسیریابی).
|
||||
|
||||
## امکانات
|
||||
|
||||
- **API عمومی محتوا**: برند، خدمات، پروژهها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایملاین، آمار، چرا آرامش، و بخش قبل/بعد
|
||||
- **API عمومی محتوا**: برند، منو، هیرو، داستان، تیتر بخشها، هیرو صفحات، خدمات، پروژهها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایملاین، آمار، چرا آرامش، قبل/بعد، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، فرم تماس، خبرنامه و فوتر — **تمام متنهای سایت در دیتابیس است**
|
||||
- **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`)
|
||||
- **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`)
|
||||
- **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیامهای تماس و مشترکین
|
||||
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار میگیرد
|
||||
- **آپلود تصویر**: `POST /api/admin/upload` — فایل در MinIO ذخیره و URL نسبی `/media/...` برمیگردد
|
||||
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار میگیرد (فقط جدولهای خالی پر میشوند — ارتقا بدون از دست رفتن داده)
|
||||
- مستندات تعاملی Swagger در مسیر `/docs`
|
||||
|
||||
## راهاندازی
|
||||
@@ -85,6 +86,13 @@ Authorization: Bearer <access_token>
|
||||
|
||||
> نام کاربری و رمز عبور پیشفرض از تنظیمات `.env` خوانده میشود — حتماً در محیط تولید تغییرشان دهید.
|
||||
|
||||
تغییر رمز (با توکن):
|
||||
|
||||
```
|
||||
POST /api/auth/change-password
|
||||
{ "current_password": "...", "new_password": "..." }
|
||||
```
|
||||
|
||||
## فهرست انتهاپوینتها
|
||||
|
||||
### عمومی (بدون نیاز به توکن)
|
||||
@@ -96,8 +104,8 @@ Authorization: Bearer <access_token>
|
||||
| GET | `/api/services` | خدمات |
|
||||
| GET | `/api/projects` | همه پروژهها |
|
||||
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
|
||||
| GET | `/api/articles` | همه مقالات |
|
||||
| GET | `/api/articles/{slug}` | جزئیات یک مقاله |
|
||||
| GET | `/api/articles` | مقالههای منتشرشده (پیشنویس نمیآید) |
|
||||
| GET | `/api/articles/{slug}` | جزئیات یک مقاله منتشرشده |
|
||||
| GET | `/api/testimonials` | نظرات مشتریان |
|
||||
| GET | `/api/faqs` | سوالات متداول |
|
||||
| GET | `/api/team` | اعضای تیم |
|
||||
@@ -108,7 +116,11 @@ Authorization: Bearer <access_token>
|
||||
| GET | `/api/site` | **همه محتوا در یک درخواست** |
|
||||
| POST | `/api/contact` | ثبت پیام تماس |
|
||||
| POST | `/api/newsletter` | عضویت در خبرنامه |
|
||||
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن |
|
||||
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن — بعد از ۵ تلاش ناموفق در ۱۵ دقیقه، 429 |
|
||||
| GET | `/robots.txt` | قوانین خزندهها |
|
||||
| GET | `/sitemap.xml` | نقشه سایت — مقاله/پروژههای منتشرشده |
|
||||
| GET | `/blog/{slug}` | HTML صفحه مقاله با متای سئوی تزریقشده |
|
||||
| GET | `/projects/{slug}` | HTML صفحه پروژه با متای سئوی تزریقشده |
|
||||
|
||||
### مدیریت (نیاز به توکن Bearer)
|
||||
|
||||
@@ -118,12 +130,35 @@ Authorization: Bearer <access_token>
|
||||
| متد | مسیر | توضیح |
|
||||
|---|---|---|
|
||||
| GET/POST | `/api/admin/{resource}` | لیست / ساخت |
|
||||
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش / حذف |
|
||||
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش کامل / حذف |
|
||||
| PATCH | `/api/admin/{resource}/{id}` | **ویرایش جزئی** — فقط فیلدهای ارسالشده بهروزرسانی میشوند |
|
||||
| POST | `/api/admin/{resource}/reorder` | ذخیره ترتیب: `{"ids": [3, 1, 2, ...]}` |
|
||||
| GET | `/api/admin/messages` | پیامهای تماس (با `?unread_only=true` فقط خواندهنشدهها) |
|
||||
| PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خواندهشدن |
|
||||
| DELETE | `/api/admin/messages/{id}` | حذف پیام |
|
||||
| GET | `/api/admin/subscribers` | مشترکین خبرنامه |
|
||||
| DELETE | `/api/admin/subscribers/{id}` | حذف مشترک |
|
||||
| POST | `/api/admin/upload` | آپلود تصویر (multipart، حداکثر ۱۰MB — jpg/png/webp/gif/avif) → `{"url": "/media/..."}` |
|
||||
|
||||
### بدنه مقاله — سند TipTap
|
||||
|
||||
`body` مقاله یک سند ساختاریافته TipTap (ProseMirror JSON) است: `{"type": "doc", "content": [...]}`.
|
||||
- فقط ساختار اعتبارسنجی میشود (`type: "doc"` + `content` لیست)؛ امنیت رندر بر عهده
|
||||
رندر استاتیک TipTap در فرانتاند است که فقط تگهای شناختهشده تولید میکند
|
||||
- ردیفهای قدیمی (لیست پاراگراف) با مهاجرت idempotent `migrate_article_bodies`
|
||||
در startup/seed خودکار به سند تبدیل میشوند
|
||||
- seed هم بدنههای لیستی را قبل از درج تبدیل میکند
|
||||
|
||||
| POST/PATCH | `/api/admin/seo-meta` | عنوان و توضیح متای صفحات — `page_key`: home / about / services / projects / investment / blog / faq / contact |
|
||||
|
||||
منابعی که `reorder` دارند (ستون `position`): `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `investment-models`، `process-steps`، `offices`، `gallery`، `nav`. ستون با مهاجرت سبک idempotent هنگام startup/seed به دیتابیسهای موجود اضافه میشود.
|
||||
|
||||
### تصاویر و MinIO
|
||||
|
||||
فایلهای آپلودشده در باکت `aramesh-media` روی MinIO ذخیره میشوند و با مسیر نسبی `/media/<سال>/<ماه>/<فایل>` برگردانده میشوند. Traefik مسیر `/media` را به MinIO میبرد، پس URLها مستقل از بکاند ذخیرهسازی هستند — بعداً برای مهاجرت به R2/S3 فقط تنظیمات را عوض کنید، نه محتوای دیتابیس را.
|
||||
|
||||
- کنسول MinIO: `http://localhost:9001` (کاربر/رمز از `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` در compose)
|
||||
- تصاویر عمومی و قابل نمایشاند؛ آپلود فقط با توکن ادمین انجام میشود.
|
||||
|
||||
## اتصال به فرانتاند
|
||||
|
||||
|
||||
@@ -1,40 +1,103 @@
|
||||
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import boto3
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models, schemas
|
||||
from ..auth import create_access_token, get_current_admin, verify_password
|
||||
from ..auth import create_access_token, get_current_admin, hash_password, verify_password
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..rate_limit import clear as clear_rate_limit
|
||||
from ..rate_limit import ensure_allowed, record_failure
|
||||
from ..schemas import (
|
||||
ArticleIn,
|
||||
ArticleOut,
|
||||
ArticlePatch,
|
||||
BeforeAfterIn,
|
||||
BeforeAfterOut,
|
||||
BeforeAfterPatch,
|
||||
BrandIn,
|
||||
BrandOut,
|
||||
BrandPatch,
|
||||
ChangePasswordIn,
|
||||
ContactSectionIn,
|
||||
ContactSectionOut,
|
||||
ContactSectionPatch,
|
||||
ContactMessageOut,
|
||||
FaqIn,
|
||||
FaqOut,
|
||||
FaqPatch,
|
||||
FooterIn,
|
||||
FooterOut,
|
||||
FooterPatch,
|
||||
GalleryImageIn,
|
||||
GalleryImageOut,
|
||||
GalleryImagePatch,
|
||||
HeroIn,
|
||||
HeroOut,
|
||||
HeroPatch,
|
||||
InvestmentModelIn,
|
||||
InvestmentModelOut,
|
||||
InvestmentModelPatch,
|
||||
LoginIn,
|
||||
MessageReadIn,
|
||||
NavItemIn,
|
||||
NavItemOut,
|
||||
NavItemPatch,
|
||||
NewsletterSectionIn,
|
||||
NewsletterOut,
|
||||
NewsletterSectionPatch,
|
||||
OfficeIn,
|
||||
OfficeOut,
|
||||
OfficePatch,
|
||||
PageHeroIn,
|
||||
PageHeroOut,
|
||||
PageHeroPatch,
|
||||
ProcessStepIn,
|
||||
ProcessStepOut,
|
||||
ProcessStepPatch,
|
||||
ProjectIn,
|
||||
ProjectOut,
|
||||
ProjectPatch,
|
||||
ReorderIn,
|
||||
SeoMetaIn,
|
||||
SeoMetaOut,
|
||||
SeoMetaPatch,
|
||||
SectionHeadingIn,
|
||||
SectionHeadingOut,
|
||||
SectionHeadingPatch,
|
||||
ServiceIn,
|
||||
ServiceOut,
|
||||
ServicePatch,
|
||||
StatIn,
|
||||
StatOut,
|
||||
StatPatch,
|
||||
StatusLabelIn,
|
||||
StatusLabelOut,
|
||||
StatusLabelPatch,
|
||||
StoryIn,
|
||||
StoryOut,
|
||||
StoryPatch,
|
||||
SubscriberOut,
|
||||
TeamMemberIn,
|
||||
TeamMemberOut,
|
||||
TeamMemberPatch,
|
||||
TestimonialIn,
|
||||
TestimonialOut,
|
||||
TestimonialPatch,
|
||||
TimelineItemIn,
|
||||
TimelineItemOut,
|
||||
TimelineItemPatch,
|
||||
TokenOut,
|
||||
WhyUsItemIn,
|
||||
WhyUsItemOut,
|
||||
WhyUsItemPatch,
|
||||
)
|
||||
|
||||
# روتر عمومی برای ورود — بدون نیاز به توکن
|
||||
@@ -45,26 +108,52 @@ router = APIRouter(dependencies=[Depends(get_current_admin)])
|
||||
|
||||
|
||||
@auth_router.post("/auth/login", response_model=TokenOut)
|
||||
def login(payload: LoginIn, db: Session = Depends(get_db)):
|
||||
def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
|
||||
ensure_allowed(request)
|
||||
user = db.query(models.AdminUser).filter(models.AdminUser.username == payload.username).first()
|
||||
if user is None or not verify_password(payload.password, user.password_hash):
|
||||
record_failure(request)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="نام کاربری یا رمز عبور اشتباه است",
|
||||
)
|
||||
clear_rate_limit(request)
|
||||
return TokenOut(access_token=create_access_token(user.username))
|
||||
|
||||
|
||||
def register_crud(path: str, model, out_schema, create_schema) -> None:
|
||||
"""ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت."""
|
||||
@auth_router.post("/auth/change-password")
|
||||
def change_password(
|
||||
payload: ChangePasswordIn,
|
||||
admin: models.AdminUser = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""تغییر رمز ادمین واردشده — رمز فعلی باید درست باشد."""
|
||||
if not verify_password(payload.current_password, admin.password_hash):
|
||||
raise HTTPException(status_code=400, detail="رمز عبور فعلی اشتباه است")
|
||||
admin.password_hash = hash_password(payload.new_password)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
|
||||
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
||||
حذف و ترتیببندی) روی روتر مدیریت."""
|
||||
|
||||
@router.get(f"/{path}", response_model=list[out_schema])
|
||||
def list_items(db: Session = Depends(get_db)):
|
||||
return db.query(model).order_by(model.id).all()
|
||||
query = db.query(model)
|
||||
if hasattr(model, "position"):
|
||||
query = query.order_by(model.position, model.id)
|
||||
else:
|
||||
query = query.order_by(model.id)
|
||||
return query.all()
|
||||
|
||||
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
||||
def create_item(payload: create_schema, db: Session = Depends(get_db)):
|
||||
item = model(**payload.model_dump())
|
||||
if hasattr(model, "position"):
|
||||
# آیتم جدید در انتهای لیست قرار میگیرد
|
||||
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
|
||||
db.add(item)
|
||||
try:
|
||||
db.commit()
|
||||
@@ -96,6 +185,38 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
if patch_schema is not None:
|
||||
|
||||
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
|
||||
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)):
|
||||
"""ویرایش جزئی — فقط فیلدهای ارسالشده بهروزرسانی میشوند (برای ویرایش زنده)."""
|
||||
item = db.get(model, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(item, key, value)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
@router.post(f"/{path}/reorder", response_model=list[out_schema])
|
||||
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)):
|
||||
"""ذخیره ترتیب جدید آیتمها بر اساس لیست idهای ارسالی."""
|
||||
if not hasattr(model, "position"):
|
||||
raise HTTPException(status_code=400, detail="این منبع از ترتیببندی پشتیبانی نمیکند")
|
||||
items = db.query(model).filter(model.id.in_(payload.ids)).all()
|
||||
if len(items) != len(payload.ids):
|
||||
raise HTTPException(status_code=404, detail="برخی رکوردها یافت نشدند")
|
||||
by_id = {item.id: item for item in items}
|
||||
for pos, item_id in enumerate(payload.ids):
|
||||
by_id[item_id].position = pos
|
||||
db.commit()
|
||||
return db.query(model).order_by(model.position, model.id).all()
|
||||
|
||||
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
||||
def delete_item(item_id: int, db: Session = Depends(get_db)):
|
||||
item = db.get(model, item_id)
|
||||
@@ -106,17 +227,87 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
|
||||
|
||||
|
||||
# CRUD محتوا
|
||||
register_crud("brand", models.Brand, BrandOut, BrandIn)
|
||||
register_crud("services", models.Service, ServiceOut, ServiceIn)
|
||||
register_crud("projects", models.Project, ProjectOut, ProjectIn)
|
||||
register_crud("articles", models.Article, ArticleOut, ArticleIn)
|
||||
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn)
|
||||
register_crud("faqs", models.Faq, FaqOut, FaqIn)
|
||||
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn)
|
||||
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn)
|
||||
register_crud("stats", models.Stat, StatOut, StatIn)
|
||||
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn)
|
||||
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn)
|
||||
register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch)
|
||||
register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch)
|
||||
register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch)
|
||||
register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch)
|
||||
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch)
|
||||
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
|
||||
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
|
||||
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
|
||||
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch)
|
||||
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
|
||||
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
|
||||
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
|
||||
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn, TimelineItemPatch)
|
||||
register_crud("stats", models.Stat, StatOut, StatIn, StatPatch)
|
||||
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn, WhyUsItemPatch)
|
||||
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn, BeforeAfterPatch)
|
||||
register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn, InvestmentModelPatch)
|
||||
register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn, ProcessStepPatch)
|
||||
register_crud("offices", models.Office, OfficeOut, OfficeIn, OfficePatch)
|
||||
register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn, GalleryImagePatch)
|
||||
register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn, StatusLabelPatch)
|
||||
register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch)
|
||||
register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch)
|
||||
register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
|
||||
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
|
||||
|
||||
|
||||
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمیگرداند تا بکاند ذخیرهسازی قابل تعویض باشد
|
||||
ALLOWED_IMAGE_TYPES = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"image/avif": "avif",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_media(
|
||||
file: UploadFile = File(...),
|
||||
_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
|
||||
data = await file.read()
|
||||
if len(data) == 0:
|
||||
raise HTTPException(status_code=400, detail="فایل خالی است")
|
||||
if len(data) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
|
||||
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
region_name="us-east-1",
|
||||
)
|
||||
try:
|
||||
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
||||
|
||||
return {
|
||||
"url": f"{settings.media_public_base}/{key}",
|
||||
"key": key,
|
||||
"size": len(data),
|
||||
"content_type": content_type,
|
||||
}
|
||||
|
||||
|
||||
# پیامهای تماس
|
||||
|
||||
@@ -37,12 +37,12 @@ def get_brand(db: Session = Depends(get_db)):
|
||||
|
||||
@router.get("/services", response_model=list[ServiceOut])
|
||||
def get_services(db: Session = Depends(get_db)):
|
||||
return db.query(models.Service).all()
|
||||
return db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
|
||||
|
||||
|
||||
@router.get("/projects", response_model=list[ProjectOut])
|
||||
def get_projects(db: Session = Depends(get_db)):
|
||||
return db.query(models.Project).order_by(models.Project.id).all()
|
||||
return db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
||||
|
||||
|
||||
@router.get("/projects/{slug}", response_model=ProjectOut)
|
||||
@@ -55,12 +55,21 @@ def get_project(slug: str, db: Session = Depends(get_db)):
|
||||
|
||||
@router.get("/articles", response_model=list[ArticleOut])
|
||||
def get_articles(db: Session = Depends(get_db)):
|
||||
return db.query(models.Article).order_by(models.Article.id).all()
|
||||
return (
|
||||
db.query(models.Article)
|
||||
.filter(models.Article.status == "published")
|
||||
.order_by(models.Article.position, models.Article.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/articles/{slug}", response_model=ArticleOut)
|
||||
def get_article(slug: str, db: Session = Depends(get_db)):
|
||||
article = db.query(models.Article).filter(models.Article.slug == slug).first()
|
||||
article = (
|
||||
db.query(models.Article)
|
||||
.filter(models.Article.slug == slug, models.Article.status == "published")
|
||||
.first()
|
||||
)
|
||||
if article is None:
|
||||
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||
return article
|
||||
@@ -68,32 +77,32 @@ def get_article(slug: str, db: Session = Depends(get_db)):
|
||||
|
||||
@router.get("/testimonials", response_model=list[TestimonialOut])
|
||||
def get_testimonials(db: Session = Depends(get_db)):
|
||||
return db.query(models.Testimonial).all()
|
||||
return db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all()
|
||||
|
||||
|
||||
@router.get("/faqs", response_model=list[FaqOut])
|
||||
def get_faqs(db: Session = Depends(get_db)):
|
||||
return db.query(models.Faq).all()
|
||||
return db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all()
|
||||
|
||||
|
||||
@router.get("/team", response_model=list[TeamMemberOut])
|
||||
def get_team(db: Session = Depends(get_db)):
|
||||
return db.query(models.TeamMember).all()
|
||||
return db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all()
|
||||
|
||||
|
||||
@router.get("/timeline", response_model=list[TimelineItemOut])
|
||||
def get_timeline(db: Session = Depends(get_db)):
|
||||
return db.query(models.TimelineItem).order_by(models.TimelineItem.id).all()
|
||||
return db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=list[StatOut])
|
||||
def get_stats(db: Session = Depends(get_db)):
|
||||
return db.query(models.Stat).all()
|
||||
return db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all()
|
||||
|
||||
|
||||
@router.get("/why-us", response_model=list[WhyUsItemOut])
|
||||
def get_why_us(db: Session = Depends(get_db)):
|
||||
return db.query(models.WhyUsItem).all()
|
||||
return db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all()
|
||||
|
||||
|
||||
@router.get("/before-after", response_model=BeforeAfterOut)
|
||||
@@ -109,14 +118,32 @@ def get_site(db: Session = Depends(get_db)):
|
||||
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانتاند."""
|
||||
return {
|
||||
"brand": db.query(models.Brand).first(),
|
||||
"services": db.query(models.Service).all(),
|
||||
"projects": db.query(models.Project).order_by(models.Project.id).all(),
|
||||
"articles": db.query(models.Article).order_by(models.Article.id).all(),
|
||||
"testimonials": db.query(models.Testimonial).all(),
|
||||
"faqs": db.query(models.Faq).all(),
|
||||
"team": db.query(models.TeamMember).all(),
|
||||
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(),
|
||||
"stats": db.query(models.Stat).all(),
|
||||
"why_us": db.query(models.WhyUsItem).all(),
|
||||
"nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(),
|
||||
"hero": db.query(models.Hero).first(),
|
||||
"story": db.query(models.StorySection).first(),
|
||||
"section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(),
|
||||
"page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(),
|
||||
"services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(),
|
||||
"projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(),
|
||||
# فقط مقالههای منتشرشده — پیشنویسها از انتهای مدیریتی خوانده میشوند
|
||||
"articles": db.query(models.Article)
|
||||
.filter(models.Article.status == "published")
|
||||
.order_by(models.Article.position, models.Article.id)
|
||||
.all(),
|
||||
"testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(),
|
||||
"faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(),
|
||||
"team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(),
|
||||
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all(),
|
||||
"stats": db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all(),
|
||||
"why_us": db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all(),
|
||||
"before_after": db.query(models.BeforeAfter).first(),
|
||||
"investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.position, models.InvestmentModel.id).all(),
|
||||
"process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.position, models.ProcessStep.id).all(),
|
||||
"offices": db.query(models.Office).order_by(models.Office.position, models.Office.id).all(),
|
||||
"gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.position, models.GalleryImage.id).all(),
|
||||
"status_labels": db.query(models.StatusLabel).order_by(models.StatusLabel.id).all(),
|
||||
"contact_section": db.query(models.ContactSectionContent).first(),
|
||||
"newsletter": db.query(models.NewsletterSectionContent).first(),
|
||||
"footer": db.query(models.FooterContent).first(),
|
||||
"seo_meta": db.query(models.SeoMeta).order_by(models.SeoMeta.id).all(),
|
||||
}
|
||||
|
||||
@@ -18,5 +18,19 @@ class Settings(BaseSettings):
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "aramesh123"
|
||||
|
||||
# Object Storage (MinIO / هر S3 سازگار) برای آپلود تصاویر
|
||||
s3_endpoint: str = "http://localhost:9000"
|
||||
s3_access_key: str = "aramesh"
|
||||
s3_secret_key: str = "aramesh-minio-secret"
|
||||
s3_bucket: str = "aramesh-media"
|
||||
# پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی میکند
|
||||
media_public_base: str = "/media"
|
||||
media_max_upload_mb: int = 10
|
||||
|
||||
# نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
|
||||
site_url: str = ""
|
||||
# مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
|
||||
frontend_dist: str = "../frontend/dist"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -5,8 +5,11 @@ from . import models # noqa: F401 — ثبت مدلها پیش از ساخت
|
||||
from .api import admin, forms, public
|
||||
from .config import settings
|
||||
from .database import Base, engine
|
||||
from .migrations import run_all as run_migrations
|
||||
from .seo_pages import router as seo_router
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
app = FastAPI(
|
||||
title="Aramesh Building Group API",
|
||||
@@ -31,6 +34,8 @@ app.include_router(public.router, prefix="/api", tags=["عمومی"])
|
||||
app.include_router(forms.router, prefix="/api", tags=["فرمها"])
|
||||
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"])
|
||||
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
|
||||
app.include_router(seo_router, tags=["سئو"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""مهاجرتهای سبک دیتابیس — DDL و تبدیل دادههای قدیمی؛ همه idempotent.
|
||||
|
||||
هم main.py (startup) و هم seed.py اینها را صدا میزنند تا هر مسیر اجرایی
|
||||
دیتابیس موجود را بدون از دست رفتن داده ارتقا دهد.
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from .database import SessionLocal, engine
|
||||
|
||||
# جدولهای مجموعهای که ستون position دارند
|
||||
POSITION_TABLES = [
|
||||
"services", "projects", "articles", "testimonials", "faqs", "team",
|
||||
"timeline", "stats", "why_us", "investment_models", "process_steps",
|
||||
"offices", "gallery_images", "nav_items",
|
||||
]
|
||||
|
||||
# ستونهای انگلیسی — (جدول، ستون، نوع). ستونهای JSON بدون default اند تا
|
||||
# سطرهای موجود NULL بگیرند (یعنی «ترجمه ثبت نشده») و فرانت به فارسی برگردد.
|
||||
EN_COLUMNS: list[tuple[str, str, str]] = [
|
||||
("brand", "name_en", "VARCHAR(128)"),
|
||||
("brand", "address_en", "TEXT"),
|
||||
("services", "title_en", "VARCHAR(256)"),
|
||||
("services", "desc_en", "TEXT"),
|
||||
("projects", "title_en", "VARCHAR(256)"),
|
||||
("projects", "location_en", "VARCHAR(128)"),
|
||||
("projects", "year_en", "VARCHAR(64)"),
|
||||
("projects", "area_en", "VARCHAR(64)"),
|
||||
("projects", "floors_en", "VARCHAR(64)"),
|
||||
("projects", "units_en", "VARCHAR(64)"),
|
||||
("projects", "client_en", "VARCHAR(128)"),
|
||||
("projects", "summary_en", "TEXT"),
|
||||
("projects", "body_en", "JSON"),
|
||||
("articles", "title_en", "VARCHAR(256)"),
|
||||
("articles", "date_en", "VARCHAR(64)"),
|
||||
("articles", "category_en", "VARCHAR(64)"),
|
||||
("articles", "read_en", "VARCHAR(32)"),
|
||||
("articles", "excerpt_en", "TEXT"),
|
||||
("articles", "body_en", "JSON"),
|
||||
("testimonials", "name_en", "VARCHAR(128)"),
|
||||
("testimonials", "role_en", "VARCHAR(128)"),
|
||||
("testimonials", "quote_en", "TEXT"),
|
||||
("faqs", "question_en", "TEXT"),
|
||||
("faqs", "answer_en", "TEXT"),
|
||||
("team", "name_en", "VARCHAR(128)"),
|
||||
("team", "role_en", "VARCHAR(128)"),
|
||||
("team", "bio_en", "TEXT"),
|
||||
("timeline", "year_en", "VARCHAR(32)"),
|
||||
("timeline", "title_en", "VARCHAR(256)"),
|
||||
("timeline", "desc_en", "TEXT"),
|
||||
("stats", "suffix_en", "VARCHAR(16)"),
|
||||
("stats", "label_en", "VARCHAR(128)"),
|
||||
("why_us", "title_en", "VARCHAR(256)"),
|
||||
("why_us", "desc_en", "TEXT"),
|
||||
("before_after", "title_en", "VARCHAR(256)"),
|
||||
("before_after", "lead_en", "TEXT"),
|
||||
("before_after", "before_label_en", "VARCHAR(128)"),
|
||||
("before_after", "after_label_en", "VARCHAR(128)"),
|
||||
("nav_items", "label_en", "VARCHAR(64)"),
|
||||
("hero", "kicker_en", "TEXT"),
|
||||
("hero", "title_en", "TEXT"),
|
||||
("hero", "title_highlight_en", "VARCHAR(128)"),
|
||||
("hero", "subtitle_en", "TEXT"),
|
||||
("hero", "badge_value_en", "VARCHAR(32)"),
|
||||
("hero", "badge_label_en", "TEXT"),
|
||||
("hero", "cta_primary_label_en", "VARCHAR(128)"),
|
||||
("hero", "cta_secondary_label_en", "VARCHAR(128)"),
|
||||
("hero", "scroll_label_en", "VARCHAR(64)"),
|
||||
("story", "title_en", "VARCHAR(256)"),
|
||||
("story", "paragraphs_en", "JSON"),
|
||||
("story", "quote_en", "TEXT"),
|
||||
("story", "quote_author_en", "TEXT"),
|
||||
("story", "stat1_value_en", "VARCHAR(32)"),
|
||||
("story", "stat1_label_en", "VARCHAR(128)"),
|
||||
("story", "stat2_value_en", "VARCHAR(32)"),
|
||||
("story", "stat2_label_en", "VARCHAR(128)"),
|
||||
("story", "link_label_en", "VARCHAR(128)"),
|
||||
("story", "bento_en", "JSON"),
|
||||
("section_headings", "label_en", "VARCHAR(64)"),
|
||||
("section_headings", "title_en", "VARCHAR(256)"),
|
||||
("section_headings", "lead_en", "TEXT"),
|
||||
("section_headings", "cta_label_en", "VARCHAR(128)"),
|
||||
("page_heroes", "title_en", "VARCHAR(256)"),
|
||||
("page_heroes", "lead_en", "TEXT"),
|
||||
("investment_models", "title_en", "VARCHAR(256)"),
|
||||
("investment_models", "desc_en", "TEXT"),
|
||||
("investment_models", "points_en", "JSON"),
|
||||
("process_steps", "step_en", "VARCHAR(16)"),
|
||||
("process_steps", "title_en", "VARCHAR(256)"),
|
||||
("process_steps", "desc_en", "TEXT"),
|
||||
("offices", "city_en", "VARCHAR(64)"),
|
||||
("offices", "address_en", "TEXT"),
|
||||
("offices", "hours_en", "VARCHAR(128)"),
|
||||
("gallery_images", "alt_en", "TEXT"),
|
||||
("status_labels", "label_en", "VARCHAR(64)"),
|
||||
("contact_section", "label_en", "VARCHAR(64)"),
|
||||
("contact_section", "title_en", "VARCHAR(256)"),
|
||||
("contact_section", "lead_en", "TEXT"),
|
||||
("contact_section", "address_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "phone_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "email_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "name_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "name_placeholder_en", "VARCHAR(128)"),
|
||||
("contact_section", "phone_field_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "phone_placeholder_en", "VARCHAR(64)"),
|
||||
("contact_section", "note_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "note_optional_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "note_placeholder_en", "VARCHAR(256)"),
|
||||
("contact_section", "submit_label_en", "VARCHAR(128)"),
|
||||
("contact_section", "sending_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "success_title_en", "VARCHAR(256)"),
|
||||
("contact_section", "success_text_en", "TEXT"),
|
||||
("contact_section", "success_button_label_en", "VARCHAR(128)"),
|
||||
("newsletter", "title_en", "VARCHAR(256)"),
|
||||
("newsletter", "lead_en", "TEXT"),
|
||||
("newsletter", "placeholder_en", "VARCHAR(128)"),
|
||||
("newsletter", "button_label_en", "VARCHAR(64)"),
|
||||
("newsletter", "sending_label_en", "VARCHAR(64)"),
|
||||
("newsletter", "success_text_en", "VARCHAR(256)"),
|
||||
("footer", "about_text_en", "TEXT"),
|
||||
("footer", "pages_heading_en", "VARCHAR(64)"),
|
||||
("footer", "services_heading_en", "VARCHAR(64)"),
|
||||
("footer", "contact_heading_en", "VARCHAR(64)"),
|
||||
("footer", "copyright_en", "VARCHAR(256)"),
|
||||
("footer", "tagline_en", "VARCHAR(256)"),
|
||||
("footer", "socials_en", "JSON"),
|
||||
("seo_meta", "title_en", "VARCHAR(256)"),
|
||||
("seo_meta", "description_en", "TEXT"),
|
||||
]
|
||||
|
||||
|
||||
def run_ddl_migrations() -> None:
|
||||
"""ستونهای جدید — برای PostgreSQL با ADD COLUMN IF NOT EXISTS."""
|
||||
if engine.dialect.name != "postgresql":
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
for table in POSITION_TABLES:
|
||||
conn.execute(
|
||||
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0")
|
||||
)
|
||||
# مقالات موجود منتشر شدهاند؛ فقط مقالههای تازه میتوانند پیشنویس باشند
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'published'")
|
||||
)
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
||||
)
|
||||
# ستونهای انگلیسی — رشتهها با پیشفرض خالی، JSON بدون پیشفرض (NULL = بدون ترجمه)
|
||||
for table, column, col_type in EN_COLUMNS:
|
||||
if col_type == "JSON":
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} JSON"))
|
||||
else:
|
||||
conn.execute(
|
||||
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} {col_type} NOT NULL DEFAULT ''")
|
||||
)
|
||||
|
||||
|
||||
def paragraphs_to_doc(paragraphs: list) -> dict:
|
||||
"""تبدیل لیست پاراگرافهای متنی (فرمت قدیمی بدنه مقاله) به سند TipTap."""
|
||||
content = []
|
||||
for para in paragraphs:
|
||||
if isinstance(para, str) and para.strip():
|
||||
content.append({
|
||||
"type": "paragraph",
|
||||
"content": [{"type": "text", "text": para}],
|
||||
})
|
||||
if not content:
|
||||
content = [{"type": "paragraph"}]
|
||||
return {"type": "doc", "content": content}
|
||||
|
||||
|
||||
def migrate_article_bodies() -> None:
|
||||
"""تبدیل بدنه مقالات از لیست پاراگراف به سند JSON — idempotent."""
|
||||
from . import models # lazy — جلوگیری از import حلقهای
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
changed = 0
|
||||
for article in db.query(models.Article).all():
|
||||
if isinstance(article.body, list):
|
||||
article.body = paragraphs_to_doc(article.body)
|
||||
changed += 1
|
||||
db.commit()
|
||||
if changed:
|
||||
print(f"مقالات تبدیلشده به فرمت سند: {changed}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _is_empty(value: object) -> bool:
|
||||
"""مقدار خالی انگلیسی — یعنی هنوز ترجمهای ثبت نشده و قابل پر کردن است."""
|
||||
if value is None or value == "" or value == [] or value == {}:
|
||||
return True
|
||||
if isinstance(value, dict) and value.get("content") == [{"type": "paragraph"}]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _backfill_row(row, en_fields: dict) -> int:
|
||||
"""پر کردن فیلدهای خالی *_en یک سطر از دیکشنری ترجمه — تعداد فیلدهای پرشده."""
|
||||
filled = 0
|
||||
for key, value in en_fields.items():
|
||||
if not key.endswith("_en"):
|
||||
continue
|
||||
if not _is_empty(getattr(row, key, None)):
|
||||
continue
|
||||
setattr(row, key, value)
|
||||
filled += 1
|
||||
return filled
|
||||
|
||||
|
||||
def backfill_en_content() -> None:
|
||||
"""پر کردن ترجمههای انگلیسی سطرهای موجود از seed_data_en — idempotent.
|
||||
|
||||
تطبیق سطرها: جدولهای تکسطری مستقیم؛ بقیه با کلیدهای طبیعی/متنی فارسی
|
||||
(مثلاً slug پروژه، href منو، عنوان خدمت، متن پرسش) — اگر ادمین متن فارسی را
|
||||
ویرایش کرده باشد سطر تطبیق نمیخورد و ترجمهاش خالی میماند تا از پنل پر شود.
|
||||
هیچ مقدار انگلیسیِ ثبتشده بازنویسی نمیشود.
|
||||
"""
|
||||
from . import models, seed_data, seed_data_en # lazy — جلوگیری از import حلقهای
|
||||
|
||||
# (مدل، فهرست فارسی، فهرست انگلیسی، کلیدهای تطبیق — خالی برای تکسطریها)
|
||||
PLAN: list[tuple[object, list[dict], list[dict], tuple[str, ...]]] = [
|
||||
(models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], ()),
|
||||
(models.Hero, [seed_data.HERO], [seed_data_en.HERO], ()),
|
||||
(models.StorySection, [seed_data.STORY], [seed_data_en.STORY], ()),
|
||||
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, ("key", "title")),
|
||||
(models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, ("key", "title")),
|
||||
(models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, ("href", "label")),
|
||||
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, ("title",)),
|
||||
(models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, ("title",)),
|
||||
(models.Office, seed_data.OFFICES, seed_data_en.OFFICES, ("city",)),
|
||||
(models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, ("alt",)),
|
||||
(models.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, ("key",)),
|
||||
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], ()),
|
||||
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], ()),
|
||||
(models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], ()),
|
||||
(models.Service, seed_data.SERVICES, seed_data_en.SERVICES, ("title",)),
|
||||
(models.Stat, seed_data.STATS, seed_data_en.STATS, ("label",)),
|
||||
(models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, ("slug", "title")),
|
||||
(models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, ("slug", "title")),
|
||||
(models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, ("name",)),
|
||||
(models.Faq, seed_data.FAQS, seed_data_en.FAQS, ("question",)),
|
||||
(models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, ("name",)),
|
||||
(models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, ("title",)),
|
||||
(models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, ("title",)),
|
||||
(models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], ()),
|
||||
(models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, ("page_key",)),
|
||||
]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
total_rows = 0
|
||||
total_fields = 0
|
||||
unmatched: list[str] = []
|
||||
for model, fa_rows, en_rows, match_keys in PLAN:
|
||||
rows = db.query(model).all()
|
||||
if not rows or not en_rows:
|
||||
continue
|
||||
table_name = getattr(model, "__tablename__", str(model))
|
||||
|
||||
if not match_keys:
|
||||
if len(rows) != 1 or len(en_rows) != 1:
|
||||
unmatched.append(table_name)
|
||||
continue
|
||||
pairs = [(rows[0], en_rows[0])]
|
||||
else:
|
||||
# برای هر کلید، نگاشت مقدار → سطر ترجمه (اولین کلید برنده است)
|
||||
en_lookups: list[dict[object, dict]] = []
|
||||
for key in match_keys:
|
||||
en_lookups.append({r[key]: r for r in en_rows if r.get(key) is not None})
|
||||
pairs = []
|
||||
matched_en_ids: set[int] = set()
|
||||
for row in rows:
|
||||
en_row = None
|
||||
for key, lookup in zip(match_keys, en_lookups):
|
||||
en_row = lookup.get(getattr(row, key, None))
|
||||
if en_row is not None:
|
||||
break
|
||||
if en_row is not None and id(en_row) not in matched_en_ids:
|
||||
matched_en_ids.add(id(en_row))
|
||||
pairs.append((row, en_row))
|
||||
|
||||
for row, en_row in pairs:
|
||||
# بدنه مقاله انگلیسی هم مثل فارسی از پاراگراف به سند تبدیل میشود
|
||||
en_fields = dict(en_row)
|
||||
if model is models.Article and isinstance(en_fields.get("body_en"), list):
|
||||
en_fields["body_en"] = paragraphs_to_doc(en_fields["body_en"])
|
||||
filled = _backfill_row(row, en_fields)
|
||||
total_fields += filled
|
||||
if filled:
|
||||
total_rows += 1
|
||||
|
||||
db.commit()
|
||||
if total_fields:
|
||||
print(f"ترجمه انگلیسی پر شد: {total_fields} فیلد در {total_rows} سطر.")
|
||||
if unmatched:
|
||||
print("سطرهایی که با seed تطبیق نکردند (از پنل مدیریت ترجمه کنید):")
|
||||
for name in unmatched:
|
||||
print(f" - {name}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def run_all() -> None:
|
||||
run_ddl_migrations()
|
||||
migrate_article_bodies()
|
||||
backfill_en_content()
|
||||
@@ -10,6 +10,10 @@ def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ستونهای *_en معادل انگلیسی فیلدهای متنیِ قابل نمایش هستند؛
|
||||
# مقدار خالی یعنی «ترجمهای ثبت نشده» و فرانتاند به نسخه فارسی برمیگردد.
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
@@ -23,6 +27,7 @@ class Brand(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), default="")
|
||||
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
logo: Mapped[str] = mapped_column(Text, default="")
|
||||
hero: Mapped[str] = mapped_column(Text, default="")
|
||||
contact_bg: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -30,101 +35,156 @@ class Brand(Base):
|
||||
phone_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
email: Mapped[str] = mapped_column(String(128), default="")
|
||||
address: Mapped[str] = mapped_column(Text, default="")
|
||||
address_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Service(Base):
|
||||
__tablename__ = "services"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
icon: Mapped[str] = mapped_column(String(32), default="draft")
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
location: Mapped[str] = mapped_column(String(128), default="")
|
||||
location_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
year: Mapped[str] = mapped_column(String(64), default="")
|
||||
year_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
status: Mapped[str] = mapped_column(String(16), default="done") # done | progress
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
gallery: Mapped[list] = mapped_column(JSON, default=list)
|
||||
area: Mapped[str] = mapped_column(String(64), default="")
|
||||
area_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
floors: Mapped[str] = mapped_column(String(64), default="")
|
||||
floors_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
units: Mapped[str] = mapped_column(String(64), default="")
|
||||
units_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
client: Mapped[str] = mapped_column(String(128), default="")
|
||||
client_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
summary_en: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||
body_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class Article(Base):
|
||||
__tablename__ = "articles"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
date: Mapped[str] = mapped_column(String(64), default="")
|
||||
date_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
category: Mapped[str] = mapped_column(String(64), default="")
|
||||
category_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
read: Mapped[str] = mapped_column(String(32), default="")
|
||||
read_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
excerpt: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||
excerpt_en: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
|
||||
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
|
||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||
seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class Testimonial(Base):
|
||||
__tablename__ = "testimonials"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
role: Mapped[str] = mapped_column(String(128), default="")
|
||||
role_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
quote: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Faq(Base):
|
||||
__tablename__ = "faqs"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
question: Mapped[str] = mapped_column(Text)
|
||||
question_en: Mapped[str] = mapped_column(Text, default="")
|
||||
answer: Mapped[str] = mapped_column(Text, default="")
|
||||
answer_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class TeamMember(Base):
|
||||
__tablename__ = "team"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
role: Mapped[str] = mapped_column(String(128), default="")
|
||||
role_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
bio: Mapped[str] = mapped_column(Text, default="")
|
||||
bio_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class TimelineItem(Base):
|
||||
__tablename__ = "timeline"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
year: Mapped[str] = mapped_column(String(32))
|
||||
year_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Stat(Base):
|
||||
__tablename__ = "stats"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
value: Mapped[int] = mapped_column(Integer)
|
||||
suffix: Mapped[str] = mapped_column(String(16), default="")
|
||||
suffix_en: Mapped[str] = mapped_column(String(16), default="")
|
||||
label: Mapped[str] = mapped_column(String(128))
|
||||
label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class WhyUsItem(Base):
|
||||
__tablename__ = "why_us"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class BeforeAfter(Base):
|
||||
@@ -132,11 +192,279 @@ class BeforeAfter(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
before: Mapped[str] = mapped_column(Text, default="")
|
||||
after: Mapped[str] = mapped_column(Text, default="")
|
||||
before_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
before_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
after_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
after_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class NavItem(Base):
|
||||
__tablename__ = "nav_items"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
label: Mapped[str] = mapped_column(String(64))
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
href: Mapped[str] = mapped_column(String(128))
|
||||
|
||||
|
||||
class Hero(Base):
|
||||
"""محتوای هیرو صفحه اصلی — رکورد یگانه."""
|
||||
|
||||
__tablename__ = "hero"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
kicker: Mapped[str] = mapped_column(Text, default="")
|
||||
kicker_en: Mapped[str] = mapped_column(Text, default="")
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
title_en: Mapped[str] = mapped_column(Text, default="")
|
||||
title_highlight: Mapped[str] = mapped_column(String(128), default="")
|
||||
title_highlight_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
subtitle: Mapped[str] = mapped_column(Text, default="")
|
||||
subtitle_en: Mapped[str] = mapped_column(Text, default="")
|
||||
badge_value: Mapped[str] = mapped_column(String(32), default="")
|
||||
badge_value_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
badge_label: Mapped[str] = mapped_column(Text, default="")
|
||||
badge_label_en: Mapped[str] = mapped_column(Text, default="")
|
||||
cta_primary_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_primary_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_primary_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_secondary_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_secondary_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_secondary_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
scroll_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
scroll_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
||||
|
||||
class StorySection(Base):
|
||||
"""بخش «درباره ما» در خانه و صفحه درباره — رکورد یگانه."""
|
||||
|
||||
__tablename__ = "story"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
paragraphs: Mapped[list] = mapped_column(JSON, default=list)
|
||||
paragraphs_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
quote: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_en: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_author: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_author_en: Mapped[str] = mapped_column(Text, default="")
|
||||
stat1_value: Mapped[str] = mapped_column(String(32), default="")
|
||||
stat1_value_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
stat1_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
stat1_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
stat2_value: Mapped[str] = mapped_column(String(32), default="")
|
||||
stat2_value_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
stat2_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
stat2_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
link_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
link_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
link_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
bento: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}]
|
||||
bento_en: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}] انگلیسی
|
||||
|
||||
|
||||
class SectionHeadingContent(Base):
|
||||
"""تیتر و توضیح بخشها — فرانتاند بر اساس key آن را پیدا میکند."""
|
||||
|
||||
__tablename__ = "section_headings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
label: Mapped[str] = mapped_column(String(64), default="")
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
cta_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class PageHeroContent(Base):
|
||||
"""هیرو بالای هر صفحه — key: about | services | projects | investment | blog | faq | contact."""
|
||||
|
||||
__tablename__ = "page_heroes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class InvestmentModel(Base):
|
||||
__tablename__ = "investment_models"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
points: Mapped[list] = mapped_column(JSON, default=list)
|
||||
points_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class ProcessStep(Base):
|
||||
__tablename__ = "process_steps"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
step: Mapped[str] = mapped_column(String(16), default="")
|
||||
step_en: Mapped[str] = mapped_column(String(16), default="")
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Office(Base):
|
||||
__tablename__ = "offices"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
city: Mapped[str] = mapped_column(String(64))
|
||||
city_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
address: Mapped[str] = mapped_column(Text, default="")
|
||||
address_en: Mapped[str] = mapped_column(Text, default="")
|
||||
hours: Mapped[str] = mapped_column(String(128), default="")
|
||||
hours_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class GalleryImage(Base):
|
||||
__tablename__ = "gallery_images"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
src: Mapped[str] = mapped_column(Text, default="")
|
||||
alt: Mapped[str] = mapped_column(Text, default="")
|
||||
alt_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class StatusLabel(Base):
|
||||
"""برچسب فارسی وضعیت پروژه — key: done | progress."""
|
||||
|
||||
__tablename__ = "status_labels"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(16), unique=True, index=True)
|
||||
label: Mapped[str] = mapped_column(String(64))
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
||||
|
||||
class ContactSectionContent(Base):
|
||||
"""بخش فرم تماس — رکورد یگانه."""
|
||||
|
||||
__tablename__ = "contact_section"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
label: Mapped[str] = mapped_column(String(64), default="")
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
address_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
address_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
email_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
email_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
name_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
name_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
name_placeholder: Mapped[str] = mapped_column(String(128), default="")
|
||||
name_placeholder_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
phone_field_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_field_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_placeholder: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_placeholder_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_optional_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_optional_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_placeholder: Mapped[str] = mapped_column(String(256), default="")
|
||||
note_placeholder_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
submit_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
submit_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
sending_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
sending_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
success_title: Mapped[str] = mapped_column(String(256), default="")
|
||||
success_title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
success_text: Mapped[str] = mapped_column(Text, default="")
|
||||
success_text_en: Mapped[str] = mapped_column(Text, default="")
|
||||
success_button_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
success_button_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class NewsletterSectionContent(Base):
|
||||
"""بخش خبرنامه — رکورد یگانه."""
|
||||
|
||||
__tablename__ = "newsletter"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
placeholder: Mapped[str] = mapped_column(String(128), default="")
|
||||
placeholder_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
button_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
button_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
sending_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
sending_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
success_text: Mapped[str] = mapped_column(String(256), default="")
|
||||
success_text_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
|
||||
|
||||
class FooterContent(Base):
|
||||
"""متنهای فوتر — رکورد یگانه."""
|
||||
|
||||
__tablename__ = "footer"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
about_text: Mapped[str] = mapped_column(Text, default="")
|
||||
about_text_en: Mapped[str] = mapped_column(Text, default="")
|
||||
pages_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||
pages_heading_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
services_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||
services_heading_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
contact_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||
contact_heading_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
copyright: Mapped[str] = mapped_column(String(256), default="")
|
||||
copyright_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
tagline: Mapped[str] = mapped_column(String(256), default="")
|
||||
tagline_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}]
|
||||
socials_en: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}] انگلیسی
|
||||
|
||||
|
||||
class SeoMeta(Base):
|
||||
"""عنوان و توضیح سئوی هر صفحه — page_key: home / about / services / ..."""
|
||||
|
||||
__tablename__ = "seo_meta"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
description_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class ContactMessage(Base):
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login.
|
||||
|
||||
پیشفرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک میکند.
|
||||
شمارنده در حافظه همین پروسه است — با ریاستارت بکاند پاک میشود و همین کافی است.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
MAX_FAILED_ATTEMPTS = 5
|
||||
WINDOW_SECONDS = 15 * 60
|
||||
|
||||
_failed: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _prune(key: str, now: float) -> list[float]:
|
||||
recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS]
|
||||
_failed[key] = recent
|
||||
return recent
|
||||
|
||||
|
||||
def ensure_allowed(request: Request) -> None:
|
||||
"""اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد میشود."""
|
||||
key = client_ip(request)
|
||||
recent = _prune(key, time.monotonic())
|
||||
if len(recent) >= MAX_FAILED_ATTEMPTS:
|
||||
retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0])))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="تلاشهای ناموفق زیاد است — بعداً دوباره امتحان کنید",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
|
||||
def record_failure(request: Request) -> None:
|
||||
_failed.setdefault(client_ip(request), []).append(time.monotonic())
|
||||
|
||||
|
||||
def clear(request: Request) -> None:
|
||||
"""با ورود موفق، تاریخچه تلاشهای ناموفق IP پاک میشود."""
|
||||
_failed.pop(client_ip(request), None)
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
|
||||
|
||||
# ---------- پایه ----------
|
||||
|
||||
@@ -10,12 +10,16 @@ class ORMModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# فیلدهای *_en معادل انگلیسیاند؛ خالی بودنشان یعنی «بدون ترجمه».
|
||||
|
||||
|
||||
# ---------- خروجیها ----------
|
||||
|
||||
|
||||
class BrandOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str = ""
|
||||
logo: str
|
||||
hero: str
|
||||
contact_bg: str
|
||||
@@ -23,12 +27,15 @@ class BrandOut(ORMModel):
|
||||
phone_href: str
|
||||
email: str
|
||||
address: str
|
||||
address_en: str = ""
|
||||
|
||||
|
||||
class ServiceOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
icon: str
|
||||
|
||||
|
||||
@@ -36,56 +43,84 @@ class ProjectOut(ORMModel):
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
location: str
|
||||
location_en: str = ""
|
||||
year: str
|
||||
year_en: str = ""
|
||||
status: str
|
||||
image: str
|
||||
gallery: list[str]
|
||||
area: str
|
||||
area_en: str = ""
|
||||
floors: str
|
||||
floors_en: str = ""
|
||||
units: str
|
||||
units_en: str = ""
|
||||
client: str
|
||||
client_en: str = ""
|
||||
summary: str
|
||||
summary_en: str = ""
|
||||
body: list[str]
|
||||
body_en: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ArticleOut(ORMModel):
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
date: str
|
||||
date_en: str = ""
|
||||
category: str
|
||||
category_en: str = ""
|
||||
image: str
|
||||
read: str
|
||||
read_en: str = ""
|
||||
excerpt: str
|
||||
body: list[str]
|
||||
excerpt_en: str = ""
|
||||
body: dict
|
||||
body_en: Optional[dict] = None
|
||||
status: str
|
||||
seo_keyphrase: str
|
||||
|
||||
|
||||
class TestimonialOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str = ""
|
||||
role: str
|
||||
role_en: str = ""
|
||||
quote: str
|
||||
quote_en: str = ""
|
||||
|
||||
|
||||
class FaqOut(ORMModel):
|
||||
id: int
|
||||
question: str
|
||||
question_en: str = ""
|
||||
answer: str
|
||||
answer_en: str = ""
|
||||
|
||||
|
||||
class TeamMemberOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str = ""
|
||||
role: str
|
||||
role_en: str = ""
|
||||
bio: str
|
||||
bio_en: str = ""
|
||||
|
||||
|
||||
class TimelineItemOut(ORMModel):
|
||||
id: int
|
||||
year: str
|
||||
year_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
image: str
|
||||
|
||||
|
||||
@@ -93,23 +128,239 @@ class StatOut(ORMModel):
|
||||
id: int
|
||||
value: int
|
||||
suffix: str
|
||||
suffix_en: str = ""
|
||||
label: str
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class WhyUsItemOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class BeforeAfterOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
before: str
|
||||
after: str
|
||||
before_label: str
|
||||
before_label_en: str = ""
|
||||
after_label: str
|
||||
after_label_en: str = ""
|
||||
|
||||
|
||||
class NavItemOut(ORMModel):
|
||||
id: int
|
||||
label: str
|
||||
label_en: str = ""
|
||||
href: str
|
||||
|
||||
|
||||
class HeroOut(ORMModel):
|
||||
id: int
|
||||
kicker: str
|
||||
kicker_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
title_highlight: str
|
||||
title_highlight_en: str = ""
|
||||
subtitle: str
|
||||
subtitle_en: str = ""
|
||||
badge_value: str
|
||||
badge_value_en: str = ""
|
||||
badge_label: str
|
||||
badge_label_en: str = ""
|
||||
cta_primary_label: str
|
||||
cta_primary_label_en: str = ""
|
||||
cta_primary_href: str
|
||||
cta_secondary_label: str
|
||||
cta_secondary_label_en: str = ""
|
||||
cta_secondary_href: str
|
||||
scroll_label: str
|
||||
scroll_label_en: str = ""
|
||||
|
||||
|
||||
class StoryOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
paragraphs: list[str]
|
||||
paragraphs_en: Optional[list[str]] = None
|
||||
quote: str
|
||||
quote_en: str = ""
|
||||
quote_author: str
|
||||
quote_author_en: str = ""
|
||||
stat1_value: str
|
||||
stat1_value_en: str = ""
|
||||
stat1_label: str
|
||||
stat1_label_en: str = ""
|
||||
stat2_value: str
|
||||
stat2_value_en: str = ""
|
||||
stat2_label: str
|
||||
stat2_label_en: str = ""
|
||||
link_label: str
|
||||
link_label_en: str = ""
|
||||
link_href: str
|
||||
bento: list[dict]
|
||||
bento_en: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class SectionHeadingOut(ORMModel):
|
||||
id: int
|
||||
key: str
|
||||
label: str
|
||||
label_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
image: str
|
||||
cta_label: str
|
||||
cta_label_en: str = ""
|
||||
cta_href: str
|
||||
|
||||
|
||||
class PageHeroOut(ORMModel):
|
||||
id: int
|
||||
key: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
image: str
|
||||
|
||||
|
||||
class InvestmentModelOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
points: list[str]
|
||||
points_en: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ProcessStepOut(ORMModel):
|
||||
id: int
|
||||
step: str
|
||||
step_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class OfficeOut(ORMModel):
|
||||
id: int
|
||||
city: str
|
||||
city_en: str = ""
|
||||
address: str
|
||||
address_en: str = ""
|
||||
hours: str
|
||||
hours_en: str = ""
|
||||
|
||||
|
||||
class GalleryImageOut(ORMModel):
|
||||
id: int
|
||||
src: str
|
||||
alt: str
|
||||
alt_en: str = ""
|
||||
|
||||
|
||||
class StatusLabelOut(ORMModel):
|
||||
id: int
|
||||
key: str
|
||||
label: str
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class ContactSectionOut(ORMModel):
|
||||
id: int
|
||||
label: str
|
||||
label_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
address_label: str
|
||||
address_label_en: str = ""
|
||||
phone_label: str
|
||||
phone_label_en: str = ""
|
||||
email_label: str
|
||||
email_label_en: str = ""
|
||||
name_label: str
|
||||
name_label_en: str = ""
|
||||
name_placeholder: str
|
||||
name_placeholder_en: str = ""
|
||||
phone_field_label: str
|
||||
phone_field_label_en: str = ""
|
||||
phone_placeholder: str
|
||||
phone_placeholder_en: str = ""
|
||||
note_label: str
|
||||
note_label_en: str = ""
|
||||
note_optional_label: str
|
||||
note_optional_label_en: str = ""
|
||||
note_placeholder: str
|
||||
note_placeholder_en: str = ""
|
||||
submit_label: str
|
||||
submit_label_en: str = ""
|
||||
sending_label: str
|
||||
sending_label_en: str = ""
|
||||
success_title: str
|
||||
success_title_en: str = ""
|
||||
success_text: str
|
||||
success_text_en: str = ""
|
||||
success_button_label: str
|
||||
success_button_label_en: str = ""
|
||||
|
||||
|
||||
class NewsletterOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
placeholder: str
|
||||
placeholder_en: str = ""
|
||||
button_label: str
|
||||
button_label_en: str = ""
|
||||
sending_label: str
|
||||
sending_label_en: str = ""
|
||||
success_text: str
|
||||
success_text_en: str = ""
|
||||
|
||||
|
||||
class FooterOut(ORMModel):
|
||||
id: int
|
||||
about_text: str
|
||||
about_text_en: str = ""
|
||||
pages_heading: str
|
||||
pages_heading_en: str = ""
|
||||
services_heading: str
|
||||
services_heading_en: str = ""
|
||||
contact_heading: str
|
||||
contact_heading_en: str = ""
|
||||
copyright: str
|
||||
copyright_en: str = ""
|
||||
tagline: str
|
||||
tagline_en: str = ""
|
||||
socials: list[dict]
|
||||
socials_en: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class SeoMetaOut(ORMModel):
|
||||
id: int
|
||||
page_key: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
description: str
|
||||
description_en: str = ""
|
||||
|
||||
|
||||
class ContactMessageOut(ORMModel):
|
||||
@@ -154,8 +405,14 @@ class TokenOut(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class BrandIn(BaseModel):
|
||||
name: str = ""
|
||||
name_en: str = ""
|
||||
logo: str = ""
|
||||
hero: str = ""
|
||||
contact_bg: str = ""
|
||||
@@ -163,84 +420,398 @@ class BrandIn(BaseModel):
|
||||
phone_href: str = ""
|
||||
email: str = ""
|
||||
address: str = ""
|
||||
address_en: str = ""
|
||||
|
||||
|
||||
class ServiceIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
icon: str = "draft"
|
||||
|
||||
|
||||
class ProjectIn(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=128)
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
location: str = ""
|
||||
location_en: str = ""
|
||||
year: str = ""
|
||||
year_en: str = ""
|
||||
status: Literal["done", "progress"] = "done"
|
||||
image: str = ""
|
||||
gallery: list[str] = []
|
||||
area: str = ""
|
||||
area_en: str = ""
|
||||
floors: str = ""
|
||||
floors_en: str = ""
|
||||
units: str = ""
|
||||
units_en: str = ""
|
||||
client: str = ""
|
||||
client_en: str = ""
|
||||
summary: str = ""
|
||||
summary_en: str = ""
|
||||
body: list[str] = []
|
||||
body_en: list[str] = []
|
||||
|
||||
|
||||
EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
|
||||
def validate_doc_body(value: object) -> dict:
|
||||
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد."""
|
||||
if not value:
|
||||
return dict(EMPTY_DOC)
|
||||
if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list):
|
||||
raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}")
|
||||
return value
|
||||
|
||||
|
||||
class ArticleIn(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=128)
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
date: str = ""
|
||||
date_en: str = ""
|
||||
category: str = ""
|
||||
category_en: str = ""
|
||||
image: str = ""
|
||||
read: str = ""
|
||||
read_en: str = ""
|
||||
excerpt: str = ""
|
||||
body: list[str] = []
|
||||
excerpt_en: str = ""
|
||||
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||
status: Literal["draft", "published"] = "draft"
|
||||
seo_keyphrase: str = Field(default="", max_length=128)
|
||||
_validate_body = field_validator("body")(validate_doc_body)
|
||||
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||
|
||||
|
||||
class TestimonialIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
name_en: str = ""
|
||||
role: str = ""
|
||||
role_en: str = ""
|
||||
quote: str = ""
|
||||
quote_en: str = ""
|
||||
|
||||
|
||||
class FaqIn(BaseModel):
|
||||
question: str = Field(min_length=1)
|
||||
question_en: str = ""
|
||||
answer: str = ""
|
||||
answer_en: str = ""
|
||||
|
||||
|
||||
class TeamMemberIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
name_en: str = ""
|
||||
role: str = ""
|
||||
role_en: str = ""
|
||||
bio: str = ""
|
||||
bio_en: str = ""
|
||||
|
||||
|
||||
class TimelineItemIn(BaseModel):
|
||||
year: str = Field(min_length=1, max_length=32)
|
||||
year_en: str = ""
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
image: str = ""
|
||||
|
||||
|
||||
class StatIn(BaseModel):
|
||||
value: int
|
||||
suffix: str = ""
|
||||
suffix_en: str = ""
|
||||
label: str = Field(min_length=1, max_length=128)
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class WhyUsItemIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class BeforeAfterIn(BaseModel):
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
before: str = ""
|
||||
after: str = ""
|
||||
before_label: str = ""
|
||||
before_label_en: str = ""
|
||||
after_label: str = ""
|
||||
after_label_en: str = ""
|
||||
|
||||
|
||||
class NavItemIn(BaseModel):
|
||||
label: str = Field(min_length=1, max_length=64)
|
||||
label_en: str = ""
|
||||
href: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class HeroIn(BaseModel):
|
||||
kicker: str = ""
|
||||
kicker_en: str = ""
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
title_highlight: str = ""
|
||||
title_highlight_en: str = ""
|
||||
subtitle: str = ""
|
||||
subtitle_en: str = ""
|
||||
badge_value: str = ""
|
||||
badge_value_en: str = ""
|
||||
badge_label: str = ""
|
||||
badge_label_en: str = ""
|
||||
cta_primary_label: str = ""
|
||||
cta_primary_label_en: str = ""
|
||||
cta_primary_href: str = ""
|
||||
cta_secondary_label: str = ""
|
||||
cta_secondary_label_en: str = ""
|
||||
cta_secondary_href: str = ""
|
||||
scroll_label: str = ""
|
||||
scroll_label_en: str = ""
|
||||
|
||||
|
||||
class StoryIn(BaseModel):
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
paragraphs: list[str] = []
|
||||
paragraphs_en: list[str] = []
|
||||
quote: str = ""
|
||||
quote_en: str = ""
|
||||
quote_author: str = ""
|
||||
quote_author_en: str = ""
|
||||
stat1_value: str = ""
|
||||
stat1_value_en: str = ""
|
||||
stat1_label: str = ""
|
||||
stat1_label_en: str = ""
|
||||
stat2_value: str = ""
|
||||
stat2_value_en: str = ""
|
||||
stat2_label: str = ""
|
||||
stat2_label_en: str = ""
|
||||
link_label: str = ""
|
||||
link_label_en: str = ""
|
||||
link_href: str = ""
|
||||
bento: list[dict] = []
|
||||
bento_en: list[dict] = []
|
||||
|
||||
|
||||
class SectionHeadingIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=64)
|
||||
label: str = ""
|
||||
label_en: str = ""
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
image: str = ""
|
||||
cta_label: str = ""
|
||||
cta_label_en: str = ""
|
||||
cta_href: str = ""
|
||||
|
||||
|
||||
class PageHeroIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=32)
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
image: str = ""
|
||||
|
||||
|
||||
class InvestmentModelIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
points: list[str] = []
|
||||
points_en: list[str] = []
|
||||
|
||||
|
||||
class ProcessStepIn(BaseModel):
|
||||
step: str = ""
|
||||
step_en: str = ""
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class OfficeIn(BaseModel):
|
||||
city: str = Field(min_length=1, max_length=64)
|
||||
city_en: str = ""
|
||||
address: str = ""
|
||||
address_en: str = ""
|
||||
hours: str = ""
|
||||
hours_en: str = ""
|
||||
|
||||
|
||||
class GalleryImageIn(BaseModel):
|
||||
src: str = ""
|
||||
alt: str = ""
|
||||
alt_en: str = ""
|
||||
|
||||
|
||||
class StatusLabelIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=16)
|
||||
label: str = Field(min_length=1, max_length=64)
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class ContactSectionIn(BaseModel):
|
||||
label: str = ""
|
||||
label_en: str = ""
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
address_label: str = ""
|
||||
address_label_en: str = ""
|
||||
phone_label: str = ""
|
||||
phone_label_en: str = ""
|
||||
email_label: str = ""
|
||||
email_label_en: str = ""
|
||||
name_label: str = ""
|
||||
name_label_en: str = ""
|
||||
name_placeholder: str = ""
|
||||
name_placeholder_en: str = ""
|
||||
phone_field_label: str = ""
|
||||
phone_field_label_en: str = ""
|
||||
phone_placeholder: str = ""
|
||||
phone_placeholder_en: str = ""
|
||||
note_label: str = ""
|
||||
note_label_en: str = ""
|
||||
note_optional_label: str = ""
|
||||
note_optional_label_en: str = ""
|
||||
note_placeholder: str = ""
|
||||
note_placeholder_en: str = ""
|
||||
submit_label: str = ""
|
||||
submit_label_en: str = ""
|
||||
sending_label: str = ""
|
||||
sending_label_en: str = ""
|
||||
success_title: str = ""
|
||||
success_title_en: str = ""
|
||||
success_text: str = ""
|
||||
success_text_en: str = ""
|
||||
success_button_label: str = ""
|
||||
success_button_label_en: str = ""
|
||||
|
||||
|
||||
class NewsletterSectionIn(BaseModel):
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
placeholder: str = ""
|
||||
placeholder_en: str = ""
|
||||
button_label: str = ""
|
||||
button_label_en: str = ""
|
||||
sending_label: str = ""
|
||||
sending_label_en: str = ""
|
||||
success_text: str = ""
|
||||
success_text_en: str = ""
|
||||
|
||||
|
||||
class FooterIn(BaseModel):
|
||||
about_text: str = ""
|
||||
about_text_en: str = ""
|
||||
pages_heading: str = ""
|
||||
pages_heading_en: str = ""
|
||||
services_heading: str = ""
|
||||
services_heading_en: str = ""
|
||||
contact_heading: str = ""
|
||||
contact_heading_en: str = ""
|
||||
copyright: str = ""
|
||||
copyright_en: str = ""
|
||||
tagline: str = ""
|
||||
tagline_en: str = ""
|
||||
socials: list[dict] = []
|
||||
socials_en: list[dict] = []
|
||||
|
||||
|
||||
class SeoMetaIn(BaseModel):
|
||||
page_key: str = Field(min_length=1, max_length=32)
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
description: str = ""
|
||||
description_en: str = ""
|
||||
|
||||
|
||||
class SeoMetaPatch(BaseModel):
|
||||
page_key: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
|
||||
|
||||
class MessageReadIn(BaseModel):
|
||||
is_read: bool = True
|
||||
|
||||
|
||||
# ---------- اسکیماهای PATCH (ویرایش جزئی — همه فیلدها اختیاری) ----------
|
||||
|
||||
|
||||
def _make_patch(name: str, model_cls: type[BaseModel]) -> type[BaseModel]:
|
||||
"""از اسکیمای ورودی، اسکیمای PATCH میسازد؛ فیلدهای ارسالنشده دستنخورده میمانند."""
|
||||
fields = {fname: (Optional[f.annotation], None) for fname, f in model_cls.model_fields.items()}
|
||||
return create_model(name, **fields)
|
||||
|
||||
|
||||
BrandPatch = _make_patch("BrandPatch", BrandIn)
|
||||
ServicePatch = _make_patch("ServicePatch", ServiceIn)
|
||||
ProjectPatch = _make_patch("ProjectPatch", ProjectIn)
|
||||
class ArticlePatch(BaseModel):
|
||||
slug: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
date_en: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
category_en: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
read: Optional[str] = None
|
||||
read_en: Optional[str] = None
|
||||
excerpt: Optional[str] = None
|
||||
excerpt_en: Optional[str] = None
|
||||
body: Optional[dict] = None
|
||||
body_en: Optional[dict] = None
|
||||
status: Optional[Literal["draft", "published"]] = None
|
||||
seo_keyphrase: Optional[str] = None
|
||||
_validate_body = field_validator("body")(validate_doc_body)
|
||||
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
|
||||
FaqPatch = _make_patch("FaqPatch", FaqIn)
|
||||
TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn)
|
||||
TimelineItemPatch = _make_patch("TimelineItemPatch", TimelineItemIn)
|
||||
StatPatch = _make_patch("StatPatch", StatIn)
|
||||
WhyUsItemPatch = _make_patch("WhyUsItemPatch", WhyUsItemIn)
|
||||
BeforeAfterPatch = _make_patch("BeforeAfterPatch", BeforeAfterIn)
|
||||
NavItemPatch = _make_patch("NavItemPatch", NavItemIn)
|
||||
HeroPatch = _make_patch("HeroPatch", HeroIn)
|
||||
StoryPatch = _make_patch("StoryPatch", StoryIn)
|
||||
SectionHeadingPatch = _make_patch("SectionHeadingPatch", SectionHeadingIn)
|
||||
PageHeroPatch = _make_patch("PageHeroPatch", PageHeroIn)
|
||||
InvestmentModelPatch = _make_patch("InvestmentModelPatch", InvestmentModelIn)
|
||||
ProcessStepPatch = _make_patch("ProcessStepPatch", ProcessStepIn)
|
||||
OfficePatch = _make_patch("OfficePatch", OfficeIn)
|
||||
GalleryImagePatch = _make_patch("GalleryImagePatch", GalleryImageIn)
|
||||
StatusLabelPatch = _make_patch("StatusLabelPatch", StatusLabelIn)
|
||||
ContactSectionPatch = _make_patch("ContactSectionPatch", ContactSectionIn)
|
||||
NewsletterSectionPatch = _make_patch("NewsletterSectionPatch", NewsletterSectionIn)
|
||||
FooterPatch = _make_patch("FooterPatch", FooterIn)
|
||||
|
||||
|
||||
class ReorderIn(BaseModel):
|
||||
"""ترتیب جدید آیتمهای یک مجموعه بر اساس id."""
|
||||
|
||||
ids: list[int]
|
||||
|
||||
@@ -1,16 +1,79 @@
|
||||
"""اسکریپت seed دیتابیس.
|
||||
|
||||
اجرا:
|
||||
python -m app.seed # فقط اگر دیتابیس خالی باشد
|
||||
python -m app.seed # فقط جدولهای خالی پر میشوند
|
||||
python -m app.seed --force # حذف کامل دادهها و ساخت مجدد
|
||||
|
||||
پر کردن هر جدول مستقل است: روی یک دیتابیس موجود، فقط جدولهای جدید یا خالی
|
||||
seed میشوند و دادههای قبلی دستنخورده میمانند.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from typing import Any
|
||||
|
||||
from . import models, seed_data
|
||||
from . import models, seed_data, seed_data_en
|
||||
from .auth import hash_password
|
||||
from .config import settings
|
||||
from .database import Base, SessionLocal, engine
|
||||
from .migrations import paragraphs_to_doc, run_all as run_migrations
|
||||
|
||||
# (مدل، دادههای اولیه، برچسب گزارش)
|
||||
CONTENT: list[tuple[Any, list[dict], str]] = [
|
||||
(models.Brand, [seed_data.BRAND], "برند"),
|
||||
(models.Hero, [seed_data.HERO], "هیرو صفحه اصلی"),
|
||||
(models.StorySection, [seed_data.STORY], "داستان ما"),
|
||||
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, "تیتر بخشها"),
|
||||
(models.PageHeroContent, seed_data.PAGE_HEROES, "هیرو صفحات"),
|
||||
(models.NavItem, seed_data.NAV_ITEMS, "منوی ناوبری"),
|
||||
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, "مدلهای سرمایهگذاری"),
|
||||
(models.ProcessStep, seed_data.PROCESS_STEPS, "مراحل فرآیند کار"),
|
||||
(models.Office, seed_data.OFFICES, "دفاتر"),
|
||||
(models.GalleryImage, seed_data.GALLERY_IMAGES, "گالری تصاویر"),
|
||||
(models.StatusLabel, seed_data.STATUS_LABELS, "برچسب وضعیت پروژه"),
|
||||
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], "بخش تماس"),
|
||||
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], "خبرنامه"),
|
||||
(models.FooterContent, [seed_data.FOOTER], "فوتر"),
|
||||
(models.Service, seed_data.SERVICES, "خدمات"),
|
||||
(models.Stat, seed_data.STATS, "آمار"),
|
||||
(models.Project, seed_data.PROJECTS, "پروژهها"),
|
||||
(models.Article, seed_data.ARTICLES, "مقالات"),
|
||||
(models.Testimonial, seed_data.TESTIMONIALS, "نظرات"),
|
||||
(models.Faq, seed_data.FAQS, "سوالات متداول"),
|
||||
(models.TeamMember, seed_data.TEAM, "تیم"),
|
||||
(models.TimelineItem, seed_data.TIMELINE, "تایملاین"),
|
||||
(models.WhyUsItem, seed_data.WHY, "چرا آرامش"),
|
||||
(models.BeforeAfter, [seed_data.BEFORE_AFTER], "قبل/بعد"),
|
||||
(models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"),
|
||||
]
|
||||
|
||||
# ترجمه انگلیسی همان جدولها — همترتیب با CONTENT تا با zip ادغام شود
|
||||
EN_CONTENT: list[list[dict]] = [
|
||||
[seed_data_en.BRAND],
|
||||
[seed_data_en.HERO],
|
||||
[seed_data_en.STORY],
|
||||
seed_data_en.SECTION_HEADINGS,
|
||||
seed_data_en.PAGE_HEROES,
|
||||
seed_data_en.NAV_ITEMS,
|
||||
seed_data_en.INVESTMENT_MODELS,
|
||||
seed_data_en.PROCESS_STEPS,
|
||||
seed_data_en.OFFICES,
|
||||
seed_data_en.GALLERY_IMAGES,
|
||||
seed_data_en.STATUS_LABELS,
|
||||
[seed_data_en.CONTACT_SECTION],
|
||||
[seed_data_en.NEWSLETTER],
|
||||
[seed_data_en.FOOTER],
|
||||
seed_data_en.SERVICES,
|
||||
seed_data_en.STATS,
|
||||
seed_data_en.PROJECTS,
|
||||
seed_data_en.ARTICLES,
|
||||
seed_data_en.TESTIMONIALS,
|
||||
seed_data_en.FAQS,
|
||||
seed_data_en.TEAM,
|
||||
seed_data_en.TIMELINE,
|
||||
seed_data_en.WHY,
|
||||
[seed_data_en.BEFORE_AFTER],
|
||||
seed_data_en.SEO_META,
|
||||
]
|
||||
|
||||
|
||||
def seed(force: bool = False) -> None:
|
||||
@@ -21,32 +84,22 @@ def seed(force: bool = False) -> None:
|
||||
print("جداول قبلی حذف شدند.")
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
if not force and db.query(models.Service).count() > 0:
|
||||
print("دیتابیس قبلاً seed شده است. برای بازنشانی کامل: python -m app.seed --force")
|
||||
return
|
||||
|
||||
db.add(models.Brand(**seed_data.BRAND))
|
||||
|
||||
for item in seed_data.SERVICES:
|
||||
db.add(models.Service(**item))
|
||||
for item in seed_data.STATS:
|
||||
db.add(models.Stat(**item))
|
||||
for item in seed_data.PROJECTS:
|
||||
db.add(models.Project(**item))
|
||||
for item in seed_data.ARTICLES:
|
||||
db.add(models.Article(**item))
|
||||
for item in seed_data.TESTIMONIALS:
|
||||
db.add(models.Testimonial(**item))
|
||||
for item in seed_data.FAQS:
|
||||
db.add(models.Faq(**item))
|
||||
for item in seed_data.TEAM:
|
||||
db.add(models.TeamMember(**item))
|
||||
for item in seed_data.TIMELINE:
|
||||
db.add(models.TimelineItem(**item))
|
||||
for item in seed_data.WHY:
|
||||
db.add(models.WhyUsItem(**item))
|
||||
db.add(models.BeforeAfter(**seed_data.BEFORE_AFTER))
|
||||
for (model, rows, label), en_rows in zip(CONTENT, EN_CONTENT):
|
||||
if db.query(model).count() > 0:
|
||||
continue
|
||||
for idx, row in enumerate(rows):
|
||||
# فایل فارسی مرجع است؛ فقط فیلدهای *_en از فایل انگلیسی اضافه میشود
|
||||
payload = {**dict(en_rows[idx]), **dict(row)} if idx < len(en_rows) else dict(row)
|
||||
if hasattr(model, "position") and "position" not in payload:
|
||||
payload["position"] = idx
|
||||
# بدنه مقاله (فارسی و انگلیسی) در seed بهصورت لیست پاراگراف است — به سند تبدیل میشود
|
||||
if model is models.Article:
|
||||
for body_key in ("body", "body_en"):
|
||||
if isinstance(payload.get(body_key), list):
|
||||
payload[body_key] = paragraphs_to_doc(payload[body_key])
|
||||
db.add(model(**payload))
|
||||
|
||||
if db.query(models.AdminUser).count() == 0:
|
||||
db.add(
|
||||
@@ -59,19 +112,8 @@ def seed(force: bool = False) -> None:
|
||||
|
||||
db.commit()
|
||||
|
||||
counts = {
|
||||
"خدمات": db.query(models.Service).count(),
|
||||
"پروژهها": db.query(models.Project).count(),
|
||||
"مقالات": db.query(models.Article).count(),
|
||||
"نظرات": db.query(models.Testimonial).count(),
|
||||
"سوالات متداول": db.query(models.Faq).count(),
|
||||
"تیم": db.query(models.TeamMember).count(),
|
||||
"تایملاین": db.query(models.TimelineItem).count(),
|
||||
"آمار": db.query(models.Stat).count(),
|
||||
"چرا آرامش": db.query(models.WhyUsItem).count(),
|
||||
}
|
||||
for label, count in counts.items():
|
||||
print(f" {label}: {count} رکورد")
|
||||
for model, _, label in CONTENT:
|
||||
print(f" {label}: {db.query(model).count()} رکورد")
|
||||
print("✔ seed با موفقیت انجام شد.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
BRAND = {
|
||||
"name": "گروه ساختمانی آرامش",
|
||||
"logo": "/images/logo.png",
|
||||
"hero": "/images/hero.png",
|
||||
"contact_bg": "/images/contact.png",
|
||||
"logo": "https://i.postimg.cc/XYNrsHsM/Chat-GPT-Image-Aug-6-2026-04-34-46-PM.png",
|
||||
"hero": "https://i.postimg.cc/J0bKgQVg/Gemini-Generated-Image-mdh969mdh969mdh9.png",
|
||||
"contact_bg": "https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png",
|
||||
"phone": "۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰",
|
||||
"phone_href": "tel:+982188742210",
|
||||
"email": "info@arameshbuildinggroup.com",
|
||||
@@ -12,11 +12,11 @@ BRAND = {
|
||||
}
|
||||
|
||||
IMAGES = [
|
||||
"/images/img1.png",
|
||||
"/images/img2.png",
|
||||
"/images/contact.png",
|
||||
"/images/img4.png",
|
||||
"/images/img5.png",
|
||||
"https://i.postimg.cc/wTQ0QFkG/Gemini-Generated-Image-1tjszd1tjszd1tjs.png",
|
||||
"https://i.postimg.cc/1zfBPS1G/Gemini-Generated-Image-at69yhat69yhat69.png",
|
||||
"https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png",
|
||||
"https://i.postimg.cc/4xFvW0HX/Gemini-Generated-Image-n7mhonn7mhonn7mh.png",
|
||||
"https://i.postimg.cc/N036cr4s/Gemini-Generated-Image-ytbhvvytbhvvytbh.png",
|
||||
]
|
||||
|
||||
SERVICES = [
|
||||
@@ -154,8 +154,8 @@ PROJECTS = [
|
||||
BEFORE_AFTER = {
|
||||
"title": "از کانسپت تا خلق واقعیت",
|
||||
"lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل میشود.",
|
||||
"before": "/images/before.webp",
|
||||
"after": "/images/after.webp",
|
||||
"before": "https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_before.webp",
|
||||
"after": "https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_after.webp",
|
||||
"before_label": "کانسپت اولیه",
|
||||
"after_label": "پروژه تکمیلشده",
|
||||
}
|
||||
@@ -289,3 +289,170 @@ FAQS = [
|
||||
{"question": "خدمات پس از تحویل شامل چه مواردی میشود؟", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دورهای سالانه و تضمین اجرای نما و عایقکاری."},
|
||||
{"question": "پروژهها در چه شهرهایی اجرا میشوند؟", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژهای نیز در جزیره کیش در دست ساخت است."},
|
||||
]
|
||||
|
||||
# ---------- محتوای قبلاً هاردکدشده در فرانتاند (الان در دیتابیس) ----------
|
||||
|
||||
NAV_ITEMS = [
|
||||
{"label": "صفحه اصلی", "href": "/"},
|
||||
{"label": "درباره ما", "href": "/about"},
|
||||
{"label": "خدمات", "href": "/services"},
|
||||
{"label": "پروژهها", "href": "/projects"},
|
||||
{"label": "سرمایهگذاری", "href": "/investment"},
|
||||
{"label": "وبلاگ", "href": "/blog"},
|
||||
{"label": "سوالات متداول", "href": "/faq"},
|
||||
{"label": "تماس با ما", "href": "/contact"},
|
||||
]
|
||||
|
||||
HERO = {
|
||||
"kicker": "از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال",
|
||||
"title": "ساختمانی که آرامش را معماری میکند",
|
||||
"title_highlight": "معماری",
|
||||
"subtitle": "طراحی، ساخت و سرمایهگذاری در پروژههای مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده میشود و در سالها دوام میآورد.",
|
||||
"badge_value": "۴۷",
|
||||
"badge_label": "پروژه تحویلشده در بیستویک سال فعالیت حرفهای",
|
||||
"cta_primary_label": "مشاهده پروژهها",
|
||||
"cta_primary_href": "/projects",
|
||||
"cta_secondary_label": "مشاوره سرمایهگذاری",
|
||||
"cta_secondary_href": "/contact",
|
||||
"scroll_label": "اسکرول کنید",
|
||||
}
|
||||
|
||||
STORY = {
|
||||
"title": "درباره ی ما",
|
||||
"paragraphs": [
|
||||
"گروه ساختمانی آرامش با یک دفتر کوچک معماری آغاز شد و امروز یکی از مجموعههای شناختهشده ساختوساز لوکس در ایران است. در تمام این سالها یک اصل تغییر نکرده است: ساختمان باید برای زندگی ساخته شود، نه برای فروش سریع.",
|
||||
"هر پروژه با مطالعه زمین، نور، اقلیم و الگوی زندگی ساکنان آغاز میشود؛ سپس تیم طراحی، سازه و اجرا در یک فرآیند واحد آن را تا تحویل کلید پیش میبرند.",
|
||||
],
|
||||
"quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی میسازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»",
|
||||
"quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
|
||||
"stat1_value": "۹۴٪",
|
||||
"stat1_label": "تحویل بهموقع پروژهها",
|
||||
"stat2_value": "۵ سال",
|
||||
"stat2_label": "پشتیبانی پس از تحویل",
|
||||
"link_label": "داستان کامل ما",
|
||||
"link_href": "/about",
|
||||
"bento": [
|
||||
{"src": IMAGES[0], "alt": "نمای برج مسکونی آرامش در تهران"},
|
||||
{"src": IMAGES[1], "alt": "آتریوم مرکزی مجتمع تجاری پارسه"},
|
||||
{"src": IMAGES[2], "alt": "ویلاهای ساختهشده در سواحل شمال"},
|
||||
{"src": IMAGES[3], "alt": "نمای شبانه برج دوقلوی نیاوران"},
|
||||
],
|
||||
}
|
||||
|
||||
SECTION_HEADINGS = [
|
||||
{"key": "services", "title": "خدمات", "lead": "از نخستین خط روی کاغذ تا تحویل کلید، مدیریت سرمایه و طراحی محوطه؛ همه مراحل در یک مجموعه و زیر یک نظام کنترل کیفیت."},
|
||||
{"key": "featured_projects", "title": "پروژههای شاخص", "lead": "کارهایی که بیشترین چیز را درباره استاندارد ما میگویند؛ چه آنهایی که تحویل شدهاند و چه آنهایی که همین امروز در حال ساختاند.", "cta_label": "همه پروژهها", "cta_href": "/projects"},
|
||||
{"key": "gallery", "title": "گالری تصاویر", "lead": "قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."},
|
||||
{"key": "timeline", "title": "مسیر ما", "lead": "از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژههای ما را ببینید."},
|
||||
{"key": "why_us", "title": "چرا گروه ساختمانی آرامش", "lead": "شش دلیلی که کارفرمایان و سرمایهگذاران بار دوم هم به ما برمیگردند.", "image": IMAGES[3]},
|
||||
{"key": "testimonials", "title": "نظرات مشتریان"},
|
||||
{"key": "team", "title": "تیم ما", "lead": "کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."},
|
||||
{"key": "articles", "label": "وبلاگ", "title": "آخرین مقالات", "lead": "یادداشتهای تیم معماری، اجرا و سرمایهگذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار میگذرد.", "cta_label": "همه مقالات", "cta_href": "/blog"},
|
||||
{"key": "faq", "title": "سوالات متداول", "lead": "پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."},
|
||||
{"key": "investment_models", "title": "مدلهای همکاری", "lead": "بسته به میزان سرمایه، افق زمانی و سطح ریسکی که میپذیرید، یکی از این سه مسیر مناسب شماست."},
|
||||
{"key": "process", "title": "فرآیند کار ما", "lead": "چهار مرحله شفاف، از نخستین جلسه تا تحویل کلید."},
|
||||
]
|
||||
|
||||
PAGE_HEROES = [
|
||||
{"key": "about", "title": "درباره ما", "lead": "گروه ساختمانی آرامش مجموعهای از معماران، مهندسان سازه و مدیران پروژه است که از سال ۱۳۸۳ در حوزه ساختوساز لوکس فعالیت میکند.", "image": IMAGES[3]},
|
||||
{"key": "services", "title": "خدمات", "lead": "هشت حوزه تخصصی که چرخه کامل یک پروژه ساختمانی را از ایده تا بهرهبرداری پوشش میدهند.", "image": IMAGES[1]},
|
||||
{"key": "projects", "title": "پروژهها", "lead": "مجموعهای از برجهای مسکونی، مراکز تجاری و ویلاهای اختصاصی؛ از پروژههای تحویلشده تا کارگاههای فعال امروز.", "image": IMAGES[0]},
|
||||
{"key": "investment", "title": "سرمایهگذاری", "lead": "سه مسیر روشن برای ورود به بازار ساختوساز لوکس، با ساختار مالی شفاف و گزارشدهی منظم.", "image": IMAGES[4]},
|
||||
{"key": "blog", "title": "وبلاگ", "lead": "یادداشتهای تیم معماری، اجرا و سرمایهگذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار میگذرد.", "image": IMAGES[2]},
|
||||
{"key": "faq", "title": "سوالات متداول", "lead": "پیش از تماس، شاید پاسخ پرسش شما همینجا باشد. اگر نبود، خوشحال میشویم مستقیم صحبت کنیم.", "image": IMAGES[1]},
|
||||
{"key": "contact", "title": "تماس با ما", "lead": "برای مشاوره ساخت، سرمایهگذاری یا بازدید از پروژهها، تیم ما در سه شهر آماده پاسخگویی است.", "image": IMAGES[1]},
|
||||
]
|
||||
|
||||
INVESTMENT_MODELS = [
|
||||
{
|
||||
"title": "مشارکت در ساخت",
|
||||
"desc": "مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک میشوند؛ اجرا، مجوزها و فروش بر عهده ماست.",
|
||||
"points": ["ارزیابی رایگان زمین", "قرارداد شفاف با ضمانت اجرا", "گزارش ماهانه پیشرفت"],
|
||||
},
|
||||
{
|
||||
"title": "صندوق پروژهمحور",
|
||||
"desc": "سرمایهگذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبهشده در پایان دوره.",
|
||||
"points": ["حداقل دوره ۲۴ ماه", "گزارش ارزشگذاری فصلی", "امکان انتقال واحد سرمایه"],
|
||||
},
|
||||
{
|
||||
"title": "پیشخرید واحد",
|
||||
"desc": "خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحلهای.",
|
||||
"points": ["پرداخت مرحلهای متناسب با پیشرفت", "امکان شخصیسازی پلان", "تضمین زمان تحویل"],
|
||||
},
|
||||
]
|
||||
|
||||
PROCESS_STEPS = [
|
||||
{"step": "۰۱", "title": "مطالعه و امکانسنجی", "desc": "بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه."},
|
||||
{"step": "۰۲", "title": "طراحی و فاز اجرایی", "desc": "کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشههای اجرایی."},
|
||||
{"step": "۰۳", "title": "اجرا و کنترل کیفیت", "desc": "ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه."},
|
||||
{"step": "۰۴", "title": "تحویل و پشتیبانی", "desc": "تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی."},
|
||||
]
|
||||
|
||||
OFFICES = [
|
||||
{"city": "تهران", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸"},
|
||||
{"city": "اصفهان", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷"},
|
||||
{"city": "نوشهر", "address": "بلوار دریا، دفتر پروژه دریاکنار", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶"},
|
||||
]
|
||||
|
||||
GALLERY_IMAGES = [
|
||||
{"src": IMAGES[0], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۱"},
|
||||
{"src": IMAGES[1], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۲"},
|
||||
{"src": IMAGES[2], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۳"},
|
||||
{"src": IMAGES[3], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۴"},
|
||||
{"src": IMAGES[4], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۵"},
|
||||
{"src": IMAGES[1], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۶"},
|
||||
]
|
||||
|
||||
STATUS_LABELS = [
|
||||
{"key": "done", "label": "انجام شده"},
|
||||
{"key": "progress", "label": "در حال انجام"},
|
||||
]
|
||||
|
||||
CONTACT_SECTION = {
|
||||
"label": "تماس با ما",
|
||||
"title": "پروژهتان را با ما در میان بگذارید",
|
||||
"lead": "شمارهتان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس میگیرد.",
|
||||
"address_label": "دفتر مرکزی",
|
||||
"phone_label": "تلفن",
|
||||
"email_label": "ایمیل",
|
||||
"name_label": "نام",
|
||||
"name_placeholder": "نام و نام خانوادگی",
|
||||
"phone_field_label": "شماره تلفن",
|
||||
"phone_placeholder": "09xx xxx xxxx",
|
||||
"note_label": "توضیحات",
|
||||
"note_optional_label": "(اختیاری)",
|
||||
"note_placeholder": "درباره پروژه یا درخواست خود بنویسید…",
|
||||
"submit_label": "ارسال درخواست مشاوره",
|
||||
"sending_label": "در حال ارسال…",
|
||||
"success_title": "درخواست شما ثبت شد",
|
||||
"success_text": "از اعتماد شما سپاسگزاریم. کارشناسان ما بهزودی با شما تماس میگیرند.",
|
||||
"success_button_label": "ثبت درخواست جدید",
|
||||
}
|
||||
|
||||
NEWSLETTER = {
|
||||
"title": "خبرنامه گروه ساختمانی آرامش",
|
||||
"lead": "هر ماه یک ایمیل: پروژههای جدید، فرصتهای سرمایهگذاری و یادداشتهای تیم معماری.",
|
||||
"placeholder": "نشانی ایمیل شما",
|
||||
"button_label": "عضویت",
|
||||
"sending_label": "در حال ارسال…",
|
||||
"success_text": "عضویت شما ثبت شد.",
|
||||
}
|
||||
|
||||
FOOTER = {
|
||||
"about_text": "گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمانهایی میسازد که قرار است چند نسل در آنها زندگی کنند؛ معماری آرام، اجرای دقیق و سرمایهگذاری شفاف.",
|
||||
"pages_heading": "صفحات",
|
||||
"services_heading": "خدمات",
|
||||
"contact_heading": "ارتباط با ما",
|
||||
"copyright": "© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.",
|
||||
"tagline": "طراحی و ساخت با دقتِ معماری",
|
||||
"socials": [
|
||||
{"label": "اینستاگرام", "href": "#", "icon": "instagram"},
|
||||
{"label": "لینکدین", "href": "#", "icon": "linkedin"},
|
||||
{"label": "تلگرام", "href": "#", "icon": "telegram"},
|
||||
],
|
||||
}
|
||||
|
||||
SEO_META = [
|
||||
{"page_key": key, "title": "", "description": ""}
|
||||
for key in ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
|
||||
]
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
"""ترجمه انگلیسی دادههای اولیه سایت — ساختار یکبهیک با seed_data.py.
|
||||
|
||||
هر فهرست همطول و همترتیب با معادل فارسی خود است تا seed.py بتواند با zip
|
||||
ادغام کند و backfill مهاجرت بتواند سطرهای موجود را پر کند. فقط فیلدهای
|
||||
`*_en` ملاک ادغاماند؛ کلیدهای همنام با فارسی (مثل title یا body) صرفاً
|
||||
برای خواندن راحتتر مترجم آمدهاند و در ادغام نادیده گرفته میشوند.
|
||||
فیلدهای تطبیق (slug، key، page_key، src) عیناً مقدار فارسیاند.
|
||||
"""
|
||||
|
||||
from .seed_data import IMAGES
|
||||
|
||||
BRAND = {
|
||||
"name": "گروه ساختمانی آرامش",
|
||||
"name_en": "Aramesh Construction Group",
|
||||
"address": "تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم",
|
||||
"address_en": "Valiasr St., Aramesh Tower, 18th Floor, Tehran, Iran",
|
||||
}
|
||||
|
||||
SERVICES = [
|
||||
{"title": "طراحی معماری", "title_en": "Architectural Design", "desc": "خلق فرمهای معاصر با نگاه ایرانی؛ از کانسپت اولیه تا نقشههای اجرایی و جزئیات نما.", "desc_en": "Contemporary forms with an Iranian eye — from the first concept to construction drawings and facade details.", "icon": "draft"},
|
||||
{"title": "ساخت برجهای مسکونی", "title_en": "Residential Towers", "desc": "اجرای برجهای بلندمرتبه با استانداردهای سازهای بینالمللی و کیفیت تحویل بینقص.", "desc_en": "High-rise execution built on international structural standards and a flawless handover quality.", "icon": "tower"},
|
||||
{"title": "مجتمعهای تجاری", "title_en": "Commercial Complexes", "desc": "طراحی و ساخت مراکز تجاری با مطالعه دقیق جریان مخاطب و بازدهی بلندمدت سرمایه.", "desc_en": "Retail destinations designed and built around precise visitor-flow studies and long-term returns.", "icon": "mall"},
|
||||
{"title": "ویلاسازی", "title_en": "Private Villas", "desc": "ویلاهای اختصاصی همنشین با طبیعت؛ محرمانگی، نور طبیعی و مصالح ماندگار.", "desc_en": "Bespoke villas in harmony with nature — privacy, natural light and enduring materials.", "icon": "villa"},
|
||||
{"title": "بازسازی", "title_en": "Renovation", "desc": "بازآفرینی ساختمانهای موجود با ارتقای سازه، نما و تجربه زندگی در فضا.", "desc_en": "Reimagining existing buildings with upgraded structure, facades and living experience.", "icon": "renew"},
|
||||
{"title": "مدیریت پروژه", "title_en": "Project Management", "desc": "کنترل همزمان زمان، هزینه و کیفیت با گزارشدهی شفاف در تمام مراحل ساخت.", "desc_en": "Controlling time, cost and quality in parallel with transparent reporting at every stage.", "icon": "manage"},
|
||||
{"title": "سرمایهگذاری ساختمانی", "title_en": "Real Estate Investment", "desc": "ساختارهای مشارکت شفاف، ارزیابی ریسک و طراحی سبد ملکی متناسب با سرمایه شما.", "desc_en": "Transparent partnership structures, risk assessment and property portfolios tailored to your capital.", "icon": "invest"},
|
||||
{"title": "محوطهسازی و لنداسکیپ", "title_en": "Landscape Design", "desc": "طراحی حیاط، بام سبز و مسیرهای دسترسی؛ ادامه معماری در فضای باز پروژه.", "desc_en": "Courtyards, green roofs and pathways — architecture carried on into the open air.", "icon": "landscape"},
|
||||
]
|
||||
|
||||
STATS = [
|
||||
{"value": 21, "suffix": "+", "suffix_en": "+", "label": "سال تجربه ساخت", "label_en": "Years of building experience"},
|
||||
{"value": 47, "suffix": "", "suffix_en": "", "label": "پروژه تحویلشده", "label_en": "Projects delivered"},
|
||||
{"value": 860, "suffix": "هزار", "suffix_en": "K", "label": "مترمربع زیربنا", "label_en": "Square meters built"},
|
||||
{"value": 1200, "suffix": "+", "suffix_en": "+", "label": "خانواده ساکن", "label_en": "Resident families"},
|
||||
]
|
||||
|
||||
PROJECTS = [
|
||||
{
|
||||
"slug": "aramesh-elahieh",
|
||||
"title": "برج آرامش الهیه",
|
||||
"title_en": "Aramesh Elahieh Tower",
|
||||
"location": "تهران، الهیه",
|
||||
"location_en": "Elahieh, Tehran",
|
||||
"year": "۱۴۰۲",
|
||||
"year_en": "2023",
|
||||
"area": "۲۴٬۰۰۰ متر مربع",
|
||||
"area_en": "24,000 m²",
|
||||
"floors": "۱۸ طبقه",
|
||||
"floors_en": "18 floors",
|
||||
"units": "۵۶ واحد",
|
||||
"units_en": "56 units",
|
||||
"client": "سرمایهگذاری خصوصی",
|
||||
"client_en": "Private investor",
|
||||
"summary": "یک برج مسکونی لوکس با نمای سنگ تراورتن و پوسته دوپوستهای که مصرف انرژی را تا ۳۵ درصد کاهش داده است.",
|
||||
"summary_en": "A luxury residential tower with travertine cladding and a double-skin facade that cuts energy use by up to 35%.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"Aramesh Elahieh Tower was designed on a site with a natural northward slope — an opportunity that placed the lobby, pool and shared spaces on three different levels and gave every unit an unobstructed view of the city.",
|
||||
"The outer shell combines light travertine stone with floor-to-ceiling triple-glazed windows. Copper-toned vertical louvers break the direct summer sun while letting winter light pour in.",
|
||||
"Construction was completed in thirty-two months, and every unit was sold before handover.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "parseh-mall",
|
||||
"title": "مجتمع تجاری پارسه",
|
||||
"title_en": "Parseh Shopping Mall",
|
||||
"location": "اصفهان، مرکز شهر",
|
||||
"location_en": "City center, Isfahan",
|
||||
"year": "۱۴۰۱",
|
||||
"year_en": "2022",
|
||||
"area": "۳۸٬۵۰۰ متر مربع",
|
||||
"area_en": "38,500 m²",
|
||||
"floors": "۷ طبقه",
|
||||
"floors_en": "7 floors",
|
||||
"units": "۱۴۰ واحد تجاری",
|
||||
"units_en": "140 retail units",
|
||||
"client": "گروه سرمایهگذاری پارسه",
|
||||
"client_en": "Parseh Investment Group",
|
||||
"summary": "مرکز تجاری با آتریوم مرکزی و نورگیر سقفی که جریان مخاطب را بهصورت طبیعی در همه ترازها توزیع میکند.",
|
||||
"summary_en": "A retail center with a central atrium and roof light-wells that naturally distribute visitor flow across every level.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"A visitor-behavior study before design showed that the shortest vertical path was the key to the complex's commercial success — so the central atrium with diagonal escalators sits at the heart of the plan.",
|
||||
"Low-E glazing on the roof light-well carries daylight down to level B2 and measurably cuts artificial lighting consumption.",
|
||||
"The complex opened two months ahead of the contractual schedule.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "daryakenar-villas",
|
||||
"title": "ویلاهای دریاکنار",
|
||||
"title_en": "Daryakenar Villas",
|
||||
"location": "مازندران، نوشهر",
|
||||
"location_en": "Nowshahr, Mazandaran",
|
||||
"year": "۱۴۰۰",
|
||||
"year_en": "2021",
|
||||
"area": "۹٬۲۰۰ متر مربع",
|
||||
"area_en": "9,200 m²",
|
||||
"floors": "۲ طبقه",
|
||||
"floors_en": "2 floors",
|
||||
"units": "۱۲ ویلا",
|
||||
"units_en": "12 villas",
|
||||
"client": "مجموعه خصوصی دریاکنار",
|
||||
"client_en": "Daryakenar Private Estate",
|
||||
"summary": "دوازده ویلای اختصاصی با چیدمان شانهای که برای هر واحد، دید مستقیم به دریا و محرمانگی کامل فراهم میکند.",
|
||||
"summary_en": "Twelve bespoke villas in a comb layout that gives each unit a direct sea view and complete privacy.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"The comb layout of the villas is the result of dozens of view-angle studies: no villa overlooks another, and all of them open onto the sea.",
|
||||
"The materials answer the humid northern climate: thermowood, exposed concrete and salt-resistant coatings on the facade metals.",
|
||||
"The landscape was designed with native plants to keep annual irrigation demand to a minimum.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "niavaran-twins",
|
||||
"title": "برج دوقلوی نیاوران",
|
||||
"title_en": "Niavaran Twin Towers",
|
||||
"location": "تهران، نیاوران",
|
||||
"location_en": "Niavaran, Tehran",
|
||||
"year": "۱۳۹۹",
|
||||
"year_en": "2020",
|
||||
"area": "۳۱٬۷۰۰ متر مربع",
|
||||
"area_en": "31,700 m²",
|
||||
"floors": "۲۰ طبقه",
|
||||
"floors_en": "20 floors",
|
||||
"units": "۸۰ واحد",
|
||||
"units_en": "80 units",
|
||||
"client": "مشارکت با مالکان زمین",
|
||||
"client_en": "Partnership with landowners",
|
||||
"summary": "دو برج همسان با پادست مشترک که فضاهای رفاهی و پارکینگها را در یک تراز واحد ادغام میکند.",
|
||||
"summary_en": "Two identical towers on a shared podium that merges amenities and parking into a single level.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"The two towers rest on a shared podium — a decision that let the amenities, event hall and parking be shared between the buildings and reduced the footprint on the garden.",
|
||||
"The structure uses shear walls with post-tensioned slabs so the interior spans stay free and the floor plans remain customizable.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "kish-garden-tower",
|
||||
"title": "باغبرج کیش",
|
||||
"title_en": "Kish Garden Tower",
|
||||
"location": "کیش، مروارید",
|
||||
"location_en": "Marvàrid, Kish Island",
|
||||
"year": "۱۴۰۵ (پیشبینی)",
|
||||
"year_en": "2026 (expected)",
|
||||
"area": "۱۸٬۴۰۰ متر مربع",
|
||||
"area_en": "18,400 m²",
|
||||
"floors": "۱۴ طبقه",
|
||||
"floors_en": "14 floors",
|
||||
"units": "۶۲ واحد اقامتی",
|
||||
"units_en": "62 hospitality units",
|
||||
"client": "صندوق پروژهمحور آرامش",
|
||||
"client_en": "Aramesh Project Fund",
|
||||
"summary": "باغبرجی اقامتی با تراسهای سبز پیوسته که در حال ساخت است؛ پیشرفت فیزیکی پروژه ۴۸ درصد.",
|
||||
"summary_en": "A hospitality garden tower with continuous green terraces, currently under construction — 48% complete.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"The continuous green terraces form a living skin that measurably lowers the facade surface temperature in the island's hot climate.",
|
||||
"The structure has reached the ninth floor, and facade installation has begun on the southern elevation.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "aramesh-business-center",
|
||||
"title": "مرکز تجاری آرامش تهران",
|
||||
"title_en": "Aramesh Business Center Tehran",
|
||||
"location": "تهران، سعادتآباد",
|
||||
"location_en": "Saadatabad, Tehran",
|
||||
"year": "۱۴۰۶ (پیشبینی)",
|
||||
"year_en": "2027 (expected)",
|
||||
"area": "۲۷٬۳۰۰ متر مربع",
|
||||
"area_en": "27,300 m²",
|
||||
"floors": "۱۲ طبقه",
|
||||
"floors_en": "12 floors",
|
||||
"units": "۹۰ دفتر اداری",
|
||||
"units_en": "90 offices",
|
||||
"client": "مشارکت با هلدینگ آرامش",
|
||||
"client_en": "Partnership with Aramesh Holding",
|
||||
"summary": "مجتمع اداری-تجاری با هسته مرکزی شفاف و لابی دوبلکس؛ در مرحله اجرای سفتکاری.",
|
||||
"summary_en": "An office-retail complex with a transparent central core and double-height lobby; currently in structural works.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"The space planning was built around flexible offices: every level can be divided into two, three or six independent units.",
|
||||
"Mechanical installations are progressing in parallel with the structural works to shorten the overall timeline.",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
BEFORE_AFTER = {
|
||||
"title": "از کانسپت تا خلق واقعیت",
|
||||
"title_en": "From Concept to Reality",
|
||||
"lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل میشود.",
|
||||
"lead_en": "Drag the handle to see how a building facade turns from a raw concept into its modern form.",
|
||||
"before_label": "کانسپت اولیه",
|
||||
"before_label_en": "Initial concept",
|
||||
"after_label": "پروژه تکمیلشده",
|
||||
"after_label_en": "Completed project",
|
||||
}
|
||||
|
||||
TIMELINE = [
|
||||
{"year": "۱۳۸۳", "year_en": "2004", "title": "آغاز مسیر", "title_en": "The Journey Begins", "desc": "تأسیس دفتر معماری آرامش با تیمی چهارنفره و نخستین پروژه بازسازی در تهران.", "desc_en": "Aramesh Architecture Office is founded by a four-person team, taking on its first renovation project in Tehran.", "image": IMAGES[2]},
|
||||
{"year": "۱۳۸۵", "year_en": "2006", "title": "نخستین ساخت مستقل", "title_en": "First Independent Build", "desc": "تحویل اولین ساختمان مسکونی هفتطبقه؛ آغاز همکاری با پیمانکاران سازهای بزرگ.", "desc_en": "Delivery of the first seven-story residential building and the start of partnerships with major structural contractors.", "image": IMAGES[0]},
|
||||
{"year": "۱۳۸۷", "year_en": "2008", "title": "ورود به بازار تجاری", "title_en": "Entering Commercial Market", "desc": "طراحی و اجرای اولین مجتمع تجاری با رویکرد بازدهی سرمایه و جریان مخاطب.", "desc_en": "Design and execution of the first commercial complex, driven by capital returns and visitor flow.", "image": IMAGES[1]},
|
||||
{"year": "۱۳۸۹", "year_en": "2010", "title": "استاندارد بینالمللی", "title_en": "International Standards", "desc": "استقرار نظام کنترل کیفیت و دریافت گواهینامههای مدیریت پروژه.", "desc_en": "A quality-control system is established and project-management certifications are earned.", "image": IMAGES[4]},
|
||||
{"year": "۱۳۹۵", "year_en": "2016", "title": "بلندمرتبهسازی", "title_en": "Going High-Rise", "desc": "آغاز ساخت نخستین برج بیستطبقه شرکت در شمال تهران.", "desc_en": "Construction begins on the company's first twenty-story tower in northern Tehran.", "image": IMAGES[3]},
|
||||
{"year": "۱۴۰۳", "year_en": "Today", "title": "گروه ساختمانی آرامش امروز", "title_en": "Aramesh Construction Group Today", "desc": "پرتفوی ۴۷ پروژه، دفاتر در سه شهر و صندوق سرمایهگذاری ساختمانی اختصاصی.", "desc_en": "A portfolio of 47 projects, offices in three cities and a dedicated real estate investment fund.", "image": IMAGES[0]},
|
||||
]
|
||||
|
||||
WHY = [
|
||||
{"title": "کیفیت بدون مصالحه", "title_en": "Quality Without Compromise", "desc": "انتخاب مصالح و مجریان بر اساس ماندگاری چنددههای، نه سریعترین تحویل.", "desc_en": "Materials and subcontractors are chosen for decades of durability, not the fastest delivery."},
|
||||
{"title": "شفافیت مالی کامل", "title_en": "Full Financial Transparency", "desc": "گزارش ماهانه هزینهها و پیشرفت فیزیکی برای همه شرکای پروژه.", "desc_en": "Monthly reporting of costs and physical progress for every project partner."},
|
||||
{"title": "تحویل بهموقع", "title_en": "On-Time Delivery", "desc": "۹۴٪ پروژههای ما در بازه زمانی قراردادی یا زودتر تحویل داده شدهاند.", "desc_en": "94% of our projects have been delivered on or ahead of the contractual schedule."},
|
||||
{"title": "طراحی اختصاصی", "title_en": "Bespoke Design", "desc": "هیچ نقشهای دو بار اجرا نمیشود؛ هر پروژه هویت معماری خودش را دارد.", "desc_en": "No drawing is ever executed twice — every project has its own architectural identity."},
|
||||
{"title": "خدمات پس از تحویل", "title_en": "After-Sales Service", "desc": "پنج سال پشتیبانی فنی و نگهداری تأسیسات پس از تحویل کلید.", "desc_en": "Five years of technical support and building-systems maintenance after handover."},
|
||||
{"title": "پایداری و انرژی", "title_en": "Sustainability & Energy", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد.", "desc_en": "Envelope and building systems designed to cut energy consumption by up to 35%."},
|
||||
]
|
||||
|
||||
TESTIMONIALS = [
|
||||
{"name": "مهندس سارا کیانی", "name_en": "Sara Kiani", "role": "سرمایهگذار پروژه پارسه", "role_en": "Investor, Parseh Mall project", "quote": "گزارشهای ماهانه گروه ساختمانی آرامش آنقدر دقیق بود که هیچوقت نیاز نشد نگران سرمایهام باشم. پروژه دو ماه زودتر از موعد به بهرهبرداری رسید.", "quote_en": "Aramesh's monthly reports were so precise that I never had to worry about my investment. The project opened two months ahead of schedule."},
|
||||
{"name": "دکتر امیرحسین راد", "name_en": "Dr. Amirhossein Rad", "role": "ساکن برج الهیه", "role_en": "Resident, Elahieh Tower", "quote": "کیفیت اجرا در جزئیاتی دیده میشود که معمولاً کسی به آنها فکر نمیکند؛ از عایق صوتی پنجرهها تا نور غیرمستقیم راهروها.", "quote_en": "The build quality shows in details nobody usually thinks about — from the acoustic insulation of the windows to the indirect lighting in the corridors."},
|
||||
{"name": "مریم دلاور", "name_en": "Maryam Delavar", "role": "مدیر مجموعه ویلاهای دریاکنار", "role_en": "Manager, Daryakenar Villas", "quote": "همکاری با تیم طراحی، تجربهای واقعاً حرفهای بود. ایدههای ما را شنیدند و به زبان معماری ترجمه کردند.", "quote_en": "Working with the design team was a truly professional experience. They listened to our ideas and translated them into architecture."},
|
||||
]
|
||||
|
||||
TEAM = [
|
||||
{"name": "کامران آرامش", "name_en": "Kamran Aramesh", "role": "بنیانگذار و مدیرعامل", "role_en": "Founder & CEO", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه.", "bio_en": "Three decades of high-rise construction experience."},
|
||||
{"name": "نگار شریفی", "name_en": "Negar Sharifi", "role": "مدیر ارشد طراحی", "role_en": "Chief Design Officer", "bio": "معمار ارشد، فارغالتحصیل دانشگاه تهران و میلان.", "bio_en": "Senior architect, graduate of the University of Tehran and Milan."},
|
||||
{"name": "بهزاد مرادی", "name_en": "Behzad Moradi", "role": "مدیر اجرایی پروژهها", "role_en": "Director of Project Execution", "bio": "مسئول کنترل کیفیت و زمانبندی همه کارگاهها.", "bio_en": "Responsible for quality control and scheduling across all sites."},
|
||||
{"name": "الهام نیکآیین", "name_en": "Elham Nikaein", "role": "مدیر سرمایهگذاری", "role_en": "Investment Director", "bio": "طراح ساختارهای مشارکت و تحلیل بازدهی ملکی.", "bio_en": "Designs partnership structures and analyzes property returns."},
|
||||
]
|
||||
|
||||
ARTICLES = [
|
||||
{
|
||||
"slug": "natural-light",
|
||||
"title": "معماری آرام: چرا نور طبیعی گرانترین مصالح ساختمان است؟",
|
||||
"title_en": "Quiet Architecture: Why Is Natural Light the Most Expensive Building Material?",
|
||||
"date": "۲۴ مرداد ۱۴۰۳",
|
||||
"date_en": "August 14, 2024",
|
||||
"category": "معماری",
|
||||
"category_en": "Architecture",
|
||||
"read": "۶ دقیقه",
|
||||
"read_en": "6 min read",
|
||||
"excerpt": "نور طبیعی نهتنها کیفیت زندگی در فضا را تعیین میکند، بلکه مستقیماً بر مصرف انرژی و ارزش بلندمدت ملک اثر میگذارد.",
|
||||
"excerpt_en": "Natural light not only defines the quality of life in a space — it directly affects energy consumption and the long-term value of the property.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"In the construction market, a unit's value is usually measured in square meters. But our experience over the past twenty years shows that the quality of light is more decisive than a few extra meters.",
|
||||
"Building orientation, bay depth and floor-to-ceiling height are the three main variables of natural light. The decision on all three is made before the first plan is drawn — and is hardly reversible afterwards.",
|
||||
"In Aramesh Elahieh Tower, rotating the building mass 12 degrees off north added two hours of useful winter light to the units — a cost-free decision with a decades-long effect.",
|
||||
"Our advice for choosing a unit is simple: visit at mid-morning and late afternoon, and judge the quality of light at exactly the hours you are actually home.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "real-estate-portfolio",
|
||||
"title": "راهنمای انتخاب سبد سرمایهگذاری ملکی در بازار امروز",
|
||||
"title_en": "A Guide to Building a Real Estate Portfolio in Today's Market",
|
||||
"date": "۱۰ مرداد ۱۴۰۳",
|
||||
"date_en": "July 31, 2024",
|
||||
"category": "سرمایهگذاری",
|
||||
"category_en": "Investment",
|
||||
"read": "۹ دقیقه",
|
||||
"read_en": "9 min read",
|
||||
"excerpt": "سه معیار کلیدی برای تشکیل سبدی متعادل از دارایی ملکی، بر اساس افق زمانی و سطح ریسک شما.",
|
||||
"excerpt_en": "Three key criteria for building a balanced property portfolio, based on your time horizon and risk appetite.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"A balanced property portfolio mixes assets with different liquidity — not all capital in one long-term project, not all in ready units.",
|
||||
"Time horizon is the first criterion. If your plan is shorter than two years, pre-purchasing in a project that has just started is a risky choice.",
|
||||
"The second criterion is reporting transparency. Without monthly reports of physical and financial progress, assessing the state of your capital is practically impossible.",
|
||||
"The third criterion is diversity of use. A residential–commercial mix measurably reduces the volatility of portfolio returns in downturns.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "facade-quality",
|
||||
"title": "پنج نکته فنی که کیفیت نما را پس از ده سال مشخص میکند",
|
||||
"title_en": "Five Technical Details That Reveal Facade Quality After Ten Years",
|
||||
"date": "۲۸ تیر ۱۴۰۳",
|
||||
"date_en": "July 18, 2024",
|
||||
"category": "اجرا",
|
||||
"category_en": "Construction",
|
||||
"read": "۵ دقیقه",
|
||||
"read_en": "5 min read",
|
||||
"excerpt": "نمای ساختمان در سال اول زیبا به نظر میرسد؛ تفاوت کیفیت اجرا در سال دهم آشکار میشود.",
|
||||
"excerpt_en": "A facade looks beautiful in its first year; the difference in workmanship shows in its tenth.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"Mechanical fixings of facade stone matter more than the stone itself. Adhesive fixing has a short life in Iran's hot-and-cold climate.",
|
||||
"Contraction joints must be designed on the drawing, not decided on site — their absence is the first cause of horizontal facade cracks.",
|
||||
"Window perimeter waterproofing is the most frequent point of failure. Installing waterproofing tape before the sill goes in removes that risk.",
|
||||
"Hot-dip galvanized sub-structures cost marginally more than painted ones — and last many times longer.",
|
||||
"Finally, take the annual facade inspection seriously: repairing one joint costs next to nothing compared with redoing the whole facade.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "commercial-yield",
|
||||
"title": "چگونه بازدهی یک پروژه تجاری را پیش از ساخت تخمین بزنیم؟",
|
||||
"title_en": "How to Estimate a Commercial Project's Yield Before Building It",
|
||||
"date": "۱۵ تیر ۱۴۰۳",
|
||||
"date_en": "July 5, 2024",
|
||||
"category": "سرمایهگذاری",
|
||||
"category_en": "Investment",
|
||||
"read": "۷ دقیقه",
|
||||
"read_en": "7 min read",
|
||||
"excerpt": "مدل سادهای برای برآورد بازدهی، بر پایه جریان مخاطب، ترکیب واحدها و نرخ اجاره منطقه.",
|
||||
"excerpt_en": "A simple yield-estimation model based on visitor flow, unit mix and local rental rates.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"Estimating commercial yield starts with three numbers: daily visitor flow, the rental rate per square meter in the area, and the net-to-gross floor ratio.",
|
||||
"A common mistake is ignoring the upper levels. Without a well-designed vertical path, levels three and above usually fail to reach even half of the ground-floor rate.",
|
||||
"Finally, write the conservative scenario too — if the project still makes sense in the pessimistic case, the investment is a rational one.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "facade-materials",
|
||||
"title": "مصالح نما: مقایسه سنگ، کامپوزیت و ترموود در اقلیم ایران",
|
||||
"title_en": "Facade Materials: Stone, Composite and Thermowood in Iran's Climate",
|
||||
"date": "۲ تیر ۱۴۰۳",
|
||||
"date_en": "June 22, 2024",
|
||||
"category": "اجرا",
|
||||
"category_en": "Construction",
|
||||
"read": "۸ دقیقه",
|
||||
"read_en": "8 min read",
|
||||
"excerpt": "هر مصالح نما در اقلیم متفاوتی درست کار میکند؛ انتخاب اشتباه، هزینه نگهداری را چند برابر میکند.",
|
||||
"excerpt_en": "Every facade material works in the right climate; the wrong choice multiplies maintenance costs.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"In a dry climate, stone has the best durability — but its weight demands a precise sub-structure and structural calculations.",
|
||||
"Composite is the fast, light choice, provided you pick a fire-rated grade and account for thermal expansion along the sheet.",
|
||||
"In the humid northern climate, thermowood is the best aesthetic answer — with a twice-yearly maintenance schedule.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "renovate-or-rebuild",
|
||||
"title": "بازسازی یا نوسازی؟ معیارهای تصمیمگیری برای مالکان",
|
||||
"title_en": "Renovate or Rebuild? Decision Criteria for Property Owners",
|
||||
"date": "۲۰ خرداد ۱۴۰۳",
|
||||
"date_en": "June 9, 2024",
|
||||
"category": "معماری",
|
||||
"category_en": "Architecture",
|
||||
"read": "۶ دقیقه",
|
||||
"read_en": "6 min read",
|
||||
"excerpt": "اگر عمر سازه و ضوابط شهری اجازه دهند، بازسازی میتواند بازدهی بالاتری از تخریب کامل داشته باشد.",
|
||||
"excerpt_en": "If the structure's age and city regulations allow, renovation can outperform full demolition and rebuild.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"First the structure is assessed: if seismic retrofitting costs more than 40% of a new build, rebuilding is the more rational option.",
|
||||
"The second criterion is today's urban regulations. Sometimes demolition grants you less permissible floor area — which changes the decision entirely.",
|
||||
"In renovation, the greatest added value comes from three places: the facade, the entrance and the re-planning of the interior.",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
FAQS = [
|
||||
{"question": "حداقل سرمایه برای مشارکت در پروژههای گروه ساختمانی آرامش چقدر است؟", "question_en": "What is the minimum capital to join Aramesh Construction Group projects?", "answer": "ساختار مشارکت هر پروژه متفاوت است، اما معمولاً واحدهای سرمایهگذاری از سطح مشخصی آغاز میشوند و جزئیات دقیق در جلسه ارزیابی اولیه ارائه میشود.", "answer_en": "Each project's partnership structure is different, but investment units usually start from a defined level, and the exact details are presented in the initial assessment meeting."},
|
||||
{"question": "مدت زمان معمول ساخت یک برج مسکونی چقدر است؟", "question_en": "How long does it take to build a residential tower?", "answer": "بسته به تعداد طبقات و شرایط زمین، میانگین اجرای یک برج مسکونی بین ۲۸ تا ۴۲ ماه از زمان صدور پروانه تا تحویل کلید است.", "answer_en": "Depending on the number of floors and site conditions, a residential tower takes on average 28 to 42 months from permit issuance to key handover."},
|
||||
{"question": "آیا امکان تغییر نقشه داخلی واحد وجود دارد؟", "question_en": "Can the unit's interior layout be customized?", "answer": "بله. تا پیش از پایان مرحله سفتکاری، امکان شخصیسازی پلان داخلی با هماهنگی تیم طراحی و تأیید سازه فراهم است.", "answer_en": "Yes. Until the structural phase is complete, the interior plan can be customized in coordination with the design team and structural approval."},
|
||||
{"question": "خدمات پس از تحویل شامل چه مواردی میشود؟", "question_en": "What does the after-sales service include?", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دورهای سالانه و تضمین اجرای نما و عایقکاری.", "answer_en": "Five years of technical support for mechanical and electrical systems, an annual inspection, and warranty on facade work and insulation."},
|
||||
{"question": "پروژهها در چه شهرهایی اجرا میشوند؟", "question_en": "In which cities do you build?", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژهای نیز در جزیره کیش در دست ساخت است.", "answer_en": "Tehran, Isfahan and the northern coasts are currently our main regions, and a project is also under construction on Kish Island."},
|
||||
]
|
||||
|
||||
NAV_ITEMS = [
|
||||
{"label": "صفحه اصلی", "label_en": "Home", "href": "/"},
|
||||
{"label": "درباره ما", "label_en": "About", "href": "/about"},
|
||||
{"label": "خدمات", "label_en": "Services", "href": "/services"},
|
||||
{"label": "پروژهها", "label_en": "Projects", "href": "/projects"},
|
||||
{"label": "سرمایهگذاری", "label_en": "Investment", "href": "/investment"},
|
||||
{"label": "وبلاگ", "label_en": "Blog", "href": "/blog"},
|
||||
{"label": "سوالات متداول", "label_en": "FAQ", "href": "/faq"},
|
||||
{"label": "تماس با ما", "label_en": "Contact", "href": "/contact"},
|
||||
]
|
||||
|
||||
HERO = {
|
||||
"kicker": "از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال",
|
||||
"kicker_en": "Since 2004 — Tehran, Isfahan, Northern Coasts",
|
||||
"title": "ساختمانی که آرامش را معماری میکند",
|
||||
"title_en": "A building that architects tranquility",
|
||||
"title_highlight": "معماری",
|
||||
"title_highlight_en": "architects",
|
||||
"subtitle": "طراحی، ساخت و سرمایهگذاری در پروژههای مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده میشود و در سالها دوام میآورد.",
|
||||
"subtitle_en": "Design, construction and investment in luxury residential and commercial projects — to a standard you can see in the details and trust for years.",
|
||||
"badge_value": "۴۷",
|
||||
"badge_value_en": "47",
|
||||
"badge_label": "پروژه تحویلشده در بیستویک سال فعالیت حرفهای",
|
||||
"badge_label_en": "Projects delivered in twenty-one years of professional work",
|
||||
"cta_primary_label": "مشاهده پروژهها",
|
||||
"cta_primary_label_en": "View Projects",
|
||||
"cta_secondary_label": "مشاوره سرمایهگذاری",
|
||||
"cta_secondary_label_en": "Investment Advisory",
|
||||
"scroll_label": "اسکرول کنید",
|
||||
"scroll_label_en": "Scroll",
|
||||
}
|
||||
|
||||
STORY = {
|
||||
"title": "درباره ی ما",
|
||||
"title_en": "About Us",
|
||||
"paragraphs": [],
|
||||
"paragraphs_en": [
|
||||
"Aramesh Construction Group began as a small architecture office and is today one of Iran's recognized names in luxury construction. Through all these years one principle has not changed: a building must be made for living, not for a quick sale.",
|
||||
"Every project starts with a study of the land, light, climate and the residents' way of life; then the design, structural and execution teams carry it through a single process to key handover.",
|
||||
],
|
||||
"quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی میسازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»",
|
||||
"quote_en": "\u201cA building, for us, is not just brick and concrete; it is a commitment to the quality of life of its residents. We build spaces that keep their value and identity as time passes.\u201d",
|
||||
"quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
|
||||
"quote_author_en": "Kamran Aramesh — CEO, Aramesh Construction Group",
|
||||
"stat1_value": "۹۴٪",
|
||||
"stat1_value_en": "94%",
|
||||
"stat1_label": "تحویل بهموقع پروژهها",
|
||||
"stat1_label_en": "On-time project delivery",
|
||||
"stat2_value": "۵ سال",
|
||||
"stat2_value_en": "5 years",
|
||||
"stat2_label": "پشتیبانی پس از تحویل",
|
||||
"stat2_label_en": "Post-handover support",
|
||||
"link_label": "داستان کامل ما",
|
||||
"link_label_en": "Our full story",
|
||||
"bento": [],
|
||||
"bento_en": [
|
||||
{"src": IMAGES[0], "alt": "Facade of an Aramesh residential tower in Tehran"},
|
||||
{"src": IMAGES[1], "alt": "Central atrium of Parseh Shopping Mall"},
|
||||
{"src": IMAGES[2], "alt": "Villas built on the northern coasts"},
|
||||
{"src": IMAGES[3], "alt": "Night view of the Niavaran Twin Towers"},
|
||||
],
|
||||
}
|
||||
|
||||
SECTION_HEADINGS = [
|
||||
{"key": "services", "title_en": "Services", "lead_en": "From the first line on paper to key handover, capital management and landscape design — every stage under one roof and one quality-control system."},
|
||||
{"key": "featured_projects", "title_en": "Featured Projects", "lead_en": "The work that says the most about our standard — both the delivered projects and the ones under construction today.", "cta_label_en": "All Projects"},
|
||||
{"key": "gallery", "title_en": "Gallery", "lead_en": "Frames from the sites and delivered projects of Aramesh Construction Group."},
|
||||
{"key": "timeline", "title_en": "Our Path", "lead_en": "From a four-person office to 47 projects. Walk through the timeline of our work."},
|
||||
{"key": "why_us", "title_en": "Why Aramesh Construction Group", "lead_en": "Six reasons clients and investors come back to us a second time."},
|
||||
{"key": "testimonials", "title_en": "Client Testimonials"},
|
||||
{"key": "team", "title_en": "Our Team", "lead_en": "The people who make each project's key decisions — and answer for them."},
|
||||
{"key": "articles", "label_en": "Blog", "title_en": "Latest Articles", "lead_en": "Notes from the architecture, execution and investment teams of Aramesh Construction Group on what's happening on site and in the market.", "cta_label_en": "All Articles"},
|
||||
{"key": "faq", "title_en": "Frequently Asked Questions", "lead_en": "Short answers to the questions investors and buyers ask most."},
|
||||
{"key": "investment_models", "title_en": "Partnership Models", "lead_en": "Depending on your capital, time horizon and risk appetite, one of these three paths is right for you."},
|
||||
{"key": "process", "title_en": "How We Work", "lead_en": "Four transparent steps, from the first meeting to key handover."},
|
||||
]
|
||||
|
||||
PAGE_HEROES = [
|
||||
{"key": "about", "title_en": "About Us", "lead_en": "Aramesh Construction Group is a team of architects, structural engineers and project managers working in luxury construction since 2004."},
|
||||
{"key": "services", "title_en": "Services", "lead_en": "Eight specialized fields covering a building project's full cycle, from idea to operation."},
|
||||
{"key": "projects", "title_en": "Projects", "lead_en": "A collection of residential towers, commercial centers and private villas — from delivered projects to today's active sites."},
|
||||
{"key": "investment", "title_en": "Investment", "lead_en": "Three clear paths into the luxury construction market, with transparent financial structures and regular reporting."},
|
||||
{"key": "blog", "title_en": "Blog", "lead_en": "Notes from the architecture, execution and investment teams of Aramesh Construction Group on what's happening on site and in the market."},
|
||||
{"key": "faq", "title_en": "Frequently Asked Questions", "lead_en": "Before you call, the answer to your question might be right here. If not, we would be glad to talk directly."},
|
||||
{"key": "contact", "title_en": "Contact Us", "lead_en": "For construction consultancy, investment or a project visit, our teams in three cities are ready to respond."},
|
||||
]
|
||||
|
||||
INVESTMENT_MODELS = [
|
||||
{
|
||||
"title": "مشارکت در ساخت",
|
||||
"title_en": "Joint Construction Partnership",
|
||||
"desc": "مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک میشوند؛ اجرا، مجوزها و فروش بر عهده ماست.",
|
||||
"desc_en": "The landowner and Aramesh share the project's profit; execution, permits and sales are on us.",
|
||||
"points": [],
|
||||
"points_en": ["Free land assessment", "Transparent contract with performance guarantee", "Monthly progress reporting"],
|
||||
},
|
||||
{
|
||||
"title": "صندوق پروژهمحور",
|
||||
"title_en": "Project-Based Fund",
|
||||
"desc": "سرمایهگذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبهشده در پایان دوره.",
|
||||
"desc_en": "Investment in a specific project with purchasable units and a calculated yield at the end of the term.",
|
||||
"points": [],
|
||||
"points_en": ["Minimum 24-month term", "Quarterly valuation reports", "Investment units are transferable"],
|
||||
},
|
||||
{
|
||||
"title": "پیشخرید واحد",
|
||||
"title_en": "Pre-Purchase",
|
||||
"desc": "خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحلهای.",
|
||||
"desc_en": "Buying a residential or commercial unit in the early stages of construction, at a pre-completion price with staged payments.",
|
||||
"points": [],
|
||||
"points_en": ["Payments staged to match progress", "Interior layout customization", "Delivery-time guarantee"],
|
||||
},
|
||||
]
|
||||
|
||||
PROCESS_STEPS = [
|
||||
{"step": "۰۱", "step_en": "01", "title": "مطالعه و امکانسنجی", "title_en": "Study & Feasibility", "desc": "بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه.", "desc_en": "Site review, urban regulations and an economic yield analysis of the project."},
|
||||
{"step": "۰۲", "step_en": "02", "title": "طراحی و فاز اجرایی", "title_en": "Design & Construction Drawings", "desc": "کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشههای اجرایی.", "desc_en": "Architectural concept, structural and MEP design, and construction documentation."},
|
||||
{"step": "۰۳", "step_en": "03", "title": "اجرا و کنترل کیفیت", "title_en": "Execution & Quality Control", "desc": "ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه.", "desc_en": "Construction with continuous supervision, monthly progress reports and cost control."},
|
||||
{"step": "۰۴", "step_en": "04", "title": "تحویل و پشتیبانی", "title_en": "Handover & Support", "desc": "تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی.", "desc_en": "Key handover, operating walkthrough and five years of technical support."},
|
||||
]
|
||||
|
||||
OFFICES = [
|
||||
{"city": "تهران", "city_en": "Tehran", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "address_en": "Valiasr St., Aramesh Tower, 18th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸", "hours_en": "Saturday–Wednesday, 9 AM–6 PM"},
|
||||
{"city": "اصفهان", "city_en": "Isfahan", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "address_en": "Chaharbagh Bala St., Parseh Complex, 6th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷", "hours_en": "Saturday–Wednesday, 9 AM–5 PM"},
|
||||
{"city": "نوشهر", "city_en": "Nowshahr", "address": "بلوار دریا، دفتر پروژه دریاکنار", "address_en": "Darya Blvd., Daryakenar Project Office", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶", "hours_en": "Saturday–Thursday, 10 AM–4 PM"},
|
||||
]
|
||||
|
||||
GALLERY_IMAGES = [
|
||||
{"src": IMAGES[0], "alt_en": "Aramesh Construction Group project, No. 1"},
|
||||
{"src": IMAGES[1], "alt_en": "Aramesh Construction Group project, No. 2"},
|
||||
{"src": IMAGES[2], "alt_en": "Aramesh Construction Group project, No. 3"},
|
||||
{"src": IMAGES[3], "alt_en": "Aramesh Construction Group project, No. 4"},
|
||||
{"src": IMAGES[4], "alt_en": "Aramesh Construction Group project, No. 5"},
|
||||
{"src": IMAGES[1], "alt_en": "Aramesh Construction Group project, No. 6"},
|
||||
]
|
||||
|
||||
STATUS_LABELS = [
|
||||
{"key": "done", "label_en": "Completed"},
|
||||
{"key": "progress", "label_en": "In Progress"},
|
||||
]
|
||||
|
||||
CONTACT_SECTION = {
|
||||
"label": "تماس با ما",
|
||||
"label_en": "Contact Us",
|
||||
"title": "پروژهتان را با ما در میان بگذارید",
|
||||
"title_en": "Tell Us About Your Project",
|
||||
"lead": "شمارهتان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس میگیرد.",
|
||||
"lead_en": "Leave your number — our expert will call you within one business day for a free consultation session.",
|
||||
"address_label": "دفتر مرکزی",
|
||||
"address_label_en": "Head Office",
|
||||
"phone_label": "تلفن",
|
||||
"phone_label_en": "Phone",
|
||||
"email_label": "ایمیل",
|
||||
"email_label_en": "Email",
|
||||
"name_label": "نام",
|
||||
"name_label_en": "Name",
|
||||
"name_placeholder": "نام و نام خانوادگی",
|
||||
"name_placeholder_en": "Full name",
|
||||
"phone_field_label": "شماره تلفن",
|
||||
"phone_field_label_en": "Phone number",
|
||||
"note_label": "توضیحات",
|
||||
"note_label_en": "Details",
|
||||
"note_optional_label": "(اختیاری)",
|
||||
"note_optional_label_en": "(optional)",
|
||||
"note_placeholder": "درباره پروژه یا درخواست خود بنویسید…",
|
||||
"note_placeholder_en": "Tell us about your project or request…",
|
||||
"submit_label": "ارسال درخواست مشاوره",
|
||||
"submit_label_en": "Request a Consultation",
|
||||
"sending_label": "در حال ارسال…",
|
||||
"sending_label_en": "Sending…",
|
||||
"success_title": "درخواست شما ثبت شد",
|
||||
"success_title_en": "Your Request Was Received",
|
||||
"success_text": "از اعتماد شما سپاسگزاریم. کارشناسان ما بهزودی با شما تماس میگیرند.",
|
||||
"success_text_en": "Thank you for your trust. Our experts will contact you shortly.",
|
||||
"success_button_label": "ثبت درخواست جدید",
|
||||
"success_button_label_en": "Submit Another Request",
|
||||
}
|
||||
|
||||
NEWSLETTER = {
|
||||
"title": "خبرنامه گروه ساختمانی آرامش",
|
||||
"title_en": "Aramesh Construction Group Newsletter",
|
||||
"lead": "هر ماه یک ایمیل: پروژههای جدید، فرصتهای سرمایهگذاری و یادداشتهای تیم معماری.",
|
||||
"lead_en": "One email a month: new projects, investment opportunities and notes from the architecture team.",
|
||||
"placeholder": "نشانی ایمیل شما",
|
||||
"placeholder_en": "Your email address",
|
||||
"button_label": "عضویت",
|
||||
"button_label_en": "Subscribe",
|
||||
"sending_label": "در حال ارسال…",
|
||||
"sending_label_en": "Sending…",
|
||||
"success_text": "عضویت شما ثبت شد.",
|
||||
"success_text_en": "Your subscription has been registered.",
|
||||
}
|
||||
|
||||
FOOTER = {
|
||||
"about_text": "گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمانهایی میسازد که قرار است چند نسل در آنها زندگی کنند؛ معماری آرام، اجرای دقیق و سرمایهگذاری شفاف.",
|
||||
"about_text_en": "Since 2004, Aramesh Construction Group has been building for generations to come: calm architecture, precise execution and transparent investment.",
|
||||
"pages_heading": "صفحات",
|
||||
"pages_heading_en": "Pages",
|
||||
"services_heading": "خدمات",
|
||||
"services_heading_en": "Services",
|
||||
"contact_heading": "ارتباط با ما",
|
||||
"contact_heading_en": "Contact",
|
||||
"copyright": "© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.",
|
||||
"copyright_en": "© 2025 Aramesh Construction Group — All rights reserved.",
|
||||
"tagline": "طراحی و ساخت با دقتِ معماری",
|
||||
"tagline_en": "Designed and built with architectural precision",
|
||||
"socials_en": [
|
||||
{"label": "Instagram", "href": "#", "icon": "instagram"},
|
||||
{"label": "LinkedIn", "href": "#", "icon": "linkedin"},
|
||||
{"label": "Telegram", "href": "#", "icon": "telegram"},
|
||||
],
|
||||
}
|
||||
|
||||
SEO_META = [
|
||||
{"page_key": "home", "title_en": "Aramesh Construction Group | Luxury Residential & Commercial Buildings", "description_en": "Design, construction and investment in luxury residential and commercial projects in Iran — 21 years of experience, 47 delivered projects."},
|
||||
{"page_key": "about", "title_en": "About Us | Aramesh Construction Group", "description_en": "Architects, structural engineers and project managers in luxury construction since 2004."},
|
||||
{"page_key": "services", "title_en": "Services | Aramesh Construction Group", "description_en": "Architectural design, high-rise construction, commercial complexes, villas, renovation and project management."},
|
||||
{"page_key": "projects", "title_en": "Projects | Aramesh Construction Group", "description_en": "Residential towers, commercial centers and private villas — delivered projects and active sites."},
|
||||
{"page_key": "investment", "title_en": "Investment | Aramesh Construction Group", "description_en": "Three transparent paths into the luxury construction market: joint construction, project-based funds and pre-purchase."},
|
||||
{"page_key": "blog", "title_en": "Blog | Aramesh Construction Group", "description_en": "Articles on architecture, construction and real estate investment from the Aramesh team."},
|
||||
{"page_key": "faq", "title_en": "FAQ | Aramesh Construction Group", "description_en": "Short answers to the most frequent questions from investors and buyers."},
|
||||
{"page_key": "contact", "title_en": "Contact Us | Aramesh Construction Group", "description_en": "Get in touch for construction consultancy, investment or a project visit — offices in Tehran, Isfahan and Nowshahr."},
|
||||
]
|
||||
@@ -0,0 +1,436 @@
|
||||
"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریقشده.
|
||||
|
||||
فرانتاند یک SPA است؛ خزندههای اجتماعی (تلگرام، واتساپ و...) جاوااسکریپت اجرا نمیکنند.
|
||||
این ماژول همان index.html بیلد فرانتاند را میخواند و پیش از ارسال، تگهای
|
||||
title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
||||
بازدیدکنندههای عادی همان SPA همیشگی را میبینند و خزندهها متای درست را میخوانند.
|
||||
|
||||
دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو میشود؛
|
||||
هر دو در sitemap با hreflang متقابل و در head هر صفحه با link alternate معرفی میشوند.
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"]
|
||||
# صفحاتی که متای آنها از seo_meta خوانده میشود — کلید همان page_key است
|
||||
META_KEYS = ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
|
||||
FALLBACK_FA_BRAND = "گروه ساختمانی آرامش"
|
||||
FALLBACK_EN_BRAND = "Aramesh Construction Group"
|
||||
|
||||
|
||||
def _absolute(path: str) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
if path.startswith(("http://", "https://")):
|
||||
return path
|
||||
site = settings.site_url.rstrip("/")
|
||||
return f"{site}{path}" if site else path
|
||||
|
||||
|
||||
def _en_path(path: str) -> str:
|
||||
"""مسیر انگلیسی معادل یک مسیر فارسی — / و /en یکدیگرند."""
|
||||
return "/en" if path == "/" else f"/en{path}"
|
||||
|
||||
|
||||
def _fa_path(path: str) -> str:
|
||||
"""مسیر فارسی معادل یک مسیر انگلیسی."""
|
||||
if path == "/en":
|
||||
return "/"
|
||||
return path[3:] if path.startswith("/en/") else path
|
||||
|
||||
|
||||
def _alternates(path: str, lang: str) -> str:
|
||||
"""تگهای hreflang متقابل فارسی/انگلیسی برای head."""
|
||||
fa = path if lang == "fa" else _fa_path(path)
|
||||
en = _en_path(path) if lang == "fa" else path
|
||||
return (
|
||||
f'<link rel="alternate" hreflang="fa" href="{html.escape(_absolute(fa))}" />\n'
|
||||
f' <link rel="alternate" hreflang="en" href="{html.escape(_absolute(en))}" />\n'
|
||||
f' <link rel="alternate" hreflang="x-default" href="{html.escape(_absolute(fa))}" />'
|
||||
)
|
||||
|
||||
|
||||
def _read_index() -> str | None:
|
||||
try:
|
||||
return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _doc_text(doc: object, limit: int = 300) -> str:
|
||||
"""متن ساده سند TipTap — برای noscript و پیشنمایش."""
|
||||
words: list[str] = []
|
||||
|
||||
def walk(node: object) -> None:
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
if node.get("type") == "text" and isinstance(node.get("text"), str):
|
||||
words.append(node["text"])
|
||||
for child in node.get("content") or []:
|
||||
walk(child)
|
||||
|
||||
if isinstance(doc, dict):
|
||||
for block in doc.get("content") or []:
|
||||
walk(block)
|
||||
text = " ".join(words).strip()
|
||||
return text[:limit] + ("…" if len(text) > limit else "")
|
||||
|
||||
|
||||
def _shell(
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
path: str,
|
||||
lang: str = "fa",
|
||||
og_image: str = "",
|
||||
og_type: str = "website",
|
||||
jsonld: dict | None = None,
|
||||
noscript: str = "",
|
||||
) -> HTMLResponse:
|
||||
"""index.html فرانتاند با متای تزریقشده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
|
||||
title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND)
|
||||
description = description.strip()
|
||||
canonical = _absolute(path)
|
||||
image = _absolute(og_image)
|
||||
esc = html.escape
|
||||
alternates = _alternates(path, lang)
|
||||
|
||||
# تگ title جای تگ موجود index.html را میگیرد و در بلوک تکرار نمیشود
|
||||
tags = [
|
||||
f'<meta name="description" content="{esc(description)}" />',
|
||||
f'<meta property="og:title" content="{esc(title)}" />',
|
||||
f'<meta property="og:description" content="{esc(description)}" />',
|
||||
f'<meta property="og:type" content="{esc(og_type)}" />',
|
||||
f'<meta property="og:url" content="{esc(canonical)}" />',
|
||||
f'<meta property="og:locale" content="{"en_US" if lang == "en" else "fa_IR"}" />',
|
||||
f'<meta name="twitter:card" content="summary_large_image" />',
|
||||
]
|
||||
if image:
|
||||
tags.append(f'<meta property="og:image" content="{esc(image)}" />')
|
||||
if jsonld:
|
||||
payload = json.dumps(jsonld, ensure_ascii=False).replace("</", "<\\/")
|
||||
tags.append(f'<script type="application/ld+json">{payload}</script>')
|
||||
block = "\n ".join(tags)
|
||||
|
||||
raw = _read_index()
|
||||
if raw is None:
|
||||
# بیلد فرانتاند سوار نشده — صفحه حداقلی با خودِ متا تا خزندهها دست خالی نمانند
|
||||
lang_attr = 'lang="en" dir="ltr"' if lang == "en" else 'lang="fa" dir="rtl"'
|
||||
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
|
||||
return HTMLResponse(
|
||||
f'<!doctype html><html {lang_attr}><head><meta charset="utf-8">'
|
||||
f"<title>{esc(title)}</title>{alternates}{block}</head><body>{body}</body></html>"
|
||||
)
|
||||
|
||||
if lang == "en":
|
||||
# پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت، زبان و برند سند عوض میشود
|
||||
raw = re.sub(r'<html[^>]*>', '<html lang="en" dir="ltr">', raw, count=1)
|
||||
raw = raw.replace(FALLBACK_FA_BRAND, FALLBACK_EN_BRAND)
|
||||
raw = raw.replace('"inLanguage":"fa"', '"inLanguage":"en"')
|
||||
# description استاتیک حذف میشود تا با متای تزریقی تکراری نشود
|
||||
raw = re.sub(r'<meta name="description"[^>]*/>\s*', '', raw, count=1)
|
||||
# og:locale — در پوسته استاتیک بهروز میشود و از بلوک تزریقی حذف تا دوباره نوشته نشود
|
||||
og_locale = "en_US" if lang == "en" else "fa_IR"
|
||||
if re.search(r'<meta property="og:locale"', raw):
|
||||
raw = re.sub(
|
||||
r'(<meta property="og:locale" content=")[^"]*(")',
|
||||
lambda m: f'{m.group(1)}{og_locale}{m.group(2)}',
|
||||
raw,
|
||||
count=1,
|
||||
)
|
||||
tags = [t for t in tags if 'og:locale' not in t]
|
||||
block = "\n ".join(tags)
|
||||
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
|
||||
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1)
|
||||
if noscript:
|
||||
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
|
||||
return HTMLResponse(raw)
|
||||
|
||||
|
||||
def _brand_name(db: Session, lang: str = "fa") -> str:
|
||||
brand = db.query(models.Brand).first()
|
||||
if brand is None:
|
||||
return FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND
|
||||
if lang == "en":
|
||||
return brand.name_en or brand.name or FALLBACK_EN_BRAND
|
||||
return brand.name or FALLBACK_FA_BRAND
|
||||
|
||||
|
||||
def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None:
|
||||
return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first()
|
||||
|
||||
|
||||
def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]:
|
||||
"""عنوان و توضیح سئوی یک صفحه به زبان درخواستی — با برگشت به فارسی."""
|
||||
meta = _page_meta(db, page_key)
|
||||
if meta is None:
|
||||
return "", ""
|
||||
if lang == "en":
|
||||
return meta.title_en or meta.title or "", meta.description_en or meta.description or ""
|
||||
return meta.title or "", meta.description or ""
|
||||
|
||||
|
||||
# ---------- robots.txt و sitemap.xml ----------
|
||||
|
||||
|
||||
@router.get("/robots.txt", response_class=Response)
|
||||
def robots(db: Session = Depends(get_db)):
|
||||
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
|
||||
if settings.site_url:
|
||||
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
|
||||
return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
def _sitemap_entry(loc: str, fa: str, en: str) -> str:
|
||||
esc = html.escape
|
||||
return (
|
||||
f" <url><loc>{esc(_absolute(loc))}</loc>\n"
|
||||
f' <xhtml:link rel="alternate" hreflang="fa" href="{esc(_absolute(fa))}" />\n'
|
||||
f' <xhtml:link rel="alternate" hreflang="en" href="{esc(_absolute(en))}" />\n'
|
||||
f" </url>"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sitemap.xml", response_class=Response)
|
||||
def sitemap(db: Session = Depends(get_db)):
|
||||
fa_urls = list(STATIC_PATHS)
|
||||
fa_urls += [
|
||||
f"/blog/{a.slug}"
|
||||
for a in db.query(models.Article)
|
||||
.filter(models.Article.status == "published")
|
||||
.order_by(models.Article.position, models.Article.id)
|
||||
.all()
|
||||
]
|
||||
fa_urls += [
|
||||
f"/projects/{p.slug}"
|
||||
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
||||
]
|
||||
items = "\n".join(_sitemap_entry(u, u, _en_path(u)) for u in fa_urls)
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" '
|
||||
'xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'
|
||||
f"{items}\n</urlset>\n"
|
||||
)
|
||||
return Response(content=xml, media_type="application/xml; charset=utf-8")
|
||||
|
||||
|
||||
# ---------- صفحات وبلاگ ----------
|
||||
|
||||
|
||||
@router.get("/blog", response_class=HTMLResponse)
|
||||
def blog_list(db: Session = Depends(get_db)):
|
||||
meta = _page_meta(db, "blog")
|
||||
return _shell(
|
||||
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
|
||||
description=meta.description if meta else "",
|
||||
path="/blog",
|
||||
)
|
||||
|
||||
|
||||
def _article_pair(article: models.Article, lang: str) -> tuple[str, str]:
|
||||
"""عنوان و چکیده مقاله به زبان درخواستی — با برگشت به فارسی."""
|
||||
if lang == "en":
|
||||
return article.title_en or article.title, article.excerpt_en or article.excerpt
|
||||
return article.title, article.excerpt
|
||||
|
||||
|
||||
@router.get("/blog/{slug}", response_class=HTMLResponse)
|
||||
def blog_detail(slug: str, db: Session = Depends(get_db)):
|
||||
article = (
|
||||
db.query(models.Article)
|
||||
.filter(models.Article.slug == slug, models.Article.status == "published")
|
||||
.first()
|
||||
)
|
||||
if article is None:
|
||||
# مقاله ناموجود یا پیشنویس — پوسته ساده بدون متا؛ SPA پیام «یافت نشد» را نشان میدهد
|
||||
return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}")
|
||||
|
||||
brand = _brand_name(db)
|
||||
title, excerpt = _article_pair(article, "fa")
|
||||
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
||||
jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": title,
|
||||
"description": excerpt,
|
||||
"image": _absolute(article.image),
|
||||
"inLanguage": "fa",
|
||||
"author": {"@type": "Organization", "name": brand},
|
||||
"publisher": {"@type": "Organization", "name": brand},
|
||||
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
||||
}
|
||||
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||
return _shell(
|
||||
title=f"{title} | {brand}",
|
||||
description=excerpt,
|
||||
path=f"/blog/{article.slug}",
|
||||
og_image=article.image,
|
||||
og_type="article",
|
||||
jsonld=jsonld,
|
||||
noscript=noscript,
|
||||
)
|
||||
|
||||
|
||||
# ---------- صفحات پروژه ----------
|
||||
|
||||
|
||||
@router.get("/projects", response_class=HTMLResponse)
|
||||
def projects_list(db: Session = Depends(get_db)):
|
||||
meta = _page_meta(db, "projects")
|
||||
return _shell(
|
||||
title=meta.title if meta and meta.title else "پروژهها | " + _brand_name(db),
|
||||
description=meta.description if meta else "",
|
||||
path="/projects",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{slug}", response_class=HTMLResponse)
|
||||
def project_detail(slug: str, db: Session = Depends(get_db)):
|
||||
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
||||
if project is None:
|
||||
return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}")
|
||||
|
||||
brand = _brand_name(db)
|
||||
jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": project.title,
|
||||
"description": project.summary,
|
||||
"image": _absolute(project.image),
|
||||
"inLanguage": "fa",
|
||||
}
|
||||
return _shell(
|
||||
title=f"{project.title} | {brand}",
|
||||
description=project.summary,
|
||||
path=f"/projects/{project.slug}",
|
||||
og_image=project.image,
|
||||
jsonld=jsonld,
|
||||
)
|
||||
|
||||
|
||||
# ---------- صفحات انگلیسی (/en/...) ----------
|
||||
|
||||
|
||||
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
||||
brand = _brand_name(db, "en")
|
||||
title, excerpt = _article_pair(article, "en")
|
||||
body = article.body_en if isinstance(article.body_en, dict) else article.body
|
||||
first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2])
|
||||
jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": title,
|
||||
"description": excerpt,
|
||||
"image": _absolute(article.image),
|
||||
"inLanguage": "en",
|
||||
"author": {"@type": "Organization", "name": brand},
|
||||
"publisher": {"@type": "Organization", "name": brand},
|
||||
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
|
||||
}
|
||||
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||
return _shell(
|
||||
title=f"{title} | {brand}",
|
||||
description=excerpt,
|
||||
path=f"/en/blog/{article.slug}",
|
||||
lang="en",
|
||||
og_image=article.image,
|
||||
og_type="article",
|
||||
jsonld=jsonld,
|
||||
noscript=noscript,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/en", response_class=HTMLResponse)
|
||||
@router.get("/en/", response_class=HTMLResponse)
|
||||
def en_home(db: Session = Depends(get_db)):
|
||||
title, description = _meta_for(db, "home", "en")
|
||||
if not title:
|
||||
title = _brand_name(db, "en")
|
||||
return _shell(title=title, description=description, path="/en", lang="en")
|
||||
|
||||
|
||||
@router.get("/en/blog", response_class=HTMLResponse)
|
||||
def en_blog_list(db: Session = Depends(get_db)):
|
||||
meta = _page_meta(db, "blog")
|
||||
title = meta.title_en or meta.title if meta else ""
|
||||
description = meta.description_en or meta.description if meta else ""
|
||||
if not title:
|
||||
title = "Blog | " + _brand_name(db, "en")
|
||||
return _shell(title=title, description=description, path="/en/blog", lang="en")
|
||||
|
||||
|
||||
@router.get("/en/blog/{slug}", response_class=HTMLResponse)
|
||||
def en_blog_detail(slug: str, db: Session = Depends(get_db)):
|
||||
article = (
|
||||
db.query(models.Article)
|
||||
.filter(models.Article.slug == slug, models.Article.status == "published")
|
||||
.first()
|
||||
)
|
||||
if article is None:
|
||||
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en")
|
||||
return _en_blog_detail(article, db)
|
||||
|
||||
|
||||
@router.get("/en/projects", response_class=HTMLResponse)
|
||||
def en_projects_list(db: Session = Depends(get_db)):
|
||||
meta = _page_meta(db, "projects")
|
||||
title = meta.title_en or meta.title if meta else ""
|
||||
description = meta.description_en or meta.description if meta else ""
|
||||
if not title:
|
||||
title = "Projects | " + _brand_name(db, "en")
|
||||
return _shell(title=title, description=description, path="/en/projects", lang="en")
|
||||
|
||||
|
||||
@router.get("/en/projects/{slug}", response_class=HTMLResponse)
|
||||
def en_project_detail(slug: str, db: Session = Depends(get_db)):
|
||||
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
||||
if project is None:
|
||||
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/projects/{slug}", lang="en")
|
||||
|
||||
brand = _brand_name(db, "en")
|
||||
title = project.title_en or project.title
|
||||
summary = project.summary_en or project.summary
|
||||
jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": title,
|
||||
"description": summary,
|
||||
"image": _absolute(project.image),
|
||||
"inLanguage": "en",
|
||||
}
|
||||
return _shell(
|
||||
title=f"{title} | {brand}",
|
||||
description=summary,
|
||||
path=f"/en/projects/{project.slug}",
|
||||
lang="en",
|
||||
og_image=project.image,
|
||||
jsonld=jsonld,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/en/{path:path}", response_class=HTMLResponse)
|
||||
def en_page(path: str, db: Session = Depends(get_db)):
|
||||
"""بقیه مسیرهای انگلیسی (about، services، ...) — متا از seo_meta به انگلیسی."""
|
||||
page_key = path.split("/")[0] if path else ""
|
||||
title, description = _meta_for(db, page_key, "en") if page_key in META_KEYS else ("", "")
|
||||
brand = _brand_name(db, "en")
|
||||
if not title:
|
||||
# عنوان پیشفرض از برچسب ناوبری انگلیسی همان صفحه
|
||||
nav = db.query(models.NavItem).filter(models.NavItem.href == f"/{page_key}").first() if page_key else None
|
||||
label = (nav.label_en or nav.label) if nav else ""
|
||||
title = f"{label} | {brand}" if label else brand
|
||||
return _shell(title=title, description=description, path=f"/en/{path}".rstrip("/"), lang="en")
|
||||
@@ -0,0 +1,4 @@
|
||||
# وابستگیهای توسعه و تست — pip install -r requirements-dev.txt
|
||||
-r requirements.txt
|
||||
pytest==8.3.4
|
||||
httpx==0.27.2
|
||||
@@ -1,9 +1,13 @@
|
||||
fastapi>=0.115.8
|
||||
uvicorn[standard]>=0.34.0
|
||||
sqlalchemy>=2.0.36
|
||||
psycopg[binary]>=3.2.3
|
||||
pydantic>=2.10.4
|
||||
pydantic-settings>=2.6.1
|
||||
PyJWT>=2.10.1
|
||||
bcrypt>=4.2.1
|
||||
email-validator>=2.2.0
|
||||
# نسخهها دقیق پین شدهاند تا ارتقای خودکار یک وابستگی، استقرار را نشکند.
|
||||
# ارتقا: نسخه را دستی بالا ببرید و روی محیط آمادهسازی تست کنید.
|
||||
fastapi==0.115.8
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
psycopg[binary]==3.2.3
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.6.1
|
||||
PyJWT==2.10.1
|
||||
bcrypt==4.2.1
|
||||
email-validator==2.2.0
|
||||
boto3==1.35.0
|
||||
python-multipart==0.0.18
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""پیکربندی تستها — دیتابیس SQLite فایلمحور و دادههای حداقلی.
|
||||
|
||||
متغیرهای محیطی پیش از ایمپورت app تنظیم میشوند تا settings همانها را بخواند.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite:///./test.db?check_same_thread=false"
|
||||
os.environ["SECRET_KEY"] = "test-secret-key"
|
||||
os.environ["ADMIN_USERNAME"] = "admin"
|
||||
os.environ["ADMIN_PASSWORD"] = "test-admin-pass-1"
|
||||
os.environ["SITE_URL"] = "https://test.example"
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import models
|
||||
from app.auth import hash_password
|
||||
from app.config import settings
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.main import app
|
||||
|
||||
TEST_DB = pathlib.Path("test.db")
|
||||
|
||||
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def seed_db(tmp_path_factory):
|
||||
# اتصالات ساخت جدول در ایمپورت ماژول باید بسته شوند تا فایل قدیمی حذف شود
|
||||
engine.dispose()
|
||||
if TEST_DB.exists():
|
||||
TEST_DB.unlink()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# dist ساختگی فرانتاند برای تست تزریق متا در صفحات جزئیات
|
||||
dist = tmp_path_factory.mktemp("dist")
|
||||
(dist / "index.html").write_text(
|
||||
"<!doctype html><html><head><title>Shell</title></head>"
|
||||
"<body><div id=\"root\"></div></body></html>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings.frontend_dist = str(dist)
|
||||
settings.site_url = "https://test.example"
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(models.AdminUser(username="admin", password_hash=hash_password("test-admin-pass-1")))
|
||||
db.add(models.Brand(name="گروه ساختمانی آرامش"))
|
||||
db.add(
|
||||
models.Article(
|
||||
slug="published-post", title="مقاله منتشرشده", status="published",
|
||||
excerpt="چکیده مقاله", body=DOC, position=1,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
models.Article(
|
||||
slug="draft-post", title="پیشنویس", status="draft",
|
||||
excerpt="چکیده پیشنویس", body=DOC, position=2,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
yield
|
||||
db.close()
|
||||
engine.dispose()
|
||||
if TEST_DB.exists():
|
||||
TEST_DB.unlink()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_rate_limiter():
|
||||
from app import rate_limit
|
||||
|
||||
rate_limit._failed.clear()
|
||||
yield
|
||||
rate_limit._failed.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def auth_client(client):
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
|
||||
assert res.status_code == 200, res.text
|
||||
token = res.json()["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield client
|
||||
client.headers.pop("Authorization", None)
|
||||
@@ -0,0 +1,270 @@
|
||||
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
||||
|
||||
import re
|
||||
|
||||
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
|
||||
# ---------- عمومی ----------
|
||||
|
||||
|
||||
def test_health(client):
|
||||
res = client.get("/api/health")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_site_hides_draft_articles(client):
|
||||
res = client.get("/api/site")
|
||||
assert res.status_code == 200
|
||||
slugs = [a["slug"] for a in res.json()["articles"]]
|
||||
assert "published-post" in slugs
|
||||
assert "draft-post" not in slugs
|
||||
|
||||
|
||||
def test_public_articles_hide_drafts(client):
|
||||
res = client.get("/api/articles")
|
||||
assert all(a["status"] == "published" for a in res.json())
|
||||
assert client.get("/api/articles/draft-post").status_code == 404
|
||||
|
||||
|
||||
def test_admin_endpoints_reject_anonymous(client):
|
||||
assert client.get("/api/admin/articles").status_code == 401
|
||||
|
||||
|
||||
# ---------- احراز هویت ----------
|
||||
|
||||
|
||||
def test_login_success_and_failure(client):
|
||||
assert (
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}).status_code
|
||||
== 401
|
||||
)
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["access_token"]
|
||||
|
||||
|
||||
def test_change_password(auth_client, client):
|
||||
res = auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "wrong", "new_password": "brand-new-pass-9"},
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
res = auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "test-admin-pass-1", "new_password": "brand-new-pass-9"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
# رمز قدیمی رد و رمز جدید قبول میشود
|
||||
assert (
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"}).status_code
|
||||
== 401
|
||||
)
|
||||
assert (
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"}).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
# بازگرداندن رمز برای تستهای بعدی
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"})
|
||||
auth = {"Authorization": f"Bearer {res.json()['access_token']}"}
|
||||
res = client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "brand-new-pass-9", "new_password": "test-admin-pass-1"},
|
||||
headers=auth,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
def test_login_rate_limited(client):
|
||||
for _ in range(5):
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert res.status_code == 401
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert res.status_code == 429
|
||||
assert "Retry-After" in res.headers
|
||||
|
||||
|
||||
# ---------- CRUD مدیریت ----------
|
||||
|
||||
|
||||
def test_article_crud_and_draft_flow(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={"slug": "flow-post", "title": "مقاله جریان", "body": DOC},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
article = created.json()
|
||||
# مقاله تازه پیشنویس است و در سایت عمومی دیده نمیشود
|
||||
assert article["status"] == "draft"
|
||||
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
|
||||
|
||||
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"seo_keyphrase": "ساختمان"})
|
||||
assert res.json()["seo_keyphrase"] == "ساختمان"
|
||||
|
||||
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"status": "published"})
|
||||
assert res.status_code == 200
|
||||
assert "flow-post" in [a["slug"] for a in client.get("/api/articles").json()]
|
||||
|
||||
assert auth_client.delete(f"/api/admin/articles/{article['id']}").status_code == 204
|
||||
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
|
||||
|
||||
|
||||
def test_service_roundtrip_and_reorder(auth_client):
|
||||
ids = []
|
||||
for name in ("خدمت الف", "خدمت ب"):
|
||||
res = auth_client.post("/api/admin/services", json={"title": name})
|
||||
assert res.status_code == 201
|
||||
ids.append(res.json()["id"])
|
||||
|
||||
res = auth_client.post("/api/admin/services/reorder", json={"ids": ids[::-1]})
|
||||
assert res.status_code == 200
|
||||
ordered = [s["id"] for s in res.json() if s["id"] in ids]
|
||||
assert ordered == ids[::-1]
|
||||
|
||||
res = auth_client.patch(f"/api/admin/services/{ids[0]}", json={"desc": "توضیح تازه"})
|
||||
assert res.json()["desc"] == "توضیح تازه"
|
||||
|
||||
for i in ids:
|
||||
assert auth_client.delete(f"/api/admin/services/{i}").status_code == 204
|
||||
|
||||
|
||||
def test_seo_meta_create_and_patch(auth_client):
|
||||
res = auth_client.post(
|
||||
"/api/admin/seo-meta", json={"page_key": "test-page", "title": "عنوان", "description": "توضیح"}
|
||||
)
|
||||
assert res.status_code == 201
|
||||
meta_id = res.json()["id"]
|
||||
res = auth_client.patch(f"/api/admin/seo-meta/{meta_id}", json={"title": "عنوان تازه"})
|
||||
assert res.json()["title"] == "عنوان تازه"
|
||||
assert auth_client.delete(f"/api/admin/seo-meta/{meta_id}").status_code == 204
|
||||
|
||||
|
||||
# ---------- سئو ----------
|
||||
|
||||
|
||||
def test_robots_txt(client):
|
||||
res = client.get("/robots.txt")
|
||||
assert res.status_code == 200
|
||||
assert "Disallow: /admin" in res.text
|
||||
assert "Sitemap: https://test.example/sitemap.xml" in res.text
|
||||
|
||||
|
||||
def test_sitemap_includes_published_only(client):
|
||||
res = client.get("/sitemap.xml")
|
||||
assert res.status_code == 200
|
||||
assert "/blog/published-post" in res.text
|
||||
assert "/blog/draft-post" not in res.text
|
||||
assert res.text.startswith("<?xml")
|
||||
|
||||
|
||||
def test_head_injection_for_article(client):
|
||||
res = client.get("/blog/published-post")
|
||||
assert res.status_code == 200
|
||||
assert "مقاله منتشرشده" in res.text
|
||||
assert 'property="og:title"' in res.text
|
||||
assert "application/ld+json" in res.text
|
||||
assert "https://test.example/blog/published-post" in res.text
|
||||
|
||||
|
||||
def test_head_not_injected_for_draft(client):
|
||||
res = client.get("/blog/draft-post")
|
||||
assert res.status_code == 200
|
||||
assert "پیشنویس" not in res.text # عنوان پیشنویس افشا نمیشود
|
||||
assert "application/ld+json" not in res.text
|
||||
|
||||
|
||||
def test_head_replaces_title_once(client):
|
||||
res = client.get("/blog/published-post")
|
||||
assert len(re.findall(r"<title>", res.text)) == 1
|
||||
|
||||
|
||||
# ---------- دوزبانه ----------
|
||||
|
||||
|
||||
def test_bilingual_project_and_en_seo_shell(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/projects",
|
||||
json={
|
||||
"slug": "bilingual-tower",
|
||||
"title": "برج دوزبانه",
|
||||
"title_en": "Bilingual Tower",
|
||||
"summary": "خلاصه فارسی",
|
||||
"summary_en": "English summary",
|
||||
"body": [],
|
||||
"body_en": ["English paragraph"],
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
pid = created.json()["id"]
|
||||
|
||||
# GET /api/site هر دو زبان را برمیگرداند
|
||||
project = next(p for p in client.get("/api/site").json()["projects"] if p["slug"] == "bilingual-tower")
|
||||
assert project["title"] == "برج دوزبانه"
|
||||
assert project["title_en"] == "Bilingual Tower"
|
||||
assert project["body_en"] == ["English paragraph"]
|
||||
|
||||
# پوسته سئوی انگلیسی — جهت و متای انگلیسی و hreflang متقابل
|
||||
res = client.get("/en/projects/bilingual-tower")
|
||||
assert res.status_code == 200
|
||||
assert 'lang="en" dir="ltr"' in res.text
|
||||
assert "Bilingual Tower" in res.text
|
||||
assert "English summary" in res.text
|
||||
assert 'hreflang="fa"' in res.text
|
||||
assert "https://test.example/projects/bilingual-tower" in res.text
|
||||
assert "https://test.example/en/projects/bilingual-tower" in res.text
|
||||
|
||||
# sitemap دو زبانه با alternates
|
||||
sm = client.get("/sitemap.xml").text
|
||||
assert "xmlns:xhtml" in sm
|
||||
assert 'hreflang="en"' in sm
|
||||
assert "/en/projects/bilingual-tower" in sm
|
||||
|
||||
# پوسته فارسی هم alternates دارد
|
||||
assert 'hreflang="en"' in client.get("/blog/published-post").text
|
||||
|
||||
# صفحه استاتیک انگلیسی از مسیر catch-all سرو میشود
|
||||
res = client.get("/en/about")
|
||||
assert res.status_code == 200
|
||||
assert 'lang="en"' in res.text
|
||||
|
||||
assert auth_client.delete(f"/api/admin/projects/{pid}").status_code == 204
|
||||
assert "bilingual-tower" not in client.get("/sitemap.xml").text
|
||||
|
||||
|
||||
def test_bilingual_article_en_shell(auth_client, client):
|
||||
articles = auth_client.get("/api/admin/articles").json()
|
||||
article = next(a for a in articles if a["slug"] == "published-post")
|
||||
res = auth_client.patch(
|
||||
f"/api/admin/articles/{article['id']}",
|
||||
json={
|
||||
"title_en": "Published EN",
|
||||
"excerpt_en": "English excerpt",
|
||||
"body_en": DOC,
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
res = client.get("/en/blog/published-post")
|
||||
assert res.status_code == 200
|
||||
assert "Published EN" in res.text
|
||||
assert "English excerpt" in res.text
|
||||
assert '"inLanguage": "en"' in res.text
|
||||
|
||||
|
||||
def test_en_backfill_fills_only_empty_fields(auth_client, client):
|
||||
"""backfill ترجمههای خالی را پر میکند و مقدار ثبتشده را بازنویسی نمیکند."""
|
||||
from app.migrations import backfill_en_content
|
||||
|
||||
backfill_en_content()
|
||||
brand = client.get("/api/site").json()["brand"]
|
||||
assert brand["name_en"] == "Aramesh Construction Group"
|
||||
|
||||
# مقدار دستیِ ادمین محترم شمرده میشود
|
||||
res = auth_client.patch(f"/api/admin/brand/{brand['id']}", json={"name_en": "Custom EN Name"})
|
||||
assert res.status_code == 200
|
||||
backfill_en_content()
|
||||
assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name"
|
||||
@@ -0,0 +1,57 @@
|
||||
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورتهای استاندارد ۸۰/۴۴۳.
|
||||
# پیشنیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ بهعلاوه رکورد DNS دامنه به این سرور.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
#
|
||||
# همه درخواستهای HTTP به HTTPS هدایت میشوند و مسیریابیها فقط روی دامنه DOMAIN پاسخ میدهند.
|
||||
|
||||
services:
|
||||
traefik:
|
||||
command: !override
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:?ACME_EMAIL را در .env تنظیم کنید}
|
||||
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.le.acme.httpchallenge=true
|
||||
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
|
||||
ports: !override
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes: !override
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- letsencrypt_data:/letsencrypt
|
||||
|
||||
backend:
|
||||
environment:
|
||||
# سایت هممبدأ است — CORS لازم نیست
|
||||
CORS_ORIGINS: '[]'
|
||||
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
||||
- traefik.http.routers.api.entrypoints=websecure
|
||||
- traefik.http.routers.api.tls.certresolver=le
|
||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
||||
- traefik.http.routers.seo.entrypoints=websecure
|
||||
- traefik.http.routers.seo.tls.certresolver=le
|
||||
|
||||
minio:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
|
||||
- traefik.http.routers.media.entrypoints=websecure
|
||||
- traefik.http.routers.media.tls.certresolver=le
|
||||
|
||||
frontend:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
|
||||
- traefik.http.routers.web.entrypoints=websecure
|
||||
- traefik.http.routers.web.tls.certresolver=le
|
||||
|
||||
volumes:
|
||||
letsencrypt_data:
|
||||
@@ -1,10 +1,29 @@
|
||||
# مقادیر محرمانه از فایل .env ریشه خوانده میشوند — از .env.example کپی کنید:
|
||||
# cp .env.example .env
|
||||
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README).
|
||||
|
||||
services:
|
||||
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند) و /media (MinIO)
|
||||
traefik:
|
||||
image: traefik:v3
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
# فقط کانتینرهای همین استک — با استکهای دیگر هممیزبان تداخل نام روتر پیش نمیآید
|
||||
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
|
||||
- --entrypoints.web.address=:80
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-5173}:80"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: aramesh
|
||||
POSTGRES_PASSWORD: aramesh
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||
POSTGRES_DB: arameshplaza
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
@@ -14,31 +33,133 @@ services:
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
ports:
|
||||
- "9001:9001" # کنسول MinIO — در محیط واقعی با فایروال ببندید
|
||||
- "19000:9000" # دسترسی مستقیم/لوکال به MinIO (اختیاری — تصاویر از مسیر /media سرو میشوند)
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.media.rule=PathPrefix(`/media`)
|
||||
- traefik.http.routers.media.entrypoints=web
|
||||
- traefik.http.routers.media.priority=100
|
||||
- traefik.http.routers.media.middlewares=media-rewrite
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
||||
- traefik.http.services.media.loadbalancer.server.port=9000
|
||||
|
||||
# یکبار اجرا میشود: ساخت باکت، بازگذاشتن خواندن عمومی و پیشبارگذاری تصویرهای seed.
|
||||
# تصویرهای داخل seed-media/ (که با پیشوند /media در دیتابیس ثبتاند) روی یک حجم تازه
|
||||
# وارد باکت میشوند تا هر نسخه تازهکلون/بیلد، همان عکسها را داشته باشد؛
|
||||
# روی حجم موجود، mc cp فایلهای تکراری را رد میکند و دادهتازه را نمیخورد.
|
||||
minio-setup:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
MINIO_BUCKET: aramesh-media
|
||||
volumes:
|
||||
- ./seed-media:/seed-media:ro
|
||||
restart: "no"
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||
mc mb --ignore-existing local/$$MINIO_BUCKET &&
|
||||
mc anonymous set download local/$$MINIO_BUCKET &&
|
||||
echo 'minio-setup: bucket ready' &&
|
||||
cd /seed-media && mc mirror --overwrite . local/$$MINIO_BUCKET/ &&
|
||||
echo 'minio-setup: seed media mirrored'
|
||||
"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:aramesh@db:5432/arameshplaza
|
||||
SECRET_KEY: change-me-to-a-long-random-string
|
||||
CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]'
|
||||
ADMIN_USERNAME: admin
|
||||
ADMIN_PASSWORD: aramesh123
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost:5173","http://127.0.0.1:5173"]}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD را در .env تنظیم کنید}
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-aramesh}
|
||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
S3_BUCKET: aramesh-media
|
||||
MEDIA_PUBLIC_BASE: /media
|
||||
# نشانی عمومی سایت — برای sitemap و آدرس مطلق OG (مثل https://aramesh.example)
|
||||
SITE_URL: ${SITE_URL:-}
|
||||
# بیلد فرانتاند برای تزریق متای سئو در صفحات /blog و /projects
|
||||
FRONTEND_DIST: /srv/frontend
|
||||
volumes:
|
||||
- ./frontend/dist:/srv/frontend:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8000:8000"
|
||||
minio:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.api.rule=PathPrefix(`/api`)
|
||||
- traefik.http.routers.api.entrypoints=web
|
||||
- traefik.http.routers.api.priority=100
|
||||
- traefik.http.services.api.loadbalancer.server.port=8000
|
||||
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
|
||||
# مسیرهای /en/... هم همه به بکاند میروند تا متای سئوی انگلیسی و html انگلیسی تزریق شود
|
||||
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
|
||||
- traefik.http.routers.seo.entrypoints=web
|
||||
- traefik.http.routers.seo.priority=50
|
||||
- traefik.http.routers.seo.service=api
|
||||
|
||||
# فقط سرو فایلهای استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_API_URL: ""
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "5173:80"
|
||||
- minio
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.web.rule=PathPrefix(`/`)
|
||||
- traefik.http.routers.web.entrypoints=web
|
||||
- traefik.http.routers.web.priority=1
|
||||
- traefik.http.services.web.loadbalancer.server.port=80
|
||||
|
||||
# ابزار پشتیبانگیری — فقط با پروفایل tools اجرا میشود (scripts/backup.sh این کار را میکند)
|
||||
mc:
|
||||
image: minio/mc:latest
|
||||
profiles: ["tools"]
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
minio_data:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
:80 {
|
||||
root * /srv
|
||||
encode gzip
|
||||
|
||||
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمیگردند
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
|
||||
# کش بلندمدت برای فایلهای هشدار بیلد
|
||||
@assets path /assets/*
|
||||
header @assets Cache-Control "public, immutable, max-age=31536000"
|
||||
}
|
||||
@@ -1,23 +1,10 @@
|
||||
# ---------- مرحله ساخت ----------
|
||||
FROM node:22-alpine AS build
|
||||
# ---------- فرانتاند فقط سرو میکند ----------
|
||||
# بیلد روی هاست انجام میشود (npm install && npm run build) و خروجی dist/
|
||||
# داخل ایمیج کپی میشود — شبکه کانتینر بیلد برای npm قابلاعتماد نیست.
|
||||
|
||||
WORKDIR /app
|
||||
FROM caddy:2-alpine
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# آدرس API در زمان بیلد؛ پیشفرض خالی یعنی درخواستهای نسبی (/api) که nginx آنها را به بکاند پروکسی میکند
|
||||
ARG VITE_API_URL=""
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---------- مرحله سرو ----------
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY dist /srv
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -1,8 +1,62 @@
|
||||
# Magic Patterns - Vite Template
|
||||
|
||||
This code was generated by [Magic Patterns](https://magicpatterns.com) for this design: [Source Design](https://www.magicpatterns.com/c/ka4i8ikygtakmdobhws6nu)
|
||||
# Aramesh Construction Group — Frontend
|
||||
|
||||
## Getting Started
|
||||
A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
|
||||
|
||||
1. Run `npm install`
|
||||
2. Run `npm run dev`
|
||||
## Typography & Smart Bidi Text
|
||||
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
|
||||
|
||||
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
|
||||
|
||||
## Recent UI/UX Improvements
|
||||
* **Tiptap Editor Fixes**:
|
||||
* Resolved the initialization bug where the editor body remained empty during edit mode.
|
||||
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
|
||||
* **Dynamic TOC & Layout**:
|
||||
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
|
||||
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
|
||||
|
||||
## CMS, SEO & Editor Features
|
||||
|
||||
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
|
||||
|
||||
* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting.
|
||||
* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema.
|
||||
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
|
||||
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
|
||||
* **Custom Tiptap WYSIWYG Editor:**
|
||||
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
|
||||
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls.
|
||||
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
|
||||
* **Full Jalali (Shamsi) Calendar Integration:**
|
||||
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
|
||||
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
```
|
||||
|
||||
In development mode, Vite proxies `/api` and `/media` requests to Traefik (port 5173). Therefore, the Docker stack must be running.
|
||||
The backend API URL is read from the `VITE_API_URL` environment variable in `src/config.ts`; an empty default implies same-origin requests.
|
||||
|
||||
## Build & Production
|
||||
|
||||
The build process runs on the host machine. The Docker container only serves the generated static files in the `dist/` directory using Caddy:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
|
||||
```
|
||||
|
||||
> **Note:** The backend reads the `dist/` directory (mounted at `/srv/frontend` in Docker Compose) to inject SEO metadata into the `/blog` and `/projects` pages. After making any frontend changes, running `npm run build` and restarting the backend is sufficient.
|
||||
|
||||
## Key Modules & Architecture
|
||||
|
||||
* `src/api.tsx` — Handles site data loading and draft article merging for the admin view.
|
||||
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
|
||||
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
|
||||
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/images/logo.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Aramesh Building Group Luxury Website</title>
|
||||
<title>گروه ساختمانی آرامش</title>
|
||||
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژههای ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایهگذاری ساختوساز." />
|
||||
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"Organization","name":"گروه ساختمانی آرامش","inLanguage":"fa"}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// زبان و جهت سند پیش از اولین رندر، از خود مسیر — /en/... انگلیسی و LTR است
|
||||
// تا جهت صفحه از نخستین فریم درست باشد (نسخه انگلیسی سمت سرور هم همین را تزریق میکند)
|
||||
if (location.pathname === '/en' || location.pathname.startsWith('/en/')) {
|
||||
document.documentElement.setAttribute('lang', 'en');
|
||||
document.documentElement.setAttribute('dir', 'ltr');
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# مسیریابی SPA — همه مسیرها به index.html برمیگردند
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# پروکسی درخواستهای API به سرویس بکاند
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# تصاویر: بدون کش طولانی — تا تغییرات سریع دیده شوند
|
||||
location ~* ^/images/ {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# کش استاتیک
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|ico|woff2?)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,21 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@tiptap/extension-image": "^3.31.3",
|
||||
"@tiptap/extension-link": "^3.31.3",
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
"slugify": "^1.6.9",
|
||||
"sweetalert2": "^11.26.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -922,6 +932,31 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -1427,6 +1462,465 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz",
|
||||
"integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz",
|
||||
"integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==",
|
||||
"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-bold": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz",
|
||||
"integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz",
|
||||
"integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz",
|
||||
"integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz",
|
||||
"integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz",
|
||||
"integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==",
|
||||
"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-document": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz",
|
||||
"integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz",
|
||||
"integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz",
|
||||
"integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz",
|
||||
"integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz",
|
||||
"integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz",
|
||||
"integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz",
|
||||
"integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==",
|
||||
"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-image": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.31.3.tgz",
|
||||
"integrity": "sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz",
|
||||
"integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz",
|
||||
"integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz",
|
||||
"integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==",
|
||||
"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-list-item": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz",
|
||||
"integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz",
|
||||
"integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz",
|
||||
"integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz",
|
||||
"integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz",
|
||||
"integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
|
||||
"integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz",
|
||||
"integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz",
|
||||
"integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==",
|
||||
"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/pm": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz",
|
||||
"integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.4.1",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-dropcursor": "^1.8.2",
|
||||
"prosemirror-gapcursor": "^1.4.1",
|
||||
"prosemirror-history": "^1.5.0",
|
||||
"prosemirror-inputrules": "^1.5.1",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.11",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.5",
|
||||
"prosemirror-transform": "^1.12.0",
|
||||
"prosemirror-view": "^1.42.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz",
|
||||
"integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-equals": "^5.3.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.31.3",
|
||||
"@tiptap/extension-floating-menu": "^3.31.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz",
|
||||
"integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-blockquote": "3.31.3",
|
||||
"@tiptap/extension-bold": "3.31.3",
|
||||
"@tiptap/extension-bullet-list": "3.31.3",
|
||||
"@tiptap/extension-code": "3.31.3",
|
||||
"@tiptap/extension-code-block": "3.31.3",
|
||||
"@tiptap/extension-document": "3.31.3",
|
||||
"@tiptap/extension-dropcursor": "3.31.3",
|
||||
"@tiptap/extension-gapcursor": "3.31.3",
|
||||
"@tiptap/extension-hard-break": "3.31.3",
|
||||
"@tiptap/extension-heading": "3.31.3",
|
||||
"@tiptap/extension-horizontal-rule": "3.31.3",
|
||||
"@tiptap/extension-italic": "3.31.3",
|
||||
"@tiptap/extension-link": "3.31.3",
|
||||
"@tiptap/extension-list": "3.31.3",
|
||||
"@tiptap/extension-list-item": "3.31.3",
|
||||
"@tiptap/extension-list-keymap": "3.31.3",
|
||||
"@tiptap/extension-ordered-list": "3.31.3",
|
||||
"@tiptap/extension-paragraph": "3.31.3",
|
||||
"@tiptap/extension-strike": "3.31.3",
|
||||
"@tiptap/extension-text": "3.31.3",
|
||||
"@tiptap/extension-underline": "3.31.3",
|
||||
"@tiptap/extensions": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/static-renderer": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/static-renderer/-/static-renderer-3.31.3.tgz",
|
||||
"integrity": "sha512-TzbN/aSTQBhGZL94aaEnfbwZw/gIiQd8/q3TucTYuzdbsrsZNfBMg7CCA9W9dOg4vlKxCTLRVas21ivsrUm7og==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1506,14 +2000,12 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -1524,12 +2016,20 @@
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
|
||||
@@ -1537,6 +2037,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
||||
@@ -2267,6 +2773,16 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.407",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
||||
@@ -2578,6 +3094,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz",
|
||||
"integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -3168,6 +3693,12 @@
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -3222,6 +3753,12 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -3349,7 +3886,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -3393,6 +3929,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -3721,6 +4263,156 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
|
||||
"integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
|
||||
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz",
|
||||
"integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.11",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz",
|
||||
"integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz",
|
||||
"integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.42.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz",
|
||||
"integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.8",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -3764,6 +4456,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-date-object": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmmirror.com/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
@@ -3777,12 +4475,52 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-element-popper": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/react-element-popper/-/react-element-popper-2.1.7.tgz",
|
||||
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.88.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz",
|
||||
"integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-multi-date-picker": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
|
||||
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-date-object": "^2.1.8",
|
||||
"react-element-popper": "^2.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -3825,6 +4563,43 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-select": {
|
||||
"version": "5.10.2",
|
||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
|
||||
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.0",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.8.1",
|
||||
"@floating-ui/dom": "^1.0.1",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"prop-types": "^15.6.0",
|
||||
"react-transition-group": "^4.3.0",
|
||||
"use-isomorphic-layout-effect": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -3952,6 +4727,12 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -4031,6 +4812,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/slugify": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
|
||||
"integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
@@ -4393,6 +5183,29 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-isomorphic-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -4460,6 +5273,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -11,11 +11,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@tiptap/extension-image": "^3.31.3",
|
||||
"@tiptap/extension-link": "^3.31.3",
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
"slugify": "^1.6.9",
|
||||
"sweetalert2": "^11.26.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
|
Before Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 481 KiB |
|
Before Width: | Height: | Size: 505 KiB |
|
Before Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 490 KiB |
|
Before Width: | Height: | Size: 228 KiB |
@@ -15,7 +15,35 @@ import { Blog } from './pages/Blog';
|
||||
import { ArticleDetail } from './pages/ArticleDetail';
|
||||
import { FaqPage } from './pages/FaqPage';
|
||||
import { ContactPage } from './pages/ContactPage';
|
||||
import { SiteProvider, useSiteData } from './api';
|
||||
import { SiteProvider, useSiteData, useSite } from './api';
|
||||
import { applySeo, seoKeyForPath } from './seo';
|
||||
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
||||
import { AdminBar } from './admin/AdminBar';
|
||||
import { AdminLoginPage } from './admin/AdminLoginPage';
|
||||
import { ArticleWritePage } from './admin/ArticleWritePage';
|
||||
import { LangProvider, useLang } from './i18n/lang';
|
||||
|
||||
// مسیرهای عمومی یک بار تعریف میشوند و دو بار سرو میشوند: ریشه فارسی و /en انگلیسی
|
||||
const PUBLIC_ROUTES: { path: string; element: React.ReactElement }[] = [
|
||||
{ path: '/', element: <Home /> },
|
||||
{ path: '/about', element: <About /> },
|
||||
{ path: '/services', element: <ServicesPage /> },
|
||||
{ path: '/projects', element: <Projects /> },
|
||||
{ path: '/projects/:slug', element: <ProjectDetail /> },
|
||||
{ path: '/investment', element: <Investment /> },
|
||||
{ path: '/blog', element: <Blog /> },
|
||||
{ path: '/blog/:slug', element: <ArticleDetail /> },
|
||||
{ path: '/faq', element: <FaqPage /> },
|
||||
{ path: '/contact', element: <ContactPage /> },
|
||||
];
|
||||
|
||||
// فرزند مستقیم <Routes> باید <Route> باشد — آرایههای Route بهجای کامپوننت واسط
|
||||
const FA_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
|
||||
<Route key={path} path={path} element={element} />
|
||||
));
|
||||
const EN_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
|
||||
<Route key={`en-${path}`} path={path === '/' ? '/en' : `/en${path}`} element={element} />
|
||||
));
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
@@ -35,67 +63,97 @@ function AnimatedRoutes() {
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
||||
|
||||
|
||||
<Routes location={location}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/projects/:slug" element={<ProjectDetail />} />
|
||||
<Route path="/investment" element={<Investment />} />
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
<Route path="/blog/:slug" element={<ArticleDetail />} />
|
||||
<Route path="/faq" element={<FaqPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
<Route path="/admin/write" element={<ArticleWritePage />} />
|
||||
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
|
||||
{FA_ROUTES}
|
||||
<Route path="*" element={<Home />} />
|
||||
{EN_ROUTES}
|
||||
</Routes>
|
||||
</motion.main>
|
||||
</AnimatePresence>);
|
||||
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
function AppChrome() {
|
||||
const { pathname } = useLocation();
|
||||
const { isAuthenticated } = useAdmin();
|
||||
const data = useSite();
|
||||
const { lang } = useLang();
|
||||
const isAdminPage = pathname.startsWith('/admin');
|
||||
|
||||
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحههای جزئیات که خودشان ست میکنند
|
||||
useEffect(() => {
|
||||
if (isAdminPage) return;
|
||||
const key = seoKeyForPath(pathname);
|
||||
if (!key) return;
|
||||
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
||||
const brand = data.brand.name;
|
||||
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
|
||||
applySeo({
|
||||
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
||||
description: meta?.description ?? '',
|
||||
path: pathname,
|
||||
lang,
|
||||
});
|
||||
}, [pathname, data, isAdminPage, lang]);
|
||||
|
||||
return (
|
||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
|
||||
<>
|
||||
{!isAdminPage && <Header />}
|
||||
<AnimatedRoutes />
|
||||
{!isAdminPage && <Footer />}
|
||||
{!isAdminPage && <FloatingContact />}
|
||||
{isAuthenticated && !isAdminPage && <AdminBar />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
const { t, dir, lang } = useLang();
|
||||
|
||||
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
||||
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
||||
return (
|
||||
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
||||
<Preloader />
|
||||
{error ? (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-2xl font-bold text-ivory">اتصال به سرور برقرار نشد</h1>
|
||||
<h1 className="text-2xl font-bold text-ivory">{t('errorTitle')}</h1>
|
||||
<p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70">
|
||||
محتوای سایت از سرور بارگذاری میشود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.
|
||||
{t('errorText')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105"
|
||||
>
|
||||
تلاش دوباره
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : data ? (
|
||||
<BrowserRouter>
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<Header />
|
||||
<AnimatedRoutes />
|
||||
<Footer />
|
||||
<FloatingContact />
|
||||
</BrowserRouter>
|
||||
<AppChrome />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('dir', 'rtl');
|
||||
document.documentElement.setAttribute('lang', 'fa');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SiteProvider>
|
||||
<AppShell />
|
||||
<AdminProvider>
|
||||
<BrowserRouter>
|
||||
<LangProvider>
|
||||
<AppShell />
|
||||
</LangProvider>
|
||||
</BrowserRouter>
|
||||
</AdminProvider>
|
||||
</SiteProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState } from 'react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { KeyRoundIcon, XIcon } from 'lucide-react'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
idle: 'همگامسازی شد',
|
||||
saving: 'در حال ذخیره…',
|
||||
error: 'خطا در ذخیره',
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
idle: 'bg-emerald-400',
|
||||
saving: 'bg-copper animate-pulse',
|
||||
error: 'bg-red-400',
|
||||
}
|
||||
|
||||
const SWAL_BASE = {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10 rounded-none',
|
||||
title: 'font-pinar font-bold text-lg text-ivory',
|
||||
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
|
||||
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
|
||||
},
|
||||
}
|
||||
|
||||
function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
if (next.length < 8) {
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'رمز جدید کوتاه است', text: 'رمز جدید باید دستکم ۸ نویسه باشد.', confirmButtonText: 'باشه' })
|
||||
return
|
||||
}
|
||||
if (next !== confirm) {
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'تکرار رمز مطابق نیست', text: 'رمز جدید و تکرار آن یکسان نیستند.', confirmButtonText: 'باشه' })
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await adminApi.changePassword(current, next)
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'success', title: 'رمز عبور عوض شد', text: 'از این به بعد با رمز جدید وارد شوید.', iconColor: '#34d399', confirmButtonText: 'باشه' })
|
||||
onClose()
|
||||
} catch (error) {
|
||||
void Swal.fire({
|
||||
...SWAL_BASE,
|
||||
icon: 'error',
|
||||
title: 'تغییر رمز ناموفق بود',
|
||||
text: error instanceof Error ? error.message : 'دوباره تلاش کنید',
|
||||
confirmButtonText: 'باشه',
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const field =
|
||||
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[95] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="تغییر رمز عبور">
|
||||
<div dir="rtl" className="w-full max-w-sm border border-ivory/15 bg-ink p-8 shadow-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||
<KeyRoundIcon className="h-4 w-4" />
|
||||
تغییر رمز عبور
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="بستن"
|
||||
className="text-ivory/50 transition-colors hover:text-ivory"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4">
|
||||
<input
|
||||
type="password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
placeholder="رمز عبور فعلی"
|
||||
aria-label="رمز عبور فعلی"
|
||||
autoComplete="current-password"
|
||||
className={field}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
placeholder="رمز جدید (دستکم ۸ نویسه)"
|
||||
aria-label="رمز جدید"
|
||||
autoComplete="new-password"
|
||||
className={field}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="تکرار رمز جدید"
|
||||
aria-label="تکرار رمز جدید"
|
||||
autoComplete="new-password"
|
||||
className={field}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={busy || !current || !next}
|
||||
className="mt-6 w-full bg-copper py-2.5 text-sm font-bold text-char transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'در حال ذخیره…' : 'ذخیره رمز جدید'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||
export function AdminBar() {
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
|
||||
const [pwOpen, setPwOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
dir="rtl"
|
||||
className="fixed bottom-4 right-1/2 z-[90] flex translate-x-1/2 items-center gap-5 border border-ivory/15 bg-ink/95 py-2.5 pl-6 pr-5 shadow-2xl backdrop-blur-md"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-bold text-copper">
|
||||
<span className="flex h-2 w-2 rounded-full bg-copper" />
|
||||
حالت مدیریت
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isEditMode}
|
||||
onClick={toggleEditMode}
|
||||
className="group flex items-center gap-3 text-sm font-light text-ivory/80"
|
||||
>
|
||||
<span
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${
|
||||
isEditMode ? 'border-copper bg-copper/30' : 'border-ivory/25 bg-char'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute h-4 w-4 rounded-full transition-all duration-200 ${
|
||||
isEditMode ? 'right-1 bg-copper' : 'right-6 bg-ivory/50'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleEditLang}
|
||||
title="تعیین میکند ویرایشها روی کدام ستون زبان ذخیره شوند"
|
||||
className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${
|
||||
editLang === 'en'
|
||||
? 'border-copper bg-copper/10 text-copper'
|
||||
: 'border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
زبان ویرایش: {editLang === 'fa' ? 'فارسی' : 'English'}
|
||||
</button>
|
||||
|
||||
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPwOpen(true)}
|
||||
className="flex items-center gap-1.5 border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||
>
|
||||
<KeyRoundIcon className="h-3.5 w-3.5" />
|
||||
رمز
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||
>
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pwOpen && <ChangePasswordModal onClose={() => setPwOpen(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import Swal from 'sweetalert2'
|
||||
import type { SiteData } from '../api'
|
||||
import { useSiteData } from '../api'
|
||||
import { adminApi, getToken, HttpError, setToken } from './adminClient'
|
||||
import { ENTITIES, isTranslatable } from './entities'
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
||||
export type EditLang = 'fa' | 'en'
|
||||
|
||||
interface AdminContextValue {
|
||||
isAuthenticated: boolean
|
||||
isEditMode: boolean
|
||||
toggleEditMode: () => void
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
saveStatus: SaveStatus
|
||||
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||
runSave: (action: () => Promise<void>) => Promise<boolean>
|
||||
/** زبان محتوایی که در حالت ویرایش ذخیره میشود (فارسی یا انگلیسی) */
|
||||
editLang: EditLang
|
||||
toggleEditLang: () => void
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextValue | null>(null)
|
||||
|
||||
export function useAdmin(): AdminContextValue {
|
||||
const ctx = useContext(AdminContext)
|
||||
if (!ctx) throw new Error('useAdmin باید داخل AdminProvider استفاده شود')
|
||||
return ctx
|
||||
}
|
||||
|
||||
function showSaveError(error: unknown): void {
|
||||
const message =
|
||||
error instanceof HttpError ? error.message : 'ارتباط با سرور برقرار نشد — اتصال اینترنت را بررسی کنید'
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'ذخیره نشد',
|
||||
text: message,
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'باشه',
|
||||
timer: 4000,
|
||||
timerProgressBar: true,
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10 rounded-none',
|
||||
title: 'font-pinar font-bold text-lg text-ivory',
|
||||
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
|
||||
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
|
||||
const [isEditMode, setIsEditMode] = useState<boolean>(
|
||||
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
|
||||
)
|
||||
const [editLang, setEditLang] = useState<EditLang>(
|
||||
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
|
||||
)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const { reload } = useSiteData()
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setIsEditMode((v) => {
|
||||
sessionStorage.setItem('aramesh_edit_mode', v ? '0' : '1')
|
||||
return !v
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleEditLang = useCallback(() => {
|
||||
setEditLang((v) => {
|
||||
const next: EditLang = v === 'fa' ? 'en' : 'fa'
|
||||
sessionStorage.setItem('aramesh_edit_lang', next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
await adminApi.login(username, password)
|
||||
setIsAuthenticated(true)
|
||||
// داده سایت دوباره بارگذاری میشود تا پیشنویس مقالهها هم ادغام شوند
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setToken(null)
|
||||
setIsAuthenticated(false)
|
||||
setIsEditMode(false)
|
||||
sessionStorage.removeItem('aramesh_edit_mode')
|
||||
// داده سایت دوباره بارگذاری میشود تا پیشنویسها از درخت داده حذف شوند
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
const runSave = useCallback(
|
||||
async (action: () => Promise<void>): Promise<boolean> => {
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
await action()
|
||||
setSaveStatus('idle')
|
||||
return true
|
||||
} catch (error) {
|
||||
setSaveStatus('error')
|
||||
showSaveError(error)
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
logout()
|
||||
reload()
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
[logout, reload],
|
||||
)
|
||||
|
||||
// در حالت ویرایش، کلیک روی لینکها مسیریابی نمیکند تا ویرایش متن ممکن باشد
|
||||
useEffect(() => {
|
||||
if (!isEditMode) return
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const anchor = (e.target as HTMLElement | null)?.closest('a')
|
||||
// لینکهای خود لایه مدیریت (مثل «نوشتن مقاله جدید») کار میکنند
|
||||
if (anchor && !anchor.dataset.adminLink) e.preventDefault()
|
||||
}
|
||||
document.addEventListener('click', onClick, true)
|
||||
return () => document.removeEventListener('click', onClick, true)
|
||||
}, [isEditMode])
|
||||
|
||||
// در حین ذخیرهسازی، از بستهشدن تصادفی صفحه جلوگیری میشود
|
||||
useEffect(() => {
|
||||
if (saveStatus !== 'saving') return
|
||||
const guard = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
window.addEventListener('beforeunload', guard)
|
||||
return () => window.removeEventListener('beforeunload', guard)
|
||||
}, [saveStatus])
|
||||
|
||||
const value = useMemo<AdminContextValue>(
|
||||
() => ({
|
||||
isAuthenticated,
|
||||
isEditMode,
|
||||
toggleEditMode,
|
||||
login,
|
||||
logout,
|
||||
saveStatus,
|
||||
runSave,
|
||||
editLang,
|
||||
toggleEditLang,
|
||||
}),
|
||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
|
||||
)
|
||||
|
||||
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* پرکاربردترین کمکساز ویرایش: یک فیلد واحد را PATCH میکند و پس از موفقیت
|
||||
* همان تغییر را بهصورت خوشبینانه در درخت داده سایت اعمال میکند.
|
||||
*
|
||||
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت میشوند؛ چون
|
||||
* برخی mutatorهای کامپوننتها نام فیلد را جداگانه مینویسند، مقدار قبلی فیلدهای
|
||||
* فارسی اسنپشات میشود و پس از اجرای mutator بازگردانده میشود تا متن انگلیسی
|
||||
* هرگز جای متن فارسی در درخت خام ننشیند.
|
||||
*/
|
||||
export function useFieldSaver() {
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { updateSite } = useSiteData()
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
resource: string,
|
||||
id: number,
|
||||
changes: Record<string, unknown>,
|
||||
apply: (data: SiteData) => void,
|
||||
): Promise<boolean> =>
|
||||
runSave(async () => {
|
||||
const originalKeys = Object.keys(changes)
|
||||
if (editLang === 'en') {
|
||||
const mapped: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
mapped[isTranslatable(resource, key) ? `${key}_en` : key] = value
|
||||
}
|
||||
for (const key of Object.keys(changes)) delete changes[key]
|
||||
Object.assign(changes, mapped)
|
||||
}
|
||||
await adminApi.patch(resource, id, changes)
|
||||
updateSite((d) => {
|
||||
const config = ENTITIES[resource]
|
||||
let node: Record<string, unknown> | undefined
|
||||
if (config) {
|
||||
const target = d[config.dataKey as keyof SiteData] as unknown
|
||||
node =
|
||||
config.kind === 'singleton'
|
||||
? (target as Record<string, unknown> | undefined)
|
||||
: (target as { id: number }[] | undefined)?.find((x) => x.id === id)
|
||||
}
|
||||
const snapshot =
|
||||
editLang === 'en' && node
|
||||
? Object.fromEntries(originalKeys.filter((k) => k !== 'id').map((k) => [k, node![k]]))
|
||||
: null
|
||||
apply(d)
|
||||
if (snapshot && node) {
|
||||
Object.assign(node, snapshot)
|
||||
Object.assign(node, changes)
|
||||
}
|
||||
})
|
||||
}),
|
||||
[runSave, updateSite, editLang],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* نام فیلد متناظر با زبان ویرایش جاری — در حالت انگلیسی فیلدهای قابل ترجمه
|
||||
* پسوند _en میگیرند. برای جاهایی که نام فیلد جدا از شیء تغییرات ذخیره میشود.
|
||||
*/
|
||||
export function useEditField() {
|
||||
const { editLang } = useAdmin()
|
||||
return useCallback(
|
||||
(resource: string, field: string) =>
|
||||
editLang === 'en' && isTranslatable(resource, field) ? `${field}_en` : field,
|
||||
[editLang],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useAdmin } from './AdminContext'
|
||||
|
||||
/** صفحه ورود ادمین — مسیر پنهان /admin/login */
|
||||
export function AdminLoginPage() {
|
||||
const { isAuthenticated, login } = useAdmin()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
if (isAuthenticated) return <Navigate to="/" replace />
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (submitting) return
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(username.trim(), password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex min-h-screen flex-col items-center justify-center bg-ink px-6" dir="rtl">
|
||||
<div className="w-full max-w-sm border border-ivory/10 bg-char p-10">
|
||||
<h1 className="text-xl font-bold text-ivory">ورود مدیریت</h1>
|
||||
<p className="mt-3 text-sm font-light leading-7 text-ivory/55">
|
||||
برای فعالسازی حالت ویرایش زنده سایت وارد شوید.
|
||||
</p>
|
||||
|
||||
<form onSubmit={submit} className="mt-8 grid gap-5" noValidate>
|
||||
<div>
|
||||
<label htmlFor="admin-username" className="block text-sm font-bold text-ivory/90">
|
||||
نام کاربری
|
||||
</label>
|
||||
<input
|
||||
id="admin-username"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="admin-password" className="block text-sm font-bold text-ivory/90">
|
||||
رمز عبور
|
||||
</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
dir="ltr"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="border-r-2 border-red-400/70 pr-3 text-xs font-light leading-6 text-red-300">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="mt-2 bg-copper px-8 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-[1.02] disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'در حال ورود…' : 'ورود'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Controller, useController, useForm } from 'react-hook-form'
|
||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import type { CategoryOption } from '../components/admin/LuxeSelect'
|
||||
import { ARTICLE_CATEGORIES } from './entities'
|
||||
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||
import { formatReadingTime } from '../utils/readingTime'
|
||||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||
import type { Article, ArticleBodyDoc } from '../data/site'
|
||||
|
||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||||
|
||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
|
||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
|
||||
|
||||
/**
|
||||
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت میشوند:
|
||||
* عنوان (مبنای Alt پویا)، دستهبندی و متادیتای تصویر شاخص.
|
||||
* `category` در فرم یک شیء تکمقداری {value,label} یا null است
|
||||
* که هنگام ذخیره به رشته (ستون category در بکاند) نگاشت میشود.
|
||||
*/
|
||||
type ArticleMetaForm = {
|
||||
title: string
|
||||
category: CategoryOption | null
|
||||
image_alt: string
|
||||
image_title: string
|
||||
}
|
||||
|
||||
/** مقدار رشتهای ذخیرهشده → گزینه react-select (یا null وقتی خالی است) */
|
||||
function toCategoryOption(value: string): CategoryOption | null {
|
||||
const category = (value ?? '').trim()
|
||||
return category ? { label: category, value: category } : null
|
||||
}
|
||||
|
||||
/** گزینه react-select → مقدار رشتهای ذخیرهسازی در بکاند */
|
||||
function categoryValue(option: CategoryOption | null): string {
|
||||
return option?.value.trim() ?? ''
|
||||
}
|
||||
|
||||
/** دادههای قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
|
||||
if (typeof value === 'string') return value.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
return []
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
idle: 'ذخیره شد',
|
||||
saving: 'در حال ذخیره…',
|
||||
error: 'خطا در ذخیره',
|
||||
}
|
||||
|
||||
/**
|
||||
* صفحه تمامعرض نوشتن/ویرایش مقاله — محیط نوشتن متمرکز بدون چرخه سایت.
|
||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||
*/
|
||||
export function ArticleWritePage() {
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { data, updateSite } = useSiteData()
|
||||
|
||||
const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined
|
||||
const [fetched, setFetched] = useState<Draft | null>(null)
|
||||
|
||||
// مقاله تازه همیشه پیشنویس شروع میشود؛ با دکمه «انتشار در سایت» عمومی میشود
|
||||
const [draft, setDraft] = useState<Draft>({
|
||||
id: 0,
|
||||
slug: `article-${Date.now()}`,
|
||||
title: '',
|
||||
title_en: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
date_en: '',
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
category_en: '',
|
||||
tags: [],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
read_en: '',
|
||||
excerpt: '',
|
||||
excerpt_en: '',
|
||||
body: EMPTY_DOC,
|
||||
body_en: EMPTY_DOC,
|
||||
status: 'draft',
|
||||
seo_keyphrase: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* فرم متادیتا با React Hook Form — منبع نمایش فیلدها.
|
||||
* هر تغییر، هم فرم و هم draft (مبنای ذخیرهسازی خودکار) را بهروز میکند.
|
||||
*/
|
||||
const { control, watch, reset } = useForm<ArticleMetaForm>({
|
||||
defaultValues: {
|
||||
title: '',
|
||||
category: toCategoryOption(ARTICLE_CATEGORIES[0]),
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
/** عنوان زنده مقاله — مبنای متن جایگزین (Alt) تصویر شاخص */
|
||||
const watchedTitle = watch('title')
|
||||
const { field: imageAltField } = useController({ name: 'image_alt', control })
|
||||
const { field: imageTitleField } = useController({ name: 'image_title', control })
|
||||
|
||||
const [createdId, setCreatedId] = useState<number | null>(null)
|
||||
/**
|
||||
* شناسه محتوای بارگذاریشده — با رسیدن مقاله از سرور عوض میشود تا
|
||||
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
|
||||
*/
|
||||
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const draftRef = useRef(draft)
|
||||
draftRef.current = draft
|
||||
const createdRef = useRef(createdId)
|
||||
createdRef.current = createdId
|
||||
const pendingRef = useRef<Record<string, unknown> | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// اگر مقاله در داده سایت نبود (مثلاً پیش از ادغام پیشنویسها) مستقیم از انتهای مدیریتی خوانده میشود
|
||||
useEffect(() => {
|
||||
if (!id || existing) return
|
||||
let cancelled = false
|
||||
adminApi
|
||||
.get<Draft>('articles', Number(id))
|
||||
.then((a) => {
|
||||
if (!cancelled) setFetched(a)
|
||||
})
|
||||
.catch(() => {
|
||||
/* مقاله وجود ندارد — فرم «مقاله جدید» میماند */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, existing])
|
||||
|
||||
const article = existing ?? fetched
|
||||
|
||||
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساختهشده یکبار مقداردهی میشود
|
||||
useEffect(() => {
|
||||
if (!article || createdRef.current) return
|
||||
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
|
||||
setDraft({
|
||||
...(article as unknown as Draft),
|
||||
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
|
||||
// بدنه ذخیرهشده (سند جدید یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
body: toArticleBodyDoc(article.body),
|
||||
body_en:
|
||||
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
|
||||
? (rawEnBody as ArticleBodyDoc)
|
||||
: EMPTY_DOC,
|
||||
})
|
||||
setCreatedId(article.id)
|
||||
// بدنه ویرایشگر با محتوای بارگذاریشده همگام میشود
|
||||
setLoadedContentKey(article.id)
|
||||
// فرم متادیتا هم با مقادیر مقاله بارگذاریشده همگام میشود
|
||||
reset({
|
||||
title: article.title ?? '',
|
||||
category: toCategoryOption(article.category ?? ''),
|
||||
image_alt: article.image_alt ?? '',
|
||||
image_title: article.image_title ?? '',
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [article?.id])
|
||||
|
||||
const doSave = async (changes: Record<string, unknown>) => {
|
||||
if (createdRef.current) {
|
||||
await adminApi.patch('articles', createdRef.current, changes)
|
||||
updateSite((d) => {
|
||||
const a = d.articles.find((x) => x.id === createdRef.current)
|
||||
if (a) Object.assign(a, changes)
|
||||
})
|
||||
} else {
|
||||
// اولین ذخیره = ساخت پیشنویس: مقاله ساخته میشود اما تا «انتشار» در سایت عمومی دیده نمیشود
|
||||
const payload = { ...draftRef.current, ...changes }
|
||||
if (!payload.title) payload.title = 'بدون عنوان'
|
||||
const created = await adminApi.create<Draft & { id: number }>('articles', payload)
|
||||
setCreatedId(created.id)
|
||||
updateSite((d) => {
|
||||
d.articles.unshift(created as never)
|
||||
})
|
||||
navigate(`/admin/write/${created.id}`, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
const changes = pendingRef.current
|
||||
if (!changes) return
|
||||
pendingRef.current = null
|
||||
await doSave(changes)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const queueSave = (changes: Record<string, unknown>) => {
|
||||
pendingRef.current = { ...(pendingRef.current ?? {}), ...changes }
|
||||
setDraft((d) => ({ ...d, ...changes }) as Draft)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => void flush(), 800)
|
||||
}
|
||||
|
||||
// ذخیره تغییرات در انتظار هنگام ترک صفحه — فقط برای مقاله ساختهشده
|
||||
// (create در unmount ممنوع: با draft قدیمی، مقاله سرگردانی میسازد)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
const changes = pendingRef.current
|
||||
if (!changes || !createdRef.current) return
|
||||
void adminApi.patch('articles', createdRef.current, changes).catch(() => {
|
||||
/* صفحه در حال بستن است */
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const forceSave = () => void runSave(flush)
|
||||
|
||||
// محافظ مسیر — پس از همه هوکها تا ترتیب فراخوانی هوکها همیشه یکسان بماند
|
||||
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
|
||||
|
||||
const remove = async () => {
|
||||
if (!createdId) return
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'حذف مقاله',
|
||||
text: 'این مقاله برای همیشه حذف شود؟',
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'حذف',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||
},
|
||||
})
|
||||
if (!result.isConfirmed) return
|
||||
setDeleting(true)
|
||||
await runSave(async () => {
|
||||
await adminApi.remove('articles', createdId)
|
||||
navigate('/blog')
|
||||
})
|
||||
setDeleting(false)
|
||||
}
|
||||
|
||||
const textField = (name: keyof Draft) => ({
|
||||
value: String(draft[name] ?? ''),
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
queueSave({ [name]: e.target.value }),
|
||||
})
|
||||
|
||||
const metaLength = (draft.meta_description ?? '').trim().length
|
||||
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
||||
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
||||
|
||||
// متن جایگزین تصویر شاخص — با watch('title') بهصورت زنده ساخته میشود
|
||||
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||||
{/* نوار بالا */}
|
||||
<header className="sticky top-0 z-40 border-b border-ivory/10 bg-ink/95 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-3">
|
||||
<Link
|
||||
to="/blog"
|
||||
data-admin-link
|
||||
className="flex items-center gap-2 text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
بازگشت به وبلاگ
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleEditLang}
|
||||
title="تعیین میکند متن مقاله روی کدام زبان ذخیره شود"
|
||||
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
||||
editLang === 'en'
|
||||
? 'bg-copper text-char'
|
||||
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{editLang === 'fa' ? 'ویرایش: فارسی' : 'Edit: English'}
|
||||
</button>
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
{saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />}
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={forceSave}
|
||||
className="border border-copper/50 px-5 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||
>
|
||||
ذخیره
|
||||
</button>
|
||||
<span
|
||||
className={`border px-3 py-2 text-xs font-light ${
|
||||
draft.status === 'draft'
|
||||
? 'border-amber-400/50 text-amber-300'
|
||||
: 'border-emerald-400/50 text-emerald-300'
|
||||
}`}
|
||||
>
|
||||
{draft.status === 'draft' ? 'پیشنویس' : 'منتشر شده'}
|
||||
</span>
|
||||
{createdId && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
|
||||
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
||||
draft.status === 'draft'
|
||||
? 'bg-copper text-char hover:scale-105'
|
||||
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||
</button>
|
||||
<Link
|
||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
||||
data-admin-link
|
||||
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
دیدن در سایت
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
disabled={deleting}
|
||||
aria-label="حذف مقاله"
|
||||
className="flex h-8 w-8 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-4xl px-6 pb-32 pt-10">
|
||||
<p className="text-xs font-light text-copper">
|
||||
{!createdId
|
||||
? 'نوشتن مقاله جدید — بهصورت پیشنویس ذخیره میشود؛ با دکمه «انتشار در سایت» عمومی میشود'
|
||||
: draft.status === 'draft'
|
||||
? 'پیشنویس — تا انتشار در سایت عمومی دیده نمیشود؛ تغییرها ذخیره میشوند'
|
||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||
</p>
|
||||
|
||||
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
|
||||
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره میشود */}
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
type="text"
|
||||
{...field}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value)
|
||||
queueSave({ [suf('title')]: event.target.value })
|
||||
}}
|
||||
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
|
||||
aria-label="عنوان مقاله"
|
||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-4 h-px w-28 bg-copper" />
|
||||
|
||||
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصلاند) */}
|
||||
<div className="mt-8">
|
||||
<ImageField
|
||||
label="تصویر شاخص مقاله"
|
||||
value={draft.image}
|
||||
alt={imageAltField.value}
|
||||
altFallback={imageAltFallback}
|
||||
title={imageTitleField.value}
|
||||
onSave={async (url) => {
|
||||
queueSave({ image: url })
|
||||
return true
|
||||
}}
|
||||
onSaveAlt={async (alt) => {
|
||||
imageAltField.onChange(alt)
|
||||
queueSave({ image_alt: alt })
|
||||
return true
|
||||
}}
|
||||
onSaveTitle={async (imageTitle) => {
|
||||
imageTitleField.onChange(imageTitle)
|
||||
queueSave({ image_title: imageTitle })
|
||||
return true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
contentKey={loadedContentKey}
|
||||
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
||||
onChange={(json) => {
|
||||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود — به زبان جاری
|
||||
const minutes = formatReadingTime(json)
|
||||
if (editLang === 'en') {
|
||||
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
|
||||
} else {
|
||||
queueSave({ body: json, read: `${minutes} دقیقه` })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* توضیحات متای سئو */}
|
||||
<section className="mt-8 border border-ivory/15 bg-char p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-bold text-copper">توضیحات سئو (Meta Description)</h2>
|
||||
<span
|
||||
className={`text-[11px] font-light ${
|
||||
metaLength >= 150 && metaLength <= 160 ? 'text-emerald-400' : 'text-ivory/40'
|
||||
}`}
|
||||
>
|
||||
{metaLength.toLocaleString('fa-IR')} از ۱۶۰ نویسه
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-light text-ivory/50">
|
||||
این همان متنی است که در صفحه نتایج گوگل زیر لینک مقاله به کاربران نمایش داده میشود (۱۵۰ تا ۱۶۰ نویسه).
|
||||
</p>
|
||||
<textarea
|
||||
id="meta-description"
|
||||
rows={3}
|
||||
value={draft.meta_description ?? ''}
|
||||
onChange={(e) => queueSave({ meta_description: e.target.value })}
|
||||
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
|
||||
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* چکیده و متادیتا */}
|
||||
<SeoChecklist
|
||||
input={{
|
||||
title: draft.title,
|
||||
slug: draft.slug,
|
||||
excerpt: draft.meta_description || draft.excerpt,
|
||||
body: draft.body,
|
||||
keyphrase: draft.seo_keyphrase,
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
|
||||
<h2 className="text-sm font-bold text-copper">جزئیات انتشار، دستهبندی و هشتگها</h2>
|
||||
<div className="mt-5 grid gap-5">
|
||||
<div>
|
||||
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمیشود)</label>
|
||||
<input
|
||||
id="seo-keyphrase"
|
||||
type="text"
|
||||
value={draft.seo_keyphrase}
|
||||
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
{...textField(suf('excerpt'))}
|
||||
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارتهای وبلاگ…'}
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="article-author" className="text-xs font-light text-ivory/50">نویسنده</label>
|
||||
<input
|
||||
id="article-author"
|
||||
type="text"
|
||||
{...textField('author')}
|
||||
placeholder="نام و نام خانوادگی نویسنده"
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">دستهبندی مقاله (Category)</label>
|
||||
<div className="mt-2">
|
||||
{/* تکانتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
|
||||
<Controller
|
||||
name="category"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CategorySelect
|
||||
value={field.value}
|
||||
options={ARTICLE_CATEGORIES}
|
||||
onChange={(option) => {
|
||||
field.onChange(option)
|
||||
queueSave({ category: categoryValue(option) })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">هشتگها / برچسبها</label>
|
||||
<div className="mt-2">
|
||||
<LuxeTagsSelect
|
||||
value={normalizeTags(draft.tags)}
|
||||
onChange={(tags) => queueSave({ tags })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="article-date" className="text-xs font-light text-ivory/50">تاریخ انتشار (شمسی)</label>
|
||||
<div className="mt-2">
|
||||
<JalaliDatePicker
|
||||
id="article-date"
|
||||
value={draft.date ?? ''}
|
||||
onChange={(isoDate) => queueSave({ date: isoDate })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled={editLang !== 'en'}
|
||||
{...(editLang === 'en' ? textField('read_en') : { value: String(draft.read ?? '') })}
|
||||
className={`mt-2 w-full text-sm font-light ${
|
||||
editLang === 'en'
|
||||
? 'border border-ivory/15 bg-char px-3 py-2 text-ivory focus:border-copper focus:outline-none'
|
||||
: 'cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-ivory/50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">نشانی (slug)</label>
|
||||
<input
|
||||
type="text"
|
||||
dir="ltr"
|
||||
{...textField('slug')}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import type { SiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { ENTITIES } from './entities'
|
||||
import { EntityDrawer } from './EntityDrawer'
|
||||
|
||||
type ItemWithId = Record<string, unknown> & { id: number }
|
||||
|
||||
function useEntityList(resource: string): ItemWithId[] {
|
||||
const { data } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
|
||||
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
onClick,
|
||||
label,
|
||||
children,
|
||||
danger = false,
|
||||
}: {
|
||||
onClick: () => void
|
||||
label: string
|
||||
children: ReactNode
|
||||
danger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
className={`flex h-7 w-7 items-center justify-center border backdrop-blur-sm transition-colors duration-150 ${
|
||||
danger
|
||||
? 'border-red-400/40 bg-ink/90 text-red-300 hover:bg-red-500/20'
|
||||
: 'border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* بستهبندی آیتمهای مجموعهها — فقط در حالت ویرایش ادمین، دکمههای شناور
|
||||
* (ویرایش / بالا / پایین / حذف) روی آیتم نشان میدهد؛ در حالت عادی خروجی یکسان است.
|
||||
*/
|
||||
export function EditableItem({
|
||||
resource,
|
||||
item,
|
||||
children,
|
||||
className = '',
|
||||
editHref,
|
||||
}: {
|
||||
resource: string
|
||||
item: ItemWithId
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت میکند (مثل صفحه نوشتن مقاله) */
|
||||
editHref?: string
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
const list = useEntityList(resource)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
if (!config) return <>{children}</>
|
||||
if (!isAuthenticated || !isEditMode) return <>{children}</>
|
||||
|
||||
const index = list.findIndex((x) => x.id === item.id)
|
||||
|
||||
const move = (delta: number) => {
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= list.length) return
|
||||
const ids = list.map((x) => x.id)
|
||||
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
||||
void runSave(async () => {
|
||||
await adminApi.reorder(config.resource, ids)
|
||||
reload()
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'حذف رکورد',
|
||||
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'حذف',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||
},
|
||||
})
|
||||
if (!result.isConfirmed) return
|
||||
void runSave(async () => {
|
||||
await adminApi.remove(config.resource, item.id)
|
||||
reload()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative group/editable ${className}`}>
|
||||
<div className="absolute left-2 top-2 z-30 flex flex-col gap-1.5 opacity-0 transition-opacity duration-150 group-hover/editable:opacity-100">
|
||||
{editHref ? (
|
||||
<Link
|
||||
to={editHref}
|
||||
data-admin-link
|
||||
title="ویرایش"
|
||||
aria-label="ویرایش"
|
||||
className="flex h-7 w-7 items-center justify-center border border-copper/50 bg-ink/90 text-copper backdrop-blur-sm transition-colors duration-150 hover:bg-copper hover:text-char"
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
) : (
|
||||
<PillButton onClick={() => setDrawerOpen(true)} label="ویرایش">
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
)}
|
||||
{index > 0 && (
|
||||
<PillButton onClick={() => move(-1)} label="انتقال به قبل">
|
||||
<ArrowUpIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
)}
|
||||
{index < list.length - 1 && (
|
||||
<PillButton onClick={() => move(1)} label="انتقال به بعد">
|
||||
<ArrowDownIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
)}
|
||||
<PillButton onClick={remove} label="حذف" danger>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
{drawerOpen && (
|
||||
<EntityDrawer config={config} item={item} deletable onClose={() => setDrawerOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** دکمه افزودن آیتم جدید به یک مجموعه — فقط در حالت ویرایش */
|
||||
export function AddItemButton({
|
||||
resource,
|
||||
label,
|
||||
variant = 'cell',
|
||||
}: {
|
||||
resource: string
|
||||
label?: string
|
||||
variant?: 'cell' | 'button'
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const { updateSite } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
|
||||
|
||||
if (!config || !isAuthenticated || !isEditMode) return null
|
||||
|
||||
const create = () => {
|
||||
void runSave(async () => {
|
||||
const created = await adminApi.create<ItemWithId>(config.resource, config.defaults)
|
||||
// درج خوشبینانه — reload کل درخت را بازسازی میکند و کشوی تازهساختهشده میمیرد
|
||||
updateSite((d) => {
|
||||
const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[]
|
||||
if (Array.isArray(list)) list.push(created)
|
||||
})
|
||||
setNewItem(created)
|
||||
})
|
||||
}
|
||||
|
||||
const drawer = newItem ? (
|
||||
<EntityDrawer config={config} item={newItem} deletable onClose={() => setNewItem(null)} />
|
||||
) : null
|
||||
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={create}
|
||||
className="flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{label ?? `افزودن ${config.title}`}
|
||||
</button>
|
||||
{drawer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={create}
|
||||
className="flex min-h-40 w-full flex-col items-center justify-center gap-2 border border-dashed border-copper/40 bg-copper/5 text-copper transition-colors duration-200 hover:bg-copper/10"
|
||||
>
|
||||
<PlusIcon className="h-6 w-6" />
|
||||
<span className="text-sm font-light">افزودن {config.title}</span>
|
||||
</button>
|
||||
|
||||
{drawer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش.
|
||||
* اگر رکورد هنوز در دیتابیس ساخته نشده باشد و createDefaults داده شود، دکمه + آن را میسازد. */
|
||||
export function SingletonPill({
|
||||
resource,
|
||||
label,
|
||||
className = '',
|
||||
select,
|
||||
createDefaults,
|
||||
}: {
|
||||
resource: string
|
||||
label: string
|
||||
className?: string
|
||||
/** انتخاب رکورد از درخت داده — برای مجموعههایی که یک رکورد خاص اینجاست (مثل سئو) */
|
||||
select?: (data: NonNullable<ReturnType<typeof useSiteData>['data']>) => ItemWithId | undefined
|
||||
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
|
||||
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const config = ENTITIES[resource]
|
||||
const { data, updateSite } = useSiteData()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [created, setCreated] = useState<ItemWithId | null>(null)
|
||||
|
||||
if (!config || !isAuthenticated || !isEditMode) return null
|
||||
const item =
|
||||
created ??
|
||||
(select
|
||||
? select(data!)
|
||||
: (data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined))
|
||||
if (!item && !createDefaults) return null
|
||||
|
||||
const create = () => {
|
||||
const defaults =
|
||||
typeof createDefaults === 'function' ? createDefaults() : (createDefaults ?? {})
|
||||
void runSave(async () => {
|
||||
const rec = await adminApi.create<ItemWithId>(config.resource, defaults)
|
||||
// درج خوشبینانه در درخت داده — دفعه بعد از داده انتخاب میشود
|
||||
updateSite((d) => {
|
||||
const list = d[config.dataKey as keyof NonNullable<typeof d>] as unknown as ItemWithId[]
|
||||
if (Array.isArray(list)) list.push(rec)
|
||||
})
|
||||
setCreated(rec)
|
||||
setDrawerOpen(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={className}>
|
||||
{item ? (
|
||||
<PillButton onClick={() => setDrawerOpen(true)} label={label}>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
) : (
|
||||
<PillButton onClick={create} label={`ساخت — ${label}`}>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
)}
|
||||
</span>
|
||||
{drawerOpen && item && (
|
||||
<EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { editableFields } from './entities'
|
||||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||
import type { EntityConfig, FieldConfig } from './entities'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
|
||||
type EntityDrawerProps = {
|
||||
config: EntityConfig
|
||||
/** رکورد یگانه یا آیتم مجموعه — باید id داشته باشد */
|
||||
item: Record<string, unknown> & { id: number }
|
||||
onClose: () => void
|
||||
/** فقط برای مجموعهها: حذف آیتم */
|
||||
deletable?: boolean
|
||||
}
|
||||
|
||||
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
|
||||
const save = useFieldSaver()
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const flushRef = useRef<() => void>(() => {})
|
||||
|
||||
// در ویرایش انگلیسی فقط فیلدهای قابل ترجمه با نام انگلیسی رندر میشوند؛
|
||||
// متن فارسی همان فیلد بهعنوان جاینگهدار نشان داده میشود تا مترجم زمینه را ببیند
|
||||
const fields = editableFields(config, editLang)
|
||||
const faBase = (name: string) => name.replace(/_en$/, '')
|
||||
|
||||
const closeWithFlush = () => {
|
||||
flushRef.current()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const saveField = (name: string, value: unknown) =>
|
||||
save(config.resource, item.id, { [name]: value }, (d) => {
|
||||
const target = d[config.dataKey as keyof typeof d] as unknown
|
||||
const node =
|
||||
config.kind === 'singleton'
|
||||
? (target as Record<string, unknown>)
|
||||
: (target as { id: number }[]).find((x) => x.id === item.id)
|
||||
if (node) (node as Record<string, unknown>)[name] = value
|
||||
})
|
||||
|
||||
const remove = async () => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'حذف رکورد',
|
||||
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'حذف',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||
},
|
||||
})
|
||||
if (!result.isConfirmed) return
|
||||
setDeleting(true)
|
||||
await runSave(async () => {
|
||||
await adminApi.remove(config.resource, item.id)
|
||||
reload()
|
||||
onClose()
|
||||
})
|
||||
setDeleting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="backdrop"
|
||||
className="fixed inset-0 z-[95] bg-char/70 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={closeWithFlush}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<motion.aside
|
||||
key="drawer"
|
||||
dir="rtl"
|
||||
role="dialog"
|
||||
aria-label={`ویرایش ${config.title}`}
|
||||
className={`fixed inset-y-0 left-0 z-[96] flex w-full flex-col border-e border-ivory/10 bg-ink shadow-2xl ${
|
||||
config.resource === 'articles' ? 'sm:max-w-3xl' : 'sm:max-w-md'
|
||||
}`}
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-ivory/10 px-6 py-4">
|
||||
<div>
|
||||
<p className="text-xs font-light text-copper">
|
||||
{config.kind === 'singleton' ? 'تنظیمات بخش' : 'ویرایش رکورد'}
|
||||
</p>
|
||||
<h2 className="mt-1 text-lg font-bold text-ivory">{config.title}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeWithFlush}
|
||||
aria-label="بستن"
|
||||
className="flex h-9 w-9 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="grid gap-6">
|
||||
{fields.map((field) => {
|
||||
const isImage = field.type === 'image'
|
||||
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
||||
const altKey = isImage
|
||||
? field.name === 'src' && 'alt' in item
|
||||
? 'alt'
|
||||
: `${field.name}_alt`
|
||||
: ''
|
||||
const imageTitleKey = isImage ? `${field.name}_title` : ''
|
||||
// پیشنهاد Alt از عنوان رکورد ساخته میشود (هرگز از URL)
|
||||
const imageAltFallback = [
|
||||
String(item[config.titleKey] ?? '').trim(),
|
||||
field.altFallbackSuffix,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
return (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
|
||||
altValue={altKey ? String(item[altKey] ?? '') : undefined}
|
||||
altFallback={imageAltFallback}
|
||||
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
|
||||
onSaveImageTitle={
|
||||
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
|
||||
}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
? (fn) => {
|
||||
flushRef.current = fn
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deletable && (
|
||||
<footer className="border-t border-ivory/10 px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-2 border border-red-400/40 px-4 py-2 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
حذف این رکورد
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</motion.aside>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
altValue,
|
||||
altFallback,
|
||||
placeholder,
|
||||
imageTitleValue,
|
||||
onSave,
|
||||
onSaveAlt,
|
||||
onSaveImageTitle,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
/** مقدار Alt تصویر — فقط برای فیلدهای image */
|
||||
altValue?: string
|
||||
altFallback?: string
|
||||
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی بهعنوان جاینگهدار نمایش داده میشود */
|
||||
placeholder?: unknown
|
||||
/** مقدار Title تصویر — فقط برای فیلدهای image */
|
||||
imageTitleValue?: string
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||
onSaveImageTitle?: (title: string) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [text, setText] = useState<string>(String(value ?? ''))
|
||||
|
||||
// همگامسازی مقدار محلی با دادهای که از بیرون میآید (بارگذاری/بهروزرسانی خوشبینانه)
|
||||
useEffect(() => {
|
||||
setText(String(value ?? ''))
|
||||
}, [value])
|
||||
|
||||
const commitText = async () => {
|
||||
const next = field.type === 'number' ? Number(text) || 0 : text
|
||||
if (String(value ?? '') === String(next)) return
|
||||
await onSave(next)
|
||||
}
|
||||
|
||||
if (field.type === 'image') {
|
||||
return (
|
||||
<ImageField
|
||||
label={field.label}
|
||||
value={String(value ?? '')}
|
||||
alt={altValue}
|
||||
altFallback={altFallback}
|
||||
title={imageTitleValue}
|
||||
onSave={(url) => onSave(url)}
|
||||
onSaveAlt={onSaveAlt}
|
||||
onSaveTitle={onSaveImageTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
// مقدار نمایشی از وضعیت محلی میآید تا افزودن/حذف گزینه فوراً دیده شود
|
||||
const selectProps = {
|
||||
value: text,
|
||||
options: field.options ?? [],
|
||||
onChange: (next: string) => {
|
||||
setText(next)
|
||||
void onSave(next)
|
||||
},
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
{field.creatable ? (
|
||||
<LuxeCreatableSelect {...selectProps} />
|
||||
) : (
|
||||
<LuxeSelect {...selectProps} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'date') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
<JalaliDatePicker
|
||||
value={String(value ?? '')}
|
||||
onChange={(isoDate) => {
|
||||
setText(isoDate)
|
||||
void onSave(isoDate)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'wysiwyg') {
|
||||
return (
|
||||
<WysiwygField
|
||||
field={field}
|
||||
value={value}
|
||||
onSave={onSave}
|
||||
registerFlush={registerFlush}
|
||||
hint={placeholder ? 'خالی = بازدیدکننده متن فارسی را میبیند' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
return field.ui === 'tags' ? (
|
||||
<TagsField field={field} value={value} onSave={onSave} />
|
||||
) : (
|
||||
<ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را میبیند' : undefined} />
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
<textarea
|
||||
dir="auto"
|
||||
rows={4}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<input
|
||||
dir={field.type === 'number' ? 'ltr' : 'auto'}
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** فیلد هشتگها — react-select قابل ساخت (Creatable) با ذخیره فوری */
|
||||
function TagsField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
}) {
|
||||
const [tags, setTags] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||
|
||||
const update = (next: string[]) => {
|
||||
setTags(next)
|
||||
void onSave(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
<LuxeTagsSelect value={tags} onChange={update} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
||||
function ListField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
hint,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
hint?: string
|
||||
}) {
|
||||
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||
|
||||
const update = (next: string[]) => {
|
||||
setLines(next)
|
||||
void onSave(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
|
||||
<div className="mt-2 grid gap-2">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<textarea
|
||||
dir="auto"
|
||||
rows={line.length > 60 ? 3 : 1}
|
||||
value={line}
|
||||
onChange={(e) => {
|
||||
const next = [...lines]
|
||||
next[i] = e.target.value
|
||||
setLines(next)
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (line !== (value as string[])[i]) update(lines)
|
||||
}}
|
||||
className="flex-1 resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف خط"
|
||||
onClick={() => update(lines.filter((_, j) => j !== i))}
|
||||
className="mt-1 text-ivory/40 transition-colors hover:text-red-300"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update([...lines, ''])}
|
||||
className="flex w-fit items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1.5 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
افزودن خط
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/** فیلد WYSIWYG مقاله — ذخیره خودکار با تاخیر کوتاه و flush هنگام بستن کشو */
|
||||
function WysiwygField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
registerFlush,
|
||||
hint,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
hint?: string
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(
|
||||
// بدنه ذخیرهشده (سند جدید، رشته JSON یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
() => toArticleBodyDoc(value),
|
||||
)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pending = useRef<Record<string, unknown> | null>(null)
|
||||
|
||||
const flush = () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
if (pending.current) {
|
||||
const next = pending.current
|
||||
pending.current = null
|
||||
void onSave(next)
|
||||
}
|
||||
}
|
||||
|
||||
// ثبت آخرین flush در کشو + ذخیره تغییرات در انتظار هنگام unmount
|
||||
useEffect(() => {
|
||||
registerFlush?.(() => flush())
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
if (pending.current) {
|
||||
const next = pending.current
|
||||
pending.current = null
|
||||
void onSave(next)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
})
|
||||
|
||||
const handleChange = (json: Record<string, unknown>) => {
|
||||
setDoc(json)
|
||||
pending.current = json
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(flush, 800)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
|
||||
<p className="mt-1 text-[10px] font-light text-ivory/40">
|
||||
ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<ArticleEditor doc={doc} onChange={handleChange} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// لایه تماس ادمین — همه درخواستها با توکن Bearer و مدیریت خطای یکسان
|
||||
import { API_URL } from '../config'
|
||||
|
||||
const TOKEN_KEY = 'aramesh_admin_token'
|
||||
|
||||
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
|
||||
export const setToken = (token: string | null): void => {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function authFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken()
|
||||
const isFormData = options.body instanceof FormData
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.body && !isFormData ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
setToken(null)
|
||||
throw new HttpError(401, 'نشست شما منقضی شده — دوباره وارد شوید')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
|
||||
const detail =
|
||||
typeof data?.detail === 'string'
|
||||
? data.detail
|
||||
: res.status === 409
|
||||
? 'رکوردی با این مقدار تکراری وجود دارد'
|
||||
: 'خطا در ذخیرهسازی — دوباره تلاش کنید'
|
||||
throw new HttpError(res.status, detail)
|
||||
}
|
||||
return res.status === 204 ? (null as T) : ((await res.json()) as T)
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
login: async (username: string, password: string): Promise<string> => {
|
||||
const res = await fetch(`${API_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
|
||||
throw new HttpError(
|
||||
res.status,
|
||||
typeof data?.detail === 'string' ? data.detail : 'ورود ناموفق بود',
|
||||
)
|
||||
}
|
||||
const data = (await res.json()) as { access_token: string }
|
||||
setToken(data.access_token)
|
||||
return data.access_token
|
||||
},
|
||||
|
||||
patch: <T = unknown>(resource: string, id: number, partial: Record<string, unknown>) =>
|
||||
authFetch<T>(`/api/admin/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(partial) }),
|
||||
|
||||
list: <T = unknown>(resource: string) => authFetch<T>(`/api/admin/${resource}`),
|
||||
|
||||
get: <T = unknown>(resource: string, id: number) =>
|
||||
authFetch<T>(`/api/admin/${resource}/${id}`),
|
||||
|
||||
create: <T = unknown>(resource: string, data: Record<string, unknown>) =>
|
||||
authFetch<T>(`/api/admin/${resource}`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
|
||||
remove: (resource: string, id: number) =>
|
||||
authFetch<null>(`/api/admin/${resource}/${id}`, { method: 'DELETE' }),
|
||||
|
||||
reorder: <T = unknown>(resource: string, ids: number[]) =>
|
||||
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
|
||||
|
||||
upload: async (file: File): Promise<{ url: string; key: string }> => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
|
||||
},
|
||||
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
authFetch<{ ok: boolean }>('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { SiteData } from '../api'
|
||||
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
|
||||
|
||||
export type FieldConfig = {
|
||||
name: string
|
||||
label: string
|
||||
type: FieldType
|
||||
hint?: string
|
||||
/** فقط برای type === 'select' — گزینههای ثابت react-select */
|
||||
options?: string[]
|
||||
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
|
||||
creatable?: boolean
|
||||
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
||||
ui?: 'lines' | 'tags'
|
||||
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمیشوند */
|
||||
shared?: boolean
|
||||
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
|
||||
altFallbackSuffix?: string
|
||||
}
|
||||
|
||||
export type EntityConfig = {
|
||||
resource: string
|
||||
dataKey: string
|
||||
title: string
|
||||
kind: 'collection' | 'singleton'
|
||||
titleKey: string
|
||||
fields: FieldConfig[]
|
||||
defaults: Record<string, unknown>
|
||||
}
|
||||
|
||||
const F = (
|
||||
name: string,
|
||||
label: string,
|
||||
type: FieldType = 'text',
|
||||
hint?: string,
|
||||
sharedOrExtra?: boolean | Partial<FieldConfig>,
|
||||
): FieldConfig => ({
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
hint,
|
||||
...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null
|
||||
? sharedOrExtra
|
||||
: { shared: !!sharedOrExtra }),
|
||||
})
|
||||
|
||||
/** دستهبندیهای استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده میشوند */
|
||||
export const ARTICLE_CATEGORIES = [
|
||||
'معماری و ساخت',
|
||||
'سرمایهگذاری ملکی',
|
||||
'طراحی داخلی',
|
||||
'اخبار و اطلاعیهها',
|
||||
'یادداشت مدیریت',
|
||||
]
|
||||
|
||||
export const ENTITIES: Record<string, EntityConfig> = {
|
||||
services: {
|
||||
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape', true)],
|
||||
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
|
||||
},
|
||||
projects: {
|
||||
resource: 'projects', dataKey: 'projects', title: 'پروژهها', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
|
||||
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
|
||||
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
|
||||
],
|
||||
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
|
||||
},
|
||||
articles: {
|
||||
resource: 'articles', dataKey: 'articles', title: 'مقالات', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('title', 'عنوان'),
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود', true),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
|
||||
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
|
||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
|
||||
F('author', 'نویسنده', 'text', undefined, true),
|
||||
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
|
||||
F('read', 'زمان مطالعه (دقیقه)'),
|
||||
F('slug', 'نشانی (slug)', 'text', undefined, true),
|
||||
],
|
||||
defaults: {
|
||||
title: 'مقاله جدید',
|
||||
slug: `article-${Date.now()}`,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
tags: ['مدیریت پروژه', 'ساختوساز'],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
seo_keyphrase: '',
|
||||
body: { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||
},
|
||||
},
|
||||
testimonials: {
|
||||
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام'), F('role', 'سمت'), F('quote', 'متن نظر', 'textarea')],
|
||||
defaults: { name: 'نظر جدید', role: '', quote: '' },
|
||||
},
|
||||
faqs: {
|
||||
resource: 'faqs', dataKey: 'faqs', title: 'سوالات متداول', kind: 'collection',
|
||||
titleKey: 'question',
|
||||
fields: [F('question', 'سوال'), F('answer', 'پاسخ', 'textarea')],
|
||||
defaults: { question: 'سوال جدید', answer: '' },
|
||||
},
|
||||
team: {
|
||||
resource: 'team', dataKey: 'team', title: 'تیم', kind: 'collection',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام'), F('role', 'سمت'), F('bio', 'معرفی', 'textarea')],
|
||||
defaults: { name: 'عضو جدید', role: '', bio: '' },
|
||||
},
|
||||
timeline: {
|
||||
resource: 'timeline', dataKey: 'timeline', title: 'تایملاین', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image', undefined, true)],
|
||||
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
|
||||
},
|
||||
stats: {
|
||||
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number', undefined, true), F('suffix', 'پسوند (مثل + یا هزار)')],
|
||||
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
|
||||
},
|
||||
why_us: {
|
||||
resource: 'why-us', dataKey: 'why_us', title: 'چرا آرامش', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
|
||||
defaults: { title: 'مورد جدید', desc: '' },
|
||||
},
|
||||
investment_models: {
|
||||
resource: 'investment-models', dataKey: 'investment_models', title: 'مدلهای سرمایهگذاری', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('points', 'نکات (هر خط یک مورد)', 'list')],
|
||||
defaults: { title: 'مدل جدید', desc: '', points: [] },
|
||||
},
|
||||
process_steps: {
|
||||
resource: 'process-steps', dataKey: 'process_steps', title: 'مراحل فرآیند کار', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('step', 'شماره مرحله'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
|
||||
defaults: { step: '۰۱', title: 'مرحله جدید', desc: '' },
|
||||
},
|
||||
offices: {
|
||||
resource: 'offices', dataKey: 'offices', title: 'دفاتر', kind: 'collection',
|
||||
titleKey: 'city',
|
||||
fields: [F('city', 'شهر'), F('address', 'نشانی', 'textarea'), F('hours', 'ساعات کاری')],
|
||||
defaults: { city: 'شهر جدید', address: '', hours: '' },
|
||||
},
|
||||
gallery: {
|
||||
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
|
||||
titleKey: 'alt',
|
||||
fields: [F('src', 'تصویر', 'image', undefined, true), F('alt', 'متن جایگزین')],
|
||||
defaults: { src: '', alt: 'تصویر گالری' },
|
||||
},
|
||||
nav: {
|
||||
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
|
||||
defaults: { label: 'آیتم جدید', href: '/' },
|
||||
},
|
||||
seo_meta: {
|
||||
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||
titleKey: 'page_key',
|
||||
fields: [
|
||||
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact', true),
|
||||
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
|
||||
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
|
||||
],
|
||||
defaults: { page_key: '', title: '', description: '' },
|
||||
},
|
||||
brand: {
|
||||
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image', undefined, true), F('hero', 'تصویر هیرو', 'image', undefined, true), F('contact_bg', 'پسزمینه بخش تماس', 'image', undefined, true), F('phone', 'تلفن نمایشی', 'text', undefined, true), F('phone_href', 'لینک تلفن', 'text', undefined, true), F('email', 'ایمیل', 'text', undefined, true), F('address', 'نشانی', 'textarea')],
|
||||
defaults: {},
|
||||
},
|
||||
hero: {
|
||||
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||
defaults: {},
|
||||
},
|
||||
story: {
|
||||
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان بخش'), F('paragraphs', 'پاراگرافهای معرفی', 'list'), F('quote', 'نقلقول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقلقول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه', 'text', undefined, true)],
|
||||
defaults: {},
|
||||
},
|
||||
before_after: {
|
||||
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image', undefined, true), F('after', 'تصویر بعد', 'image', undefined, true), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||
defaults: {},
|
||||
},
|
||||
contact_section: {
|
||||
resource: 'contact-section', dataKey: 'contact_section', title: 'فرم تماس', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('label', 'برچسب بالا'), F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'),
|
||||
F('address_label', 'برچسب نشانی'), F('phone_label', 'برچسب تلفن'), F('email_label', 'برچسب ایمیل'),
|
||||
F('name_label', 'برچسب نام'), F('name_placeholder', 'جاینگهدار نام'),
|
||||
F('phone_field_label', 'برچسب تلفن (فرم)'), F('phone_placeholder', 'جاینگهدار تلفن'),
|
||||
F('note_label', 'برچسب توضیحات'), F('note_optional_label', 'برچسب اختیاری'), F('note_placeholder', 'جاینگهدار توضیحات'),
|
||||
F('submit_label', 'متن دکمه ارسال'), F('sending_label', 'متن در حال ارسال'),
|
||||
F('success_title', 'عنوان پیام موفقیت'), F('success_text', 'متن پیام موفقیت', 'textarea'), F('success_button_label', 'دکمه پیام موفقیت'),
|
||||
],
|
||||
defaults: {},
|
||||
},
|
||||
newsletter: {
|
||||
resource: 'newsletter', dataKey: 'newsletter', title: 'خبرنامه', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('placeholder', 'جاینگهدار ایمیل'), F('button_label', 'متن دکمه'), F('sending_label', 'متن در حال ارسال'), F('success_text', 'پیام موفقیت')],
|
||||
defaults: {},
|
||||
},
|
||||
footer: {
|
||||
resource: 'footer', dataKey: 'footer', title: 'فوتر', kind: 'singleton',
|
||||
titleKey: 'pages_heading',
|
||||
fields: [F('about_text', 'متن معرفی', 'textarea'), F('pages_heading', 'تیتر ستون صفحات'), F('services_heading', 'تیتر ستون خدمات'), F('contact_heading', 'تیتر ستون تماس'), F('copyright', 'متن کپیرایت'), F('tagline', 'شعار پایین')],
|
||||
defaults: {},
|
||||
},
|
||||
}
|
||||
|
||||
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
|
||||
export function isTranslatable(resource: string, name: string): boolean {
|
||||
if (name.endsWith('_en')) return false
|
||||
const config = ENTITIES[resource]
|
||||
return !!config?.fields.some((f) => f.name === name && !f.shared)
|
||||
}
|
||||
|
||||
/** فیلدهای قابل ویرایش یک منبع در زبان دادهشده — انگلیسی: بدون فیلدهای مشترک و با پسوند _en */
|
||||
export function editableFields(config: EntityConfig, lang: 'fa' | 'en'): FieldConfig[] {
|
||||
if (lang === 'fa') return config.fields
|
||||
return config.fields
|
||||
.filter((f) => !f.shared)
|
||||
.map((f) => ({ ...f, name: `${f.name}_en` }))
|
||||
}
|
||||
|
||||
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */
|
||||
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
|
||||
return data[config.dataKey as keyof SiteData] as never
|
||||
}
|
||||
@@ -2,11 +2,15 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Article, Project, Service } from './data/site'
|
||||
import { API_URL } from './config'
|
||||
import { getToken } from './admin/adminClient'
|
||||
import { localizeSite } from './i18n/localize'
|
||||
import { useLang } from './i18n/lang'
|
||||
|
||||
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
||||
export type BrandInfo = {
|
||||
id: number
|
||||
name: string
|
||||
name_en?: string
|
||||
logo: string
|
||||
hero: string
|
||||
contact_bg: string
|
||||
@@ -14,26 +18,186 @@ export type BrandInfo = {
|
||||
phone_href: string
|
||||
email: string
|
||||
address: string
|
||||
address_en?: string
|
||||
}
|
||||
|
||||
export type TestimonialItem = { id: number; name: string; role: string; quote: string }
|
||||
export type FaqItem = { id: number; question: string; answer: string }
|
||||
export type TeamMemberItem = { id: number; name: string; role: string; bio: string }
|
||||
export type TimelineItem = { id: number; year: string; title: string; desc: string; image: string }
|
||||
export type StatItem = { id: number; value: number; suffix: string; label: string }
|
||||
export type WhyUsItem = { id: number; title: string; desc: string }
|
||||
export type TestimonialItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; quote: string; quote_en?: string }
|
||||
export type FaqItem = { id: number; question: string; question_en?: string; answer: string; answer_en?: string }
|
||||
export type TeamMemberItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; bio: string; bio_en?: string }
|
||||
export type TimelineItem = { id: number; year: string; year_en?: string; title: string; title_en?: string; desc: string; desc_en?: string; image: string }
|
||||
export type StatItem = { id: number; value: number; suffix: string; suffix_en?: string; label: string; label_en?: string }
|
||||
export type WhyUsItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string }
|
||||
export type BeforeAfterInfo = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
before: string
|
||||
after: string
|
||||
before_label: string
|
||||
before_label_en?: string
|
||||
after_label: string
|
||||
after_label_en?: string
|
||||
}
|
||||
|
||||
export type NavItem = { id: number; label: string; label_en?: string; href: string }
|
||||
|
||||
export type HeroContent = {
|
||||
id: number
|
||||
kicker: string
|
||||
kicker_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
title_highlight: string
|
||||
title_highlight_en?: string
|
||||
subtitle: string
|
||||
subtitle_en?: string
|
||||
badge_value: string
|
||||
badge_value_en?: string
|
||||
badge_label: string
|
||||
badge_label_en?: string
|
||||
cta_primary_label: string
|
||||
cta_primary_label_en?: string
|
||||
cta_primary_href: string
|
||||
cta_secondary_label: string
|
||||
cta_secondary_label_en?: string
|
||||
cta_secondary_href: string
|
||||
scroll_label: string
|
||||
scroll_label_en?: string
|
||||
}
|
||||
|
||||
export type StoryContent = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
paragraphs: string[]
|
||||
paragraphs_en?: string[] | null
|
||||
quote: string
|
||||
quote_en?: string
|
||||
quote_author: string
|
||||
quote_author_en?: string
|
||||
stat1_value: string
|
||||
stat1_value_en?: string
|
||||
stat1_label: string
|
||||
stat1_label_en?: string
|
||||
stat2_value: string
|
||||
stat2_value_en?: string
|
||||
stat2_label: string
|
||||
stat2_label_en?: string
|
||||
link_label: string
|
||||
link_label_en?: string
|
||||
link_href: string
|
||||
bento: { src: string; alt: string }[]
|
||||
bento_en?: { src: string; alt: string }[] | null
|
||||
}
|
||||
|
||||
export type SectionHeadingContent = {
|
||||
id: number
|
||||
key: string
|
||||
label: string
|
||||
label_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
image: string
|
||||
cta_label: string
|
||||
cta_label_en?: string
|
||||
cta_href: string
|
||||
}
|
||||
|
||||
export type PageHeroContent = { id: number; key: string; title: string; title_en?: string; lead: string; lead_en?: string; image: string }
|
||||
|
||||
export type InvestmentModelItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string; points: string[]; points_en?: string[] | null }
|
||||
export type ProcessStepItem = { id: number; step: string; step_en?: string; title: string; title_en?: string; desc: string; desc_en?: string }
|
||||
export type OfficeItem = { id: number; city: string; city_en?: string; address: string; address_en?: string; hours: string; hours_en?: string }
|
||||
export type GalleryImageItem = { id: number; src: string; alt: string; alt_en?: string }
|
||||
export type StatusLabelItem = { id: number; key: string; label: string; label_en?: string }
|
||||
|
||||
export type ContactSectionContent = {
|
||||
id: number
|
||||
label: string
|
||||
label_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
address_label: string
|
||||
address_label_en?: string
|
||||
phone_label: string
|
||||
phone_label_en?: string
|
||||
email_label: string
|
||||
email_label_en?: string
|
||||
name_label: string
|
||||
name_label_en?: string
|
||||
name_placeholder: string
|
||||
name_placeholder_en?: string
|
||||
phone_field_label: string
|
||||
phone_field_label_en?: string
|
||||
phone_placeholder: string
|
||||
phone_placeholder_en?: string
|
||||
note_label: string
|
||||
note_label_en?: string
|
||||
note_optional_label: string
|
||||
note_optional_label_en?: string
|
||||
note_placeholder: string
|
||||
note_placeholder_en?: string
|
||||
submit_label: string
|
||||
submit_label_en?: string
|
||||
sending_label: string
|
||||
sending_label_en?: string
|
||||
success_title: string
|
||||
success_title_en?: string
|
||||
success_text: string
|
||||
success_text_en?: string
|
||||
success_button_label: string
|
||||
success_button_label_en?: string
|
||||
}
|
||||
|
||||
export type NewsletterContent = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
placeholder: string
|
||||
placeholder_en?: string
|
||||
button_label: string
|
||||
button_label_en?: string
|
||||
sending_label: string
|
||||
sending_label_en?: string
|
||||
success_text: string
|
||||
success_text_en?: string
|
||||
}
|
||||
|
||||
export type SocialItem = { label: string; href: string; icon: string }
|
||||
|
||||
export type FooterContent = {
|
||||
id: number
|
||||
about_text: string
|
||||
about_text_en?: string
|
||||
pages_heading: string
|
||||
pages_heading_en?: string
|
||||
services_heading: string
|
||||
services_heading_en?: string
|
||||
contact_heading: string
|
||||
contact_heading_en?: string
|
||||
copyright: string
|
||||
copyright_en?: string
|
||||
tagline: string
|
||||
tagline_en?: string
|
||||
socials: SocialItem[]
|
||||
socials_en?: SocialItem[] | null
|
||||
}
|
||||
|
||||
export interface SiteData {
|
||||
brand: BrandInfo
|
||||
nav: NavItem[]
|
||||
hero: HeroContent
|
||||
story: StoryContent
|
||||
section_headings: SectionHeadingContent[]
|
||||
page_heroes: PageHeroContent[]
|
||||
services: Service[]
|
||||
projects: Project[]
|
||||
articles: Article[]
|
||||
@@ -44,15 +208,31 @@ export interface SiteData {
|
||||
stats: StatItem[]
|
||||
why_us: WhyUsItem[]
|
||||
before_after: BeforeAfterInfo
|
||||
investment_models: InvestmentModelItem[]
|
||||
process_steps: ProcessStepItem[]
|
||||
offices: OfficeItem[]
|
||||
gallery: GalleryImageItem[]
|
||||
status_labels: StatusLabelItem[]
|
||||
contact_section: ContactSectionContent
|
||||
newsletter: NewsletterContent
|
||||
footer: FooterContent
|
||||
seo_meta: { id: number; page_key: string; title: string; title_en?: string; description: string; description_en?: string }[]
|
||||
}
|
||||
|
||||
interface SiteContextValue {
|
||||
data: SiteData | null
|
||||
error: boolean
|
||||
reload: () => void
|
||||
/** بهروزرسانی خوشبینانه درخت داده — پس از موفقیت PATCH استفاده میشود */
|
||||
updateSite: (mutate: (data: SiteData) => void) => void
|
||||
}
|
||||
|
||||
const SiteContext = createContext<SiteContextValue>({ data: null, error: false, reload: () => {} })
|
||||
const SiteContext = createContext<SiteContextValue>({
|
||||
data: null,
|
||||
error: false,
|
||||
reload: () => {},
|
||||
updateSite: () => {},
|
||||
})
|
||||
|
||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const [data, setData] = useState<SiteData | null>(null)
|
||||
@@ -66,9 +246,27 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<SiteData>
|
||||
})
|
||||
.then((json) => {
|
||||
.then(async (json) => {
|
||||
if (cancelled) return
|
||||
// برای ادمین واردشده، پیشنویس مقالهها از انتهای مدیریتی خوانده و ادغام میشوند —
|
||||
// بکاند در GET /api/site فقط مقالههای منتشرشده را برمیگرداند
|
||||
if (getToken()) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const all = (await res.json()) as Article[]
|
||||
const published = new Set(json.articles.map((a) => a.id))
|
||||
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
||||
json.articles = [...json.articles, ...drafts]
|
||||
}
|
||||
} catch {
|
||||
/* بدون پیشنویسها ادامه بده */
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
if (json.brand && json.before_after) setData(json)
|
||||
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
||||
else setError(true)
|
||||
}
|
||||
})
|
||||
@@ -88,18 +286,71 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return <SiteContext.Provider value={{ data, error, reload }}>{children}</SiteContext.Provider>
|
||||
const updateSite = useCallback((mutate: (data: SiteData) => void) => {
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
const next = structuredClone(prev)
|
||||
mutate(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SiteContext.Provider value={{ data, error, reload, updateSite }}>
|
||||
{children}
|
||||
</SiteContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSiteData(): SiteContextValue {
|
||||
return useContext(SiteContext)
|
||||
}
|
||||
|
||||
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند
|
||||
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند.
|
||||
// خروجی بومیسازیشده است — فیلدهای انگلیسی در زبان انگلیسی جایگزین فارسی میشوند (با برگشت به فارسی).
|
||||
// داده خام برای ویرایش مدیریتی از useSiteData() خوانده میشود.
|
||||
export function useSite(): SiteData {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
if (!data) {
|
||||
throw new Error('useSite باید پس از بارگذاری دادههای سایت استفاده شود')
|
||||
}
|
||||
return data
|
||||
return localizeSite(data, lang)
|
||||
}
|
||||
|
||||
const EMPTY_HEADING: SectionHeadingContent = {
|
||||
id: 0,
|
||||
key: '',
|
||||
label: '',
|
||||
title: '',
|
||||
lead: '',
|
||||
image: '',
|
||||
cta_label: '',
|
||||
cta_href: '',
|
||||
}
|
||||
|
||||
/** تیتر و توضیح یک بخش بر اساس key تعریفشده در پنل مدیریت */
|
||||
export function useSectionHeading(key: string): SectionHeadingContent {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return localized?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
|
||||
}
|
||||
|
||||
/** هیرو بالای صفحه بر اساس key تعریفشده در پنل مدیریت */
|
||||
export function usePageHero(key: string): PageHeroContent {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return (
|
||||
localized?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
|
||||
)
|
||||
}
|
||||
|
||||
/** برچسب وضعیت پروژه (done | progress) به زبان جاری — از پنل مدیریت */
|
||||
export function useStatusLabel(key: string): string {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return localized?.status_labels.find((s) => s.key === key)?.label ?? key
|
||||
}
|
||||
|
||||
@@ -2,44 +2,52 @@ import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { formatJalali } from '../utils/date'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
export function ArticleCard({ article }: { article: Article }) {
|
||||
const { t, href } = useLang()
|
||||
return (
|
||||
<Link
|
||||
to={`/blog/${article.slug}`}
|
||||
className="group flex h-full flex-col border border-ivory/10 bg-char transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`خواندن مقاله ${article.title}`}
|
||||
to={href(`/blog/${article.slug}`)}
|
||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`${t('readArticlePrefix')} ${article.title}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="relative overflow-hidden">
|
||||
<img
|
||||
src={article.image}
|
||||
alt={`تصویر مقاله ${article.title}`}
|
||||
className="h-52 w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105 transform-gpu"
|
||||
alt={`${t('articleImagePrefix')} ${article.title}`}
|
||||
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||
<div className="absolute top-4 ltr:left-4 rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||
{article.category}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
<p className="text-xs font-light text-copper">{article.category}</p>
|
||||
|
||||
<h3 className="mt-4 text-lg font-bold leading-9 text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-4 mb-8 text-sm font-light leading-[2.2rem] text-ivory/55">
|
||||
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-4">
|
||||
<span>{article.date}</span>
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-3">
|
||||
<span>{formatJalali(article.date)}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
{article.read}
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||
{toPersianDigits(article.read)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-copper">
|
||||
ادامه مطلب
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 ease-out group-hover:-translate-x-1.5" />
|
||||
|
||||
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
|
||||
{t('readMore')}
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CalendarIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
type ArticleMetaProps = {
|
||||
article: Article
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* سربرگ نویسنده/تاریخ مقاله — زیر تیتر اصلی نمایش داده میشود:
|
||||
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
|
||||
*/
|
||||
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
|
||||
const rawDate = (article.date ?? '').trim()
|
||||
const author = (article.author ?? '').trim() || 'گروه ساختمانی آرامش'
|
||||
const initial = author.charAt(0)
|
||||
|
||||
// تاریخ ذخیرهشده (میلادی) به شمسی تبدیل میشود؛ در نبود تاریخ، امروز با ساعت نمایش داده میشود
|
||||
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
|
||||
const dateLabel = formatJalali(rawDate, todayLabel)
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
|
||||
{/* آواتار و نام نویسنده */}
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-lg font-bold text-copper shadow-[0_0_20px_rgba(213,135,76,0.25),inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
{initial}
|
||||
</span>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-bold text-ivory">{author}</span>
|
||||
<span className="text-[11px] font-light text-ivory/45">نویسنده</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="hidden h-9 w-px bg-ivory/10 sm:block" aria-hidden="true" />
|
||||
|
||||
{/* تاریخ جلالی */}
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
<CalendarIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||
{dateLabel}
|
||||
</span>
|
||||
|
||||
{/* زمان مطالعه — با ارقام فارسی */}
|
||||
{article.read && (
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||
{toPersianDigits(article.read)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,26 +9,33 @@ type BaseProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const STYLES: Record<Variant, { base: string; fill: string; text: string }> = {
|
||||
// استایلهای فوقلوکس شیشهای (Glassmorphic) با رنگهای اختصاصی پروژه
|
||||
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
|
||||
copper: {
|
||||
base: 'bg-copper text-char',
|
||||
fill: 'bg-char',
|
||||
text: 'group-hover:text-ivory',
|
||||
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
|
||||
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: {
|
||||
base: 'border border-ivory/30 text-ivory',
|
||||
fill: 'bg-copper',
|
||||
text: 'group-hover:text-char',
|
||||
// دکمه ثانویه: شیشه دودی کاملاً مات (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-char text-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-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-char text-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)]',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -36,12 +43,18 @@ function Inner({ children, variant = 'copper' }: { children: React.ReactNode; va
|
||||
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 transition-transform duration-[600ms] ease-lux group-hover:translate-y-0 ${style.fill}`}
|
||||
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-[600ms] ease-lux ${style.text}`}
|
||||
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
@@ -49,8 +62,9 @@ function Inner({ children, variant = 'copper' }: { children: React.ReactNode; va
|
||||
)
|
||||
}
|
||||
|
||||
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشهای
|
||||
const SHELL =
|
||||
'group relative inline-flex items-center justify-center overflow-hidden px-8 py-4 text-sm font-bold focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink'
|
||||
'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,
|
||||
@@ -58,8 +72,9 @@ export function ButtonLink({
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
}: BaseProps & { to: string }) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<Link to={to} className={`${SHELL} ${STYLES[variant].base} ${className}`}>
|
||||
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</Link>
|
||||
)
|
||||
@@ -72,8 +87,9 @@ export function ButtonAnchor({
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<a href={href} className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
|
||||
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</a>
|
||||
)
|
||||
@@ -85,8 +101,9 @@ export function Button({
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<button className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
|
||||
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
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 { Reveal } from './Reveal'
|
||||
import { Button } from './Button'
|
||||
import { useSite } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { API_URL } from '../config'
|
||||
|
||||
export function ContactSection() {
|
||||
const { brand } = useSite()
|
||||
const { brand, contact_section: content } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||
const [sent, setSent] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
const saveContent = (field: string, value: string) =>
|
||||
save('contact-section', content.id, { [field]: value }, (d) => {
|
||||
;(d.contact_section as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const saveBrand = (field: string, value: string) =>
|
||||
save('brand', brand.id, { [field]: value }, (d) => {
|
||||
;(d.brand as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const showErrorAlert = (title: string, text: string) => {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
@@ -20,14 +36,14 @@ export function ContactSection() {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonText: t('understood'),
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-[8px] bg-ink/60',
|
||||
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
popup: 'font-pinar border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl bg-char/95',
|
||||
title: 'font-pinar font-bold text-lg text-ivory pt-2',
|
||||
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
|
||||
confirmButton: 'mt-6 bg-copper text-char font-bold px-10 py-3 transition-transform duration-300 ease-lux hover:scale-105',
|
||||
confirmButton: 'mt-6 rounded-full bg-copper text-char font-bold px-10 py-3 transition-all duration-300 ease-lux hover:scale-105 hover:shadow-[0_0_20px_rgba(197,143,92,0.5)]',
|
||||
},
|
||||
showClass: {
|
||||
popup: 'swal2-show',
|
||||
@@ -36,7 +52,7 @@ export function ContactSection() {
|
||||
hideClass: {
|
||||
popup: 'swal2-hide',
|
||||
backdrop: 'swal2-backdrop-hide',
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,13 +64,13 @@ export function ContactSection() {
|
||||
const phoneValue = form.phone.trim()
|
||||
|
||||
if (nameValue.length < 3) {
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
|
||||
showErrorAlert(t('incompleteInfo'), t('nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
|
||||
if (!phoneValue || !phoneRegex.test(phoneValue)) {
|
||||
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
|
||||
showErrorAlert(t('phoneInvalidTitle'), t('phoneInvalidText'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,14 +85,14 @@ export function ContactSection() {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در ثبت درخواست', detail)
|
||||
typeof data?.detail === 'string' ? data.detail : t('formCheck')
|
||||
showErrorAlert(t('submitErrorTitle'), detail)
|
||||
return
|
||||
}
|
||||
|
||||
setSent(true)
|
||||
} catch {
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -90,53 +106,73 @@ export function ContactSection() {
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
|
||||
|
||||
<div className="relative mx-auto max-w-site px-6 lg:px-10">
|
||||
<div className="mx-auto grid max-w-6xl items-stretch gap-6 lg:grid-cols-12">
|
||||
|
||||
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[8px] md:p-10 lg:col-span-5">
|
||||
<div className="mx-auto grid max-w-6xl items-stretch gap-8 lg:grid-cols-12">
|
||||
|
||||
{/* باکس اطلاعات تماس */}
|
||||
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
|
||||
<Reveal>
|
||||
<p className="text-sm font-light text-copper">تماس با ما</p>
|
||||
<h2 id="contact-title" className="mt-5 text-section font-bold text-ivory">
|
||||
پروژهتان را با ما در میان بگذارید
|
||||
<p className="text-sm font-light tracking-wide text-copper">
|
||||
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
|
||||
</p>
|
||||
<h2 id="contact-title" className="mt-4 text-section font-bold text-ivory">
|
||||
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
|
||||
</h2>
|
||||
<p className="mt-6 text-base font-light leading-9 text-ivory/75">
|
||||
شمارهتان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس میگیرد.
|
||||
<p className="mt-5 text-base font-light leading-9 text-ivory/75">
|
||||
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<ul className="mt-10 space-y-6">
|
||||
<li className="flex items-start gap-4">
|
||||
<MapPinIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
<MapPinIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ivory">دفتر مرکزی</p>
|
||||
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">{brand.address}</p>
|
||||
<p className="text-sm font-bold text-ivory">
|
||||
<InlineEdit value={content.address_label} onSave={(v) => saveContent('address_label', v)} />
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">
|
||||
<InlineEdit value={brand.address} onSave={(v) => saveBrand('address', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-4">
|
||||
<PhoneIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
<PhoneIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ivory">تلفن</p>
|
||||
<p className="text-sm font-bold text-ivory">
|
||||
<InlineEdit value={content.phone_label} onSave={(v) => saveContent('phone_label', v)} />
|
||||
</p>
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
dir="ltr"
|
||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
{brand.phone}
|
||||
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-4">
|
||||
<MailIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
<MailIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ivory">ایمیل</p>
|
||||
<p className="text-sm font-bold text-ivory">
|
||||
<InlineEdit value={content.email_label} onSave={(v) => saveContent('email_label', v)} />
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:${brand.email}`}
|
||||
dir="ltr"
|
||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
{brand.email}
|
||||
<InlineEdit value={brand.email} onSave={(v) => saveBrand('email', v)} />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
@@ -144,13 +180,19 @@ export function ContactSection() {
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[9px] md:p-10 lg:col-span-7">
|
||||
{/* باکس فرم ارسال درخواست */}
|
||||
<div className="relative rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-7">
|
||||
<SingletonPill resource="contact_section" label="ویرایش برچسبها و متنهای فرم" className="absolute left-4 top-4" />
|
||||
{sent ? (
|
||||
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
|
||||
<CheckCircle2Icon className="h-12 w-12 text-copper" />
|
||||
<h3 className="mt-6 text-xl font-bold text-ivory">درخواست شما ثبت شد</h3>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-copper shadow-[0_0_30px_rgba(197,143,92,0.4)]">
|
||||
<CheckCircle2Icon className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-bold text-ivory">
|
||||
<InlineEdit value={content.success_title} onSave={(v) => saveContent('success_title', v)} />
|
||||
</h3>
|
||||
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
|
||||
از اعتماد شما سپاسگزاریم. کارشناسان ما بهزودی با شما تماس میگیرند.
|
||||
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -161,28 +203,28 @@ export function ContactSection() {
|
||||
setForm({ name: '', phone: '', note: '' })
|
||||
}}
|
||||
>
|
||||
ثبت درخواست جدید
|
||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="contact-name" className="block text-sm font-bold text-ivory/90">
|
||||
نام
|
||||
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="نام و نام خانوادگی"
|
||||
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
|
||||
placeholder={content.name_placeholder}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="contact-phone" className="block text-sm font-bold text-ivory/90">
|
||||
شماره تلفن <span className="text-copper">*</span>
|
||||
<label htmlFor="contact-phone" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.phone_field_label} onSave={(v) => saveContent('phone_field_label', v)} /> <span className="text-copper">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="contact-phone"
|
||||
@@ -191,28 +233,40 @@ export function ContactSection() {
|
||||
required
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder="09xx xxx xxxx"
|
||||
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-right text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
|
||||
placeholder={content.phone_placeholder}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light ltr:text-left rtl:text-right text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="contact-note" className="block text-sm font-bold text-ivory/90">
|
||||
توضیحات <span className="font-light text-ivory/50">(اختیاری)</span>
|
||||
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{content.note_optional_label}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-note"
|
||||
rows={6}
|
||||
rows={5}
|
||||
value={form.note}
|
||||
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||
placeholder="درباره پروژه یا درخواست خود بنویسید…"
|
||||
className="mt-3 w-full resize-none border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light leading-8 text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
|
||||
placeholder={content.note_placeholder}
|
||||
className="mt-3 w-full resize-none rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light leading-8 text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit" variant="copper" className="w-full md:w-auto md:px-12" disabled={sending}>
|
||||
{sending ? 'در حال ارسال…' : 'ارسال درخواست مشاوره'}
|
||||
<div className="md:col-span-2 flex justify-start">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="copper"
|
||||
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? (
|
||||
content.sending_label
|
||||
) : (
|
||||
<>
|
||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import React from 'react';
|
||||
import { PhoneIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
|
||||
export function FloatingContact() {
|
||||
const { brand } = useSite();
|
||||
const { t } = useLang();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
aria-label="تماس تلفنی با گروه ساختمانی آرامش"
|
||||
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-copper text-char shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
|
||||
|
||||
<span className="absolute inset-0 rounded-full border border-copper/50 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
|
||||
<PhoneIcon className="h-5 w-5" />
|
||||
</a>);
|
||||
aria-label={t('callBrand')}
|
||||
className="group fixed bottom-8 ltr:right-8 rtl:left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
|
||||
>
|
||||
{/* پالس هاله نوری در پسزمینه مانند رادار */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-full border border-copper/60 animate-ping opacity-30"
|
||||
/>
|
||||
|
||||
{/* آیکون تلفن با انیمیشن چرخش ظریف در زمان هاور */}
|
||||
<PhoneIcon className="relative z-10 h-6 w-6 transition-transform duration-300 ease-lux group-hover:rotate-12" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +1,111 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { NAV } from '../data/site';
|
||||
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from './admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
|
||||
|
||||
const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
instagram: InstagramIcon,
|
||||
linkedin: LinkedinIcon,
|
||||
telegram: SendIcon,
|
||||
};
|
||||
|
||||
export function Footer() {
|
||||
const { brand, services } = useSite();
|
||||
const { brand, services, nav, footer } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveFooter = (field: string, value: string) =>
|
||||
save('footer', footer.id, { [field]: value }, (d) => {
|
||||
;(d.footer as unknown as Record<string, unknown>)[field] = value;
|
||||
});
|
||||
|
||||
const saveNav = (id: number, changes: Record<string, unknown>) =>
|
||||
save('nav', id, changes, (d) => {
|
||||
const item = d.nav.find((n) => n.id === id);
|
||||
if (item) Object.assign(item, changes);
|
||||
});
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="border-t border-ivory/10 bg-char">
|
||||
<footer className="relative border-t border-white/10 bg-char/95 backdrop-blur-2xl">
|
||||
<div className="mx-auto max-w-site px-6 py-16 lg:px-10">
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
<div className="lg:col-span-4">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/55">
|
||||
گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمانهایی میسازد که قرار است چند نسل در آنها زندگی کنند؛ معماری آرام،
|
||||
اجرای دقیق و سرمایهگذاری شفاف.
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
||||
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
{[
|
||||
{ Icon: InstagramIcon, label: 'اینستاگرام' },
|
||||
{ Icon: LinkedinIcon, label: 'لینکدین' },
|
||||
{ Icon: SendIcon, label: 'تلگرام' }].
|
||||
map(({ Icon, label }) =>
|
||||
<a
|
||||
key={label}
|
||||
href="#"
|
||||
aria-label={label}
|
||||
className="flex h-10 w-10 items-center justify-center border border-ivory/15 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
||||
|
||||
<Icon className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
{/* آیکونهای شبکههای اجتماعی: گویهای شیشهای شناور */}
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
{footer.socials.map(({ label, href, icon }) => {
|
||||
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
||||
return (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
aria-label={label}
|
||||
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
|
||||
>
|
||||
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-sm font-bold text-copper">صفحات</h3>
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{NAV.map((item) =>
|
||||
<li key={item.to}>
|
||||
<Link
|
||||
to={item.to}
|
||||
className="text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper">
|
||||
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<Link
|
||||
to={href(item.href)}
|
||||
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4">
|
||||
<AddItemButton resource="nav" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">خدمات</h3>
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{services.map((service) =>
|
||||
<li key={service.title} className="text-sm font-light text-ivory/60">
|
||||
{services.map((service) => (
|
||||
<li key={service.id} className="text-sm font-light text-ivory/60">
|
||||
{service.title}
|
||||
</li>
|
||||
)}
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">ارتباط با ما</h3>
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
|
||||
<li className="flex items-start gap-3">
|
||||
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
||||
@@ -84,11 +127,28 @@ export function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-ivory/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||
<p>© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.</p>
|
||||
<p>طراحی و ساخت با دقتِ معماری</p>
|
||||
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
|
||||
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||
<p>
|
||||
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>{t('backToTop')}</span>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p>
|
||||
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>);
|
||||
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
||||
import { NAV } from "../data/site";
|
||||
import { useSite } from "../api";
|
||||
import { use } from "framer-motion/m";
|
||||
import { useLang } from "../i18n/lang";
|
||||
import { useFieldSaver } from "../admin/AdminContext";
|
||||
import { InlineEdit } from "./admin/InlineEdit";
|
||||
import { EditableItem, SingletonPill } from "../admin/Editable";
|
||||
import { LangToggle } from "./LangToggle";
|
||||
|
||||
export function Header() {
|
||||
const { brand } = useSite();
|
||||
const { brand, nav } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveNav = (id: number, changes: Record<string, unknown>) =>
|
||||
save("nav", id, changes, (d) => {
|
||||
const item = d.nav.find((n) => n.id === id);
|
||||
if (item) Object.assign(item, changes);
|
||||
});
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { pathname } = useLocation();
|
||||
@@ -23,76 +34,86 @@ export function Header() {
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed inset-x-0 top-0 z-50 transition-[background-color,border-color,backdrop-filter] duration-300 ease-lux ${
|
||||
className={`fixed inset-x-0 top-0 z-50 transition-all duration-300 ease-lux ${
|
||||
scrolled
|
||||
? "border-b border-ivory/10 bg-ink/90 backdrop-blur-md"
|
||||
? "border-b border-white/10 bg-ink/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.5)]"
|
||||
: "border-b border-transparent bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-3"
|
||||
aria-label={brand.name}
|
||||
>
|
||||
<img
|
||||
src={brand.logo}
|
||||
alt={brand.name}
|
||||
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
|
||||
scrolled ? "h-14" : "h-20"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
<div className="relative flex items-center">
|
||||
<Link
|
||||
to={href("/")}
|
||||
className="flex items-center gap-3"
|
||||
aria-label={brand.name}
|
||||
>
|
||||
<img
|
||||
src={brand.logo}
|
||||
alt={brand.name}
|
||||
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
|
||||
scrolled ? "h-14" : "h-20"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
|
||||
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
|
||||
<nav aria-label={t("mainNav")} className="hidden xl:block">
|
||||
<ul className="flex items-center gap-7">
|
||||
{NAV.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{item.label}
|
||||
<span
|
||||
className={`absolute -bottom-0.5 left-0 right-0 h-px w-full origin-center bg-copper transition-transform duration-300 ease-lux ${
|
||||
isActive
|
||||
? "scale-x-100"
|
||||
: "scale-x-0 group-hover:scale-x-100"
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={href(item.href)}
|
||||
className={({ isActive }) =>
|
||||
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
|
||||
isActive ? "text-copper font-normal" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 left-0 right-0 h-0.5 w-full origin-center rounded-full bg-copper transition-transform duration-300 ease-lux ${
|
||||
isActive
|
||||
? "scale-x-100 shadow-[0_0_8px_rgba(197,143,92,0.8)]"
|
||||
: "scale-x-0 group-hover:scale-x-100"
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* دکمه تماس کپسولی شیشهای لوکس */}
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
className="group relative hidden items-center gap-2 overflow-hidden border border-copper/50 px-5 py-2.5 text-sm font-medium text-copper [text-shadow:_0_1px_2px_rgba(0,0,0,0.5)] lg:flex"
|
||||
className="group relative hidden items-center gap-3 rounded-full border border-copper/40 bg-white/[0.04] px-5 py-2 text-sm font-medium text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_20px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_25px_rgba(197,143,92,0.35)] active:scale-95 lg:flex"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-lux group-hover:translate-y-0"
|
||||
/>
|
||||
<span className="relative z-10 flex items-center gap-2 transition-colors duration-300 ease-lux group-hover:text-char">
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
<span dir="ltr">{brand.phone}</span>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-copper/20 text-copper transition-all duration-300 group-hover:bg-copper group-hover:text-char">
|
||||
<PhoneIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
||||
</span>
|
||||
<span dir="ltr" className="tracking-wide">{brand.phone}</span>
|
||||
</a>
|
||||
|
||||
{/* تغییر زبان — فارسی/انگلیسی */}
|
||||
<LangToggle />
|
||||
|
||||
{/* دکمه منوی موبایل دایرهای شیشهای */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper xl:hidden"
|
||||
aria-label={open ? "بستن منو" : "باز کردن منو"}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/5 text-ivory backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 xl:hidden"
|
||||
aria-label={open ? t("closeMenu") : t("openMenu")}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? (
|
||||
@@ -106,23 +127,28 @@ export function Header() {
|
||||
|
||||
{open && (
|
||||
<nav
|
||||
aria-label="ناوبری موبایل"
|
||||
className="border-t border-ivory/10 bg-ink/98 px-6 py-4 xl:hidden"
|
||||
aria-label={t("mobileNav")}
|
||||
className="border-t border-white/10 bg-ink/95 backdrop-blur-2xl px-6 py-4 xl:hidden shadow-2xl"
|
||||
>
|
||||
<ul className="grid gap-1">
|
||||
{NAV.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={href(item.href)}
|
||||
className={({ isActive }) =>
|
||||
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
</NavLink>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { LanguagesIcon } from 'lucide-react'
|
||||
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
||||
|
||||
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
||||
export function LangToggle({ className = '' }: { className?: string }) {
|
||||
const { lang, t } = useLang()
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const target = counterpartPath(pathname, lang)
|
||||
|
||||
const switchLang = () => {
|
||||
writeLangCookie(lang === 'fa' ? 'en' : 'fa')
|
||||
// پرش خودکار بازدید بعدی با انتخاب دستی همینجا گره نمیخورد
|
||||
sessionStorage.setItem('langAutoRedirect', '1')
|
||||
navigate(target)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchLang}
|
||||
aria-label={t('switchLangAria')}
|
||||
title={t('switchLangAria')}
|
||||
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}`}
|
||||
>
|
||||
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
||||
<span>{t('switchLang')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -2,52 +2,90 @@ import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeftIcon } from 'lucide-react'
|
||||
import { usePageHero } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { ImageField } from './admin/ImageField'
|
||||
|
||||
type PageHeroProps = {
|
||||
title: string
|
||||
lead: string
|
||||
image: string
|
||||
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایشپذیر میشوند */
|
||||
pageKey: string
|
||||
}
|
||||
|
||||
export function PageHero({ title, lead, image }: PageHeroProps) {
|
||||
export function PageHero({ pageKey }: PageHeroProps) {
|
||||
const hero = usePageHero(pageKey)
|
||||
const { t, href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveField = (field: 'title' | 'lead' | 'image', value: string) =>
|
||||
save('page-heroes', hero.id, { [field]: value }, (d) => {
|
||||
const h = d.page_heroes.find((p) => p.id === hero.id)
|
||||
if (h) (h as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
|
||||
<img src={image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
||||
|
||||
{hero.image && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />}
|
||||
|
||||
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
|
||||
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||
{t('home')}
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<span className="text-copper">{title}</span>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
||||
<span className="text-copper">{hero.title}</span>
|
||||
</nav>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-5 max-w-[700px] text-hero font-bold text-ivory"
|
||||
className="mt-5 max-w-[700px]"
|
||||
>
|
||||
{title}
|
||||
<InlineEdit
|
||||
as="span"
|
||||
value={hero.title}
|
||||
onSave={(v) => saveField('title', v)}
|
||||
className="block text-hero font-bold text-ivory"
|
||||
/>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/65"
|
||||
className="mt-5 max-w-2xl"
|
||||
>
|
||||
{lead}
|
||||
<InlineEdit
|
||||
as="span"
|
||||
multiline
|
||||
value={hero.lead}
|
||||
onSave={(v) => saveField('lead', v)}
|
||||
className="block text-base font-light leading-9 text-ivory/65"
|
||||
/>
|
||||
</motion.p>
|
||||
<div className="mt-8 h-px w-28 bg-copper" />
|
||||
|
||||
{/* ویرایش تصویر پسزمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
|
||||
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
|
||||
<SingletonPill
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی این صفحه"
|
||||
select={(d) => d.seo_meta.find((s) => s.page_key === pageKey)}
|
||||
// اگر ردیف سئوی این صفحه هنوز ساخته نشده باشد، همینجا ساخته میشود
|
||||
createDefaults={() => ({ page_key: pageKey, title: '', description: '' })}
|
||||
/>
|
||||
<ImageField label="تصویر پسزمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { BRAND } from '../data/site'
|
||||
import { useSiteData } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری دادهها از دیتابیس استفاده میشود
|
||||
const FALLBACK_LOGO = '/images/logo.png'
|
||||
|
||||
export function Preloader() {
|
||||
const { data } = useSiteData()
|
||||
const { t } = useLang()
|
||||
const [done, setDone] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const logo = data?.brand.logo || FALLBACK_LOGO
|
||||
const name = data?.brand.name || t('brandFallback')
|
||||
|
||||
useEffect(() => {
|
||||
const start = Date.now()
|
||||
const total = 1800
|
||||
@@ -35,17 +44,17 @@ export function Preloader() {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
aria-live="polite"
|
||||
aria-label="در حال بارگذاری"
|
||||
aria-label={t('loading')}
|
||||
>
|
||||
<div className="relative h-36 w-36">
|
||||
<img
|
||||
src={BRAND.logo}
|
||||
src={logo}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-contain opacity-[0.12]"
|
||||
/>
|
||||
<div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(${100 - progress}% 0 0 0)` }}>
|
||||
<img src={BRAND.logo} alt="" aria-hidden="true" className="h-full w-full object-contain" />
|
||||
<img src={logo} alt="" aria-hidden="true" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-x-0 h-px bg-copper"
|
||||
@@ -64,10 +73,10 @@ export function Preloader() {
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mt-4 text-sm font-light tracking-widest text-copper font-pinar"
|
||||
>
|
||||
{BRAND.name}
|
||||
{name}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,58 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { useStatusLabel } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
export function ProjectCard({ project }: any) {
|
||||
const isOngoing = project.status === 'progress'
|
||||
|
||||
const statusText = isOngoing ? 'در حال انجام' : 'انجام شده'
|
||||
|
||||
const statusText = useStatusLabel(project.status)
|
||||
const { t, href, lang } = useLang()
|
||||
|
||||
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
||||
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
||||
|
||||
return (
|
||||
<article className="group flex h-full flex-col overflow-hidden border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
||||
<Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
|
||||
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
||||
<Link to={href(`/projects/${project.slug || '#'}`)} className="flex h-full flex-col focus:outline-none">
|
||||
|
||||
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشهای */}
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden">
|
||||
<img
|
||||
src={project.image}
|
||||
alt={project.title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
<div className={`absolute right-4 top-4 flex items-center gap-2 bg-ink/85 px-4 py-2 text-xs font-light backdrop-blur-md ${textColor}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${dotColor}`} />
|
||||
|
||||
{/* نشانگر وضعیت کپسولی شیشهای */}
|
||||
<div className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
|
||||
{statusText}
|
||||
</div>
|
||||
|
||||
{/* گوی دکمه شیشهای شناور برای ورود به پروژه */}
|
||||
<div className="absolute bottom-4 ltr:right-4 rtl:left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
|
||||
<ArrowLeftIcon className="h-5 w-5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<div className="text-center">
|
||||
<h3 className="font-pinar text-xl font-bold text-ivory">
|
||||
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="mt-7 text-sm font-light text-ivory/60">
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||
{project.location}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="my-7 h-px w-full bg-ivory/10" />
|
||||
<div className="my-6 h-px w-full bg-white/10" />
|
||||
|
||||
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
|
||||
<span>سال: {project.year}</span>
|
||||
<span>{project.area} متر مربع</span>
|
||||
<span>{t('yearLabel')} {project.year}</span>
|
||||
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
||||
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
|
||||
@@ -1,29 +1,58 @@
|
||||
import React from 'react'
|
||||
import { Reveal } from './Reveal'
|
||||
import { useSectionHeading } from '../api'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
|
||||
type SectionHeadingProps = {
|
||||
title: string
|
||||
/** در صورت ارسال، تیتر و توضیح از دیتابیس خوانده و بهصورت درجا ویرایشپذیر میشوند */
|
||||
headingKey?: string
|
||||
title?: string
|
||||
lead?: string
|
||||
align?: 'right' | 'center'
|
||||
tone?: 'copper' | 'ivory'
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
headingKey,
|
||||
title,
|
||||
lead,
|
||||
align = 'right',
|
||||
tone = 'ivory',
|
||||
}: SectionHeadingProps) {
|
||||
const heading = useSectionHeading(headingKey ?? '')
|
||||
const save = useFieldSaver()
|
||||
const isDb = !!headingKey
|
||||
const currentTitle = isDb ? heading.title : (title ?? '')
|
||||
const currentLead = isDb ? heading.lead : (lead ?? '')
|
||||
const isCenter = align === 'center'
|
||||
|
||||
const saveField = (field: 'title' | 'lead', value: string) =>
|
||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) (h as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
|
||||
<h2 className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{lead && <p className="mt-5 text-base font-light leading-9 text-ivory/60">{lead}</p>}
|
||||
|
||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl ltr:text-left rtl:text-right'}>
|
||||
<InlineEdit
|
||||
as="h2"
|
||||
value={currentTitle}
|
||||
onSave={(v) => saveField('title', v)}
|
||||
className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}
|
||||
/>
|
||||
|
||||
{currentLead && (
|
||||
<InlineEdit
|
||||
as="p"
|
||||
multiline
|
||||
value={currentLead}
|
||||
onSave={(v) => saveField('lead', v)}
|
||||
className="mt-5 text-base font-light leading-9 text-ivory/60"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`mt-6 h-px w-24 bg-copper/70 ${isCenter ? 'mx-auto' : ''}`} />
|
||||
</Reveal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { MouseEvent } from 'react'
|
||||
import { ListIcon } from 'lucide-react'
|
||||
import { slugifyFa, uniqueSlug } from '../utils/slug'
|
||||
|
||||
export type TocItem = { id: string; text: string; level: 2 | 3 }
|
||||
|
||||
/**
|
||||
* متن یک تیتر از HTML — همه گرههای تودرتو پیمایش میشوند و <br> به فاصله تبدیل
|
||||
* میشود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
|
||||
*/
|
||||
function headingText(element: Element): string {
|
||||
const parts: string[] = []
|
||||
|
||||
element.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
|
||||
else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const child = node as Element
|
||||
parts.push(child.tagName === 'BR' ? ' ' : headingText(child))
|
||||
}
|
||||
})
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* استخراج فهرست مطالب از HTML ویرایشگر — همه تیترهای H2 و H3 در هر عمقی از سند
|
||||
* (حتی داخل نقلقول) شناسه لنگر میگیرند؛ متن فارسی در شناسه حفظ میشود و تیترهای
|
||||
* تکراری پسوند عددی میگیرند. HTML برگشتی همان است که باید رندر شود.
|
||||
*/
|
||||
export function extractToc(
|
||||
html: string,
|
||||
transform?: (root: HTMLElement) => void,
|
||||
): { items: TocItem[]; html: string } {
|
||||
if (!html || typeof window === 'undefined') return { items: [], html }
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
const used = new Map<string, number>()
|
||||
const items: TocItem[] = []
|
||||
|
||||
doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
|
||||
const text = headingText(element).replace(/\s+/g, ' ').trim()
|
||||
if (!text) return
|
||||
// تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی میگیرد تا لنگرش بینام نماند
|
||||
const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
|
||||
element.setAttribute('id', id)
|
||||
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
|
||||
})
|
||||
|
||||
// تبدیل اختیاری (بستهبندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا میشود
|
||||
transform?.(doc.body)
|
||||
|
||||
return { items, html: doc.body.innerHTML }
|
||||
}
|
||||
|
||||
type TableOfContentsProps = {
|
||||
items: TocItem[]
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* فهرست مطالب کلیکپذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
|
||||
* همه اندازهها و ترازبندیها منطقیاند (start/end) تا در چیدمان راستبهچپ و
|
||||
* چپبهراست — بر اساس جهت مقاله — درست بنشیند.
|
||||
*/
|
||||
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
|
||||
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setActiveId(items[0]?.id ?? '')
|
||||
}, [items])
|
||||
|
||||
useEffect(() => {
|
||||
if (!items.length || typeof IntersectionObserver === 'undefined') return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||
const first = visible[0]?.target.id
|
||||
if (first) setActiveId(first)
|
||||
},
|
||||
{ rootMargin: '-15% 0px -70% 0px', threshold: [0, 1] },
|
||||
)
|
||||
items.forEach((item) => {
|
||||
const el = document.getElementById(item.id)
|
||||
if (el) observer.observe(el)
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}, [items])
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
const go = (event: MouseEvent<HTMLButtonElement>, id: string) => {
|
||||
event.preventDefault()
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
window.history.replaceState(null, '', `#${id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="فهرست مطالب"
|
||||
className={`rounded-2xl border border-white/10 bg-char/60 p-6 backdrop-blur-xl shadow-[0_20px_50px_rgba(0,0,0,0.35),inset_0_1px_1px_rgba(255,255,255,0.08)] ${className}`}
|
||||
>
|
||||
<p className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||
<ListIcon className="h-4 w-4" aria-hidden="true" />
|
||||
{title}
|
||||
</p>
|
||||
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
|
||||
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود میشود تا بریده نشود */}
|
||||
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => go(event, item.id)}
|
||||
aria-current={isActive ? 'location' : undefined}
|
||||
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
|
||||
item.level === 3 ? 'ps-4 text-[13px]' : ''
|
||||
} ${
|
||||
isActive
|
||||
? 'font-normal text-copper'
|
||||
: 'text-ivory/60 hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||
import {
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
BoldIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ImagePlusIcon,
|
||||
ItalicIcon,
|
||||
Link2Icon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
Loader2Icon,
|
||||
PilcrowIcon,
|
||||
QuoteIcon,
|
||||
Redo2Icon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
Undo2Icon,
|
||||
} from 'lucide-react'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { DEFAULT_IMAGE_ALT } from '../../data/constants'
|
||||
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
|
||||
import { ArticleImage } from './ArticleImage'
|
||||
import { BidiTypography, bidiPluginKey } from './BidiTypography'
|
||||
import { EnglishText } from './EnglishText'
|
||||
import { LinkModal } from './LinkModal'
|
||||
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
|
||||
import type { LinkModalValue } from './LinkModal'
|
||||
|
||||
/**
|
||||
* مارکهای قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آنها از نوار ابزار برداشته شده و
|
||||
* فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شدهاند.
|
||||
*/
|
||||
const LegacySmallSizeMark = Mark.create({
|
||||
name: 'smallSize',
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-small]' }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
|
||||
},
|
||||
})
|
||||
|
||||
/** اکستنشنهای مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
|
||||
export const ARTICLE_EXTENSIONS = [
|
||||
StarterKit.configure({
|
||||
link: false,
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
ArticleImage.configure({ allowBase64: false }),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||
isAllowedUri: (uri: string) => isAllowedHref(uri),
|
||||
}),
|
||||
LegacySmallSizeMark,
|
||||
EnglishText,
|
||||
BidiTypography,
|
||||
]
|
||||
|
||||
type ArticleEditorProps = {
|
||||
doc: Record<string, unknown>
|
||||
onChange: (json: Record<string, unknown>) => void
|
||||
/**
|
||||
* شناسه محتوا — با هر تغییر آن، بدنه ویرایشگر از `doc` جاری دوباره بارگذاری میشود
|
||||
* (مثلاً وقتی مقاله از سرور میرسد). بدون این prop محتوا فقط یکبار مقداردهی میشود.
|
||||
*/
|
||||
contentKey?: string | number
|
||||
}
|
||||
|
||||
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمیگرداند */
|
||||
type EditorInstance = NonNullable<ReturnType<typeof useEditor>>
|
||||
|
||||
type TextDirection = 'rtl' | 'ltr'
|
||||
|
||||
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپشده */
|
||||
type LinkApplyMode = 'selection' | 'insert' | 'range'
|
||||
|
||||
type LinkModalState = {
|
||||
open: boolean
|
||||
editing: boolean
|
||||
mode: LinkApplyMode
|
||||
range: { from: number; to: number } | null
|
||||
initial: LinkModalValue
|
||||
}
|
||||
|
||||
const CLOSED_LINK_MODAL: LinkModalState = {
|
||||
open: false,
|
||||
editing: false,
|
||||
mode: 'selection',
|
||||
range: null,
|
||||
initial: { href: '', newTab: true, nofollow: false },
|
||||
}
|
||||
|
||||
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
||||
const URL_LOOKBACK = 300
|
||||
|
||||
function ToolButton({
|
||||
onClick,
|
||||
active = false,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
|
||||
active
|
||||
? 'border-copper/50 bg-copper/15 text-copper'
|
||||
: 'border-transparent text-ivory/60 hover:border-ivory/15 hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
|
||||
|
||||
/**
|
||||
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشندار، مودال لینک سئو-محور،
|
||||
* کلید راستچین/چپچین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
|
||||
*/
|
||||
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
|
||||
const { runSave } = useAdmin()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [direction, setDirection] = useState<TextDirection>('rtl')
|
||||
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: ARTICLE_EXTENSIONS,
|
||||
content: doc && Object.keys(doc).length ? doc : undefined,
|
||||
immediatelyRender: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
dir: 'rtl',
|
||||
lang: 'fa',
|
||||
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
||||
'data-wysiwyg': 'true',
|
||||
},
|
||||
handlePaste: (_view, event, _slice) => {
|
||||
const file = event.clipboardData?.files?.[0]
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
event.preventDefault()
|
||||
void uploadAndInsert(file)
|
||||
return true
|
||||
}
|
||||
// چسباندن یک نشانی → مودال لینک بلافاصله باز میشود
|
||||
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
|
||||
if (isUrlLike(text)) {
|
||||
event.preventDefault()
|
||||
setLinkModal({
|
||||
open: true,
|
||||
editing: false,
|
||||
mode: 'insert',
|
||||
range: null,
|
||||
initial: { href: normalizeHref(text) ?? text, newTab: true, nofollow: false },
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
handleDrop: (_view, event, _slice, moved) => {
|
||||
if (moved) return false
|
||||
const file = event.dataTransfer?.files?.[0]
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
event.preventDefault()
|
||||
void uploadAndInsert(file)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
||||
handleTextInput: (view, from, to, text) => {
|
||||
if (text !== ' ' || from !== to) return false
|
||||
const start = Math.max(0, from - URL_LOOKBACK)
|
||||
const before = view.state.doc.textBetween(start, from, ' ', ' ').trimEnd()
|
||||
const lastToken = before.slice(before.lastIndexOf(' ') + 1)
|
||||
if (!isUrlLike(lastToken)) return false
|
||||
setLinkModal({
|
||||
open: true,
|
||||
editing: false,
|
||||
mode: 'range',
|
||||
range: { from: from - lastToken.length, to: from },
|
||||
initial: { href: normalizeHref(lastToken) ?? lastToken, newTab: true, nofollow: false },
|
||||
})
|
||||
return false
|
||||
},
|
||||
// Enter داخل تیتر، بلوک تازه را پاراگراف میکند تا قالب تیتر سرایت نکند
|
||||
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
||||
})
|
||||
|
||||
// جهت متن فقط روی ناحیه ویرایش اعمال میشود؛ نوار ابزار همیشه راستچین میماند
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.view.dom.setAttribute('dir', direction)
|
||||
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
|
||||
// تزئینهای دوزبانه (اندازه ۲ پیکسل کوچکتر متن لاتین) با جهت تازه بازمحاسبه میشوند
|
||||
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, direction))
|
||||
}, [editor, direction])
|
||||
|
||||
/**
|
||||
* ترکیب (نمونه ویرایشگر، شناسه محتوا) که آخرین بار بارگذاری شده است.
|
||||
* تغییرهای معمول سند (تایپ کاربر) نباید بدنه را از نو بارگذاری کنند.
|
||||
*/
|
||||
const loadedContentRef = useRef<{ editor: EditorInstance; key: string | number } | null>(null)
|
||||
|
||||
// بارگذاری بدنه با تغییر شناسه محتوا (رسیدن مقاله از سرور) یا ساختهشدن نمونه ویرایشگر
|
||||
useEffect(() => {
|
||||
if (!editor || contentKey === undefined) return
|
||||
const loaded = loadedContentRef.current
|
||||
if (loaded && loaded.editor === editor && loaded.key === contentKey) return
|
||||
const incoming = doc && Object.keys(doc).length ? doc : null
|
||||
if (!incoming) return
|
||||
loadedContentRef.current = { editor, key: contentKey }
|
||||
// emitUpdate=false تا بارگذاری، ذخیره خودکارِ بیمورد را فعال نکند
|
||||
editor.commands.setContent(incoming, { emitUpdate: false })
|
||||
}, [editor, contentKey, doc])
|
||||
|
||||
async function uploadAndInsert(file: File) {
|
||||
if (!editor) return
|
||||
setUploading(true)
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
||||
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
|
||||
.run()
|
||||
})
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
|
||||
const openLinkModalForSelection = () => {
|
||||
if (!editor) return
|
||||
const attrs = editor.getAttributes('link') as {
|
||||
href?: string | null
|
||||
target?: string | null
|
||||
rel?: string | null
|
||||
}
|
||||
const editing = Boolean(attrs.href)
|
||||
setLinkModal({
|
||||
open: true,
|
||||
editing,
|
||||
mode: 'selection',
|
||||
range: null,
|
||||
initial: {
|
||||
href: attrs.href ?? '',
|
||||
newTab: editing ? attrs.target === '_blank' : true,
|
||||
nofollow: editing ? parseLinkRel(attrs.rel).nofollow : false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const applyLink = ({ href, newTab, nofollow }: LinkModalValue) => {
|
||||
if (!editor) return
|
||||
const linkAttrs = { href, target: newTab ? '_blank' : null, rel: buildLinkRel({ newTab, nofollow }) }
|
||||
const chain = editor.chain().focus()
|
||||
|
||||
if (linkModal.mode === 'insert') {
|
||||
// نشانی چسباندهشده: متن لینک تازه درج میشود
|
||||
chain.insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: linkAttrs }] }).run()
|
||||
} else if (linkModal.mode === 'range' && linkModal.range) {
|
||||
// نشانی تایپشده: همان محدوده به لینک تبدیل میشود
|
||||
chain.setTextSelection(linkModal.range).extendMarkRange('link').setLink(linkAttrs).run()
|
||||
} else {
|
||||
chain.extendMarkRange('link').setLink(linkAttrs).run()
|
||||
}
|
||||
|
||||
setLinkModal((state) => ({ ...state, open: false }))
|
||||
}
|
||||
|
||||
const removeLink = () => {
|
||||
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
setLinkModal((state) => ({ ...state, open: false }))
|
||||
}
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border border-ivory/15 bg-char" data-dir={direction}>
|
||||
{/* نوار ابزار: همیشه راستچین و در بالای ویرایشگر ثابت میماند */}
|
||||
<div
|
||||
dir="rtl"
|
||||
className="sticky top-14 z-20 flex flex-wrap items-center gap-1 border-b border-ivory/10 bg-char px-2 py-1.5"
|
||||
>
|
||||
<ToolButton
|
||||
label="پاراگراف"
|
||||
active={editor.isActive('paragraph')}
|
||||
onClick={() => applyBlockFormat(editor.view, 'paragraph')}
|
||||
>
|
||||
<PilcrowIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۱"
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
onClick={() => applyBlockFormat(editor.view, 1)}
|
||||
>
|
||||
<Heading1Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۲"
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
onClick={() => applyBlockFormat(editor.view, 2)}
|
||||
>
|
||||
<Heading2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۳"
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
onClick={() => applyBlockFormat(editor.view, 3)}
|
||||
>
|
||||
<Heading3Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* جهت متن — فقط محتوای ویرایشگر را عوض میکند */}
|
||||
<ToolButton
|
||||
label="راستچین (فارسی)"
|
||||
active={direction === 'rtl'}
|
||||
onClick={() => setDirection('rtl')}
|
||||
>
|
||||
<AlignRightIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="چپچین (انگلیسی)"
|
||||
active={direction === 'ltr'}
|
||||
onClick={() => setDirection('ltr')}
|
||||
>
|
||||
<AlignLeftIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||
<BoldIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||
<ItalicIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
||||
<UnderlineIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="خطخورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||
<StrikethroughIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="لیست نقطهای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="لیست شمارهای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||
<ListOrderedIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="نقلقول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||
<QuoteIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="افزودن لینک" active={editor.isActive('link')} onClick={openLinkModalForSelection}>
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
||||
<Undo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
||||
<Redo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
</div>
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
data-editor-file-input
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) void uploadAndInsert(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
||||
{linkModal.open && (
|
||||
<LinkModal
|
||||
initial={linkModal.initial}
|
||||
editing={linkModal.editing}
|
||||
onApply={applyLink}
|
||||
onRemove={removeLink}
|
||||
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Image from '@tiptap/extension-image'
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import { ImageNodeView } from './ImageNodeView'
|
||||
|
||||
/**
|
||||
* تصویر مقاله با کپشن.
|
||||
* خروجی HTML در سایت: <figure><img><figcaption>کپشن</figcaption></figure>
|
||||
* و در ویرایشگر با NodeView سفارشی (ورودی کپشن + دکمه حذف) نمایش داده میشود.
|
||||
*/
|
||||
export const ArticleImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
// ویژگیهای استاندارد تصویر (src/alt/title/width/height) از افزونه پایه
|
||||
...this.parent?.(),
|
||||
caption: {
|
||||
default: '',
|
||||
parseHTML: (element) =>
|
||||
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'figure',
|
||||
priority: 60,
|
||||
getAttrs: (element) => {
|
||||
if (!(element instanceof HTMLElement)) return false
|
||||
const img = element.querySelector('img')
|
||||
if (!img) return false
|
||||
return {
|
||||
src: img.getAttribute('src'),
|
||||
alt: img.getAttribute('alt'),
|
||||
title: img.getAttribute('title'),
|
||||
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||
}
|
||||
},
|
||||
},
|
||||
// تصاویر بدون figure (محتوای قدیمی یا چسباندهشده)
|
||||
{ tag: 'img[src]:not([src^="data:"])' },
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const { caption, ...imageAttributes } = HTMLAttributes
|
||||
const image: [string, Record<string, unknown>] = [
|
||||
'img',
|
||||
mergeAttributes(this.options.HTMLAttributes, imageAttributes),
|
||||
]
|
||||
|
||||
// کپشن خالی → عنصر figcaption اصلاً رندر نمیشود (فقط تصویر میماند)
|
||||
return caption
|
||||
? [
|
||||
'figure',
|
||||
{ class: 'article-figure' },
|
||||
image,
|
||||
['figcaption', { class: 'article-figure__caption' }, caption],
|
||||
]
|
||||
: ['figure', { class: 'article-figure' }, image]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageNodeView)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import { LATIN_CLASS, RTL_CLASS, findScriptRuns } from '../../utils/bidiText'
|
||||
|
||||
/** نام مارکهای قدیمی که خودشان اندازه/جهت متن را تعیین میکنند */
|
||||
const LEGACY_MARKS = new Set(['englishText', 'smallSize'])
|
||||
|
||||
type BidiState = { dir: 'rtl' | 'ltr' }
|
||||
|
||||
/** کلید پلاگین — جهت متن با متای همین کلید بهروز میشود */
|
||||
export const bidiPluginKey = new PluginKey<BidiState>('bidiTypography')
|
||||
|
||||
/**
|
||||
* تزئین دوزبانه متن ویرایشگر — اجرای لاتین (و در متن انگلیسی، اجرای فارسی) با یک
|
||||
* span جهتدار نشانهگذاری میشود تا ویرایشگر هم دقیقاً همان اندازه «۲ پیکسل کوچکتر»
|
||||
* خروجی سایت را نشان دهد. تزئینها سند را تغییر نمیدهند و ذخیره نمیشوند.
|
||||
*/
|
||||
export const BidiTypography = Extension.create({
|
||||
name: 'bidiTypography',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin<BidiState>({
|
||||
key: bidiPluginKey,
|
||||
state: {
|
||||
init: () => ({ dir: 'rtl' }),
|
||||
apply: (tr, value) => {
|
||||
const dir = tr.getMeta(bidiPluginKey) as BidiState['dir'] | undefined
|
||||
return dir && dir !== value.dir ? { dir } : value
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations: (state) =>
|
||||
buildDecorations(state, bidiPluginKey.getState(state)?.dir ?? 'rtl'),
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
function buildDecorations(state: EditorState, dir: 'rtl' | 'ltr'): DecorationSet {
|
||||
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
|
||||
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
|
||||
const decorations: Decoration[] = []
|
||||
const { doc, schema } = state
|
||||
|
||||
doc.descendants((node, pos, parent) => {
|
||||
if (node.type.name === 'codeBlock' || parent?.type.name === 'codeBlock') return false
|
||||
if (!node.isText || !node.text) return
|
||||
if (node.marks.some((mark) => mark.type === schema.marks.code || LEGACY_MARKS.has(mark.type.name))) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const run of findScriptRuns(node.text, dir)) {
|
||||
decorations.push(
|
||||
Decoration.inline(pos + run.from, pos + run.to, { class: className, dir: runDir }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||
|
||||
/** نام مارک متن انگلیسی — ویرایشگر و رندر استاتیک مقاله از همین نام استفاده میکنند */
|
||||
export const ENGLISH_TEXT_MARK = 'englishText'
|
||||
|
||||
/**
|
||||
* مارک «متن انگلیسی» — متن انتخابشده را داخل
|
||||
* <span dir="ltr" class="english-text"> میپیچد تا ۱۲ پیکسل و چپبهراست
|
||||
* نمایش داده شود، بدون اینکه جهت متن فارسی اطرافش بههم بخورد.
|
||||
*/
|
||||
export const EnglishText = Mark.create({
|
||||
name: ENGLISH_TEXT_MARK,
|
||||
inclusive: false,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span.english-text' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, { dir: 'ltr', class: 'english-text' }),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||
import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
import { useSmartTextField } from './useSmartTextField'
|
||||
|
||||
type ImageFieldProps = {
|
||||
label: string
|
||||
value: string
|
||||
onSave: (url: string) => Promise<boolean>
|
||||
/** متن جایگزین (Alt) */
|
||||
alt?: string
|
||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||
/** عنوان تصویر (Title) */
|
||||
title?: string
|
||||
onSaveTitle?: (title: string) => Promise<boolean>
|
||||
/**
|
||||
* پیشنهاد Alt وقتی کاربر چیزی ننوشته — مثل «{عنوان مقاله} - تصویر شاخص».
|
||||
* هرگز از URL یا نام فایل ساخته نمیشود.
|
||||
*/
|
||||
altFallback?: string
|
||||
/** پیشنهاد عنوان تصویر — پیشفرض «Photo 1» */
|
||||
titleFallback?: string
|
||||
}
|
||||
|
||||
const META_FIELD_CLASS =
|
||||
'mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||
|
||||
/**
|
||||
* انتخاب و آپلود تصویر به MinIO با پیشنمایش بزرگ.
|
||||
* ترتیب فیلدها: ۱) عنوان تصویر (پیشفرض «Photo 1») ۲) متن جایگزین (پیشفرض «{عنوان} - تصویر شاخص»).
|
||||
* هر دو «دستی یا پیشفرض» هستند و Alt هرگز از URL ساخته نمیشود.
|
||||
* پیشفرض خودکار با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمیگردد.
|
||||
*/
|
||||
export function ImageField({
|
||||
label,
|
||||
value,
|
||||
onSave,
|
||||
alt,
|
||||
onSaveAlt,
|
||||
title,
|
||||
onSaveTitle,
|
||||
altFallback = '',
|
||||
titleFallback = '',
|
||||
}: ImageFieldProps) {
|
||||
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const titleId = useId()
|
||||
const altId = useId()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const showTitle = title !== undefined || !!onSaveTitle
|
||||
const showAlt = alt !== undefined || !!onSaveAlt
|
||||
|
||||
const titleField = useSmartTextField({
|
||||
value: title ?? '',
|
||||
fallback: titleFallback,
|
||||
hardFallback: DEFAULT_IMAGE_TITLE,
|
||||
onCommit: onSaveTitle,
|
||||
})
|
||||
const altField = useSmartTextField({
|
||||
value: alt ?? '',
|
||||
fallback: altFallback,
|
||||
hardFallback: label,
|
||||
onCommit: onSaveAlt,
|
||||
})
|
||||
|
||||
if (!isAuthenticated || !isEditMode) return null
|
||||
|
||||
const pick = async (file: File | undefined) => {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
try {
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
const ok = await onSave(url)
|
||||
if (!ok) throw new Error('ذخیره نشد')
|
||||
// پیشفرضهای Alt/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
||||
if (showTitle) await titleField.commit()
|
||||
if (showAlt) await altField.commit()
|
||||
})
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||
|
||||
{/* پیشنمایش بزرگ تصویر */}
|
||||
<div className="mt-2 overflow-hidden border border-ivory/15 bg-char">
|
||||
{value ? (
|
||||
<img
|
||||
src={value}
|
||||
alt={altField.display || label}
|
||||
title={titleField.display || undefined}
|
||||
className="h-[27rem] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[27rem] w-full flex-col items-center justify-center gap-2 border border-dashed border-ivory/20 text-ivory/30">
|
||||
<ImageIcon className="h-7 w-7" aria-hidden="true" />
|
||||
<span className="text-[11px] font-light">تصویری انتخاب نشده است</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(showTitle || showAlt) && (
|
||||
<div className="mt-4 grid max-w-md gap-4 border-t border-ivory/10 pt-4 sm:grid-cols-2">
|
||||
{showTitle && (
|
||||
<div>
|
||||
<label htmlFor={titleId} className="text-[11px] font-light text-ivory/50">
|
||||
عنوان تصویر (Title)
|
||||
</label>
|
||||
<input
|
||||
id={titleId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={titleField.display}
|
||||
onChange={(event) => titleField.onChange(event.target.value)}
|
||||
onFocus={titleField.onFocus}
|
||||
onBlur={titleField.onBlur}
|
||||
placeholder={titleFallback.trim() || DEFAULT_IMAGE_TITLE}
|
||||
className={META_FIELD_CLASS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAlt && (
|
||||
<div>
|
||||
<label htmlFor={altId} className="text-[11px] font-light text-ivory/50">
|
||||
متن جایگزین (Alt)
|
||||
</label>
|
||||
<input
|
||||
id={altId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={altField.display}
|
||||
onChange={(event) => altField.onChange(event.target.value)}
|
||||
onFocus={altField.onFocus}
|
||||
onBlur={altField.onBlur}
|
||||
placeholder={
|
||||
altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحهخوانها'
|
||||
}
|
||||
className={META_FIELD_CLASS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void pick(event.target.files?.[0])
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { NodeViewWrapper } from '@tiptap/react'
|
||||
import type { NodeViewProps } from '@tiptap/react'
|
||||
import { PlusIcon, XIcon } from 'lucide-react'
|
||||
|
||||
type ImageAttrs = {
|
||||
src: string
|
||||
alt: string | null
|
||||
title: string | null
|
||||
caption: string | null
|
||||
}
|
||||
|
||||
const DELETE_BUTTON_CLASS =
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center border border-red-400/50 bg-ink/85 text-red-300 backdrop-blur-sm transition-colors duration-150 hover:bg-red-500/20'
|
||||
|
||||
const INLINE_FIELD_CLASS =
|
||||
'w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||
|
||||
const ADD_CAPTION_CLASS =
|
||||
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
|
||||
|
||||
/**
|
||||
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
|
||||
* <figure><img><figcaption>کپشن</figcaption></figure>
|
||||
*
|
||||
* ویرایش کاملاً درونخطی است (بدون پاپاور/مودال):
|
||||
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره میشود.
|
||||
* - دکمه X کپشن را کامل حذف میکند: صفت کپشن به مقدار پیشفرض برمیگردد و عنصر
|
||||
* figcaption با ورودیاش از DOM ویرایشگر (و از خروجی ذخیرهشده) حذف میشود؛
|
||||
* فقط تصویر میماند و «افزودن کپشن» دوباره جعبه را باز میکند.
|
||||
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت میشود.
|
||||
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
||||
*/
|
||||
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
||||
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
||||
const hasCaption = Boolean((caption ?? '').trim())
|
||||
const [captionOpen, setCaptionOpen] = useState(true)
|
||||
const captionInputRef = useRef<HTMLInputElement>(null)
|
||||
const focusOnOpen = useRef(false)
|
||||
const showCaption = captionOpen || hasCaption
|
||||
|
||||
// فقط وقتی کاربر «افزودن کپشن» را میزند ورودی فوکوس میگیرد (نه هنگام بارگذاری مقاله)
|
||||
useEffect(() => {
|
||||
if (!focusOnOpen.current) return
|
||||
focusOnOpen.current = false
|
||||
captionInputRef.current?.focus()
|
||||
}, [captionOpen])
|
||||
|
||||
/** حذف کامل کپشن: عنصر figcaption و ورودیاش از DOM ویرایشگر بیرون میرود */
|
||||
const removeCaption = () => {
|
||||
updateAttributes({ caption: '' })
|
||||
setCaptionOpen(false)
|
||||
}
|
||||
|
||||
const addCaption = () => {
|
||||
focusOnOpen.current = true
|
||||
setCaptionOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
|
||||
data-drag-handle
|
||||
>
|
||||
<figure className="article-figure relative">
|
||||
<img src={src} alt={alt ?? ''} title={title ?? ''} draggable={false} />
|
||||
|
||||
{/* حذف کل تصویر */}
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => deleteNode()}
|
||||
aria-label="حذف تصویر"
|
||||
title="حذف تصویر"
|
||||
className={`absolute left-2 top-2 z-10 ${DELETE_BUTTON_CLASS}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{showCaption ? (
|
||||
<figcaption className="article-figure__caption" contentEditable={false}>
|
||||
<input
|
||||
ref={captionInputRef}
|
||||
type="text"
|
||||
value={caption ?? ''}
|
||||
onChange={(event) => updateAttributes({ caption: event.target.value })}
|
||||
placeholder="توضیح تصویر (کپشن)…"
|
||||
aria-label="کپشن تصویر"
|
||||
className={INLINE_FIELD_CLASS}
|
||||
/>
|
||||
{/* حذف کامل کپشن — تصویر دستنخورده میماند */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeCaption}
|
||||
aria-label="حذف کپشن"
|
||||
title="حذف کپشن (تصویر حذف نمیشود)"
|
||||
className={DELETE_BUTTON_CLASS}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</figcaption>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={addCaption}
|
||||
className={ADD_CAPTION_CLASS}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" aria-hidden="true" />
|
||||
افزودن کپشن
|
||||
</button>
|
||||
)}
|
||||
</figure>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ElementType, KeyboardEvent } from 'react'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
|
||||
type InlineEditProps = {
|
||||
value: string
|
||||
/** ذخیره async — در صورت خطا، متن به مقدار قبلی برمیگردد */
|
||||
onSave: (value: string) => Promise<boolean> | boolean | void
|
||||
as?: ElementType
|
||||
/** چندخطی: Enter خط جدید میگذارد و فقط blur ذخیره میکند */
|
||||
multiline?: boolean
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* ویرایش درجا متن — در حالت عادی دقیقاً همان HTML قبلی را رندر میکند
|
||||
* (بدون هیچ تغییر چیدمان) و فقط در Edit Mode برای ادمین قابل ویرایش میشود.
|
||||
*/
|
||||
export function InlineEdit({
|
||||
value,
|
||||
onSave,
|
||||
as: Tag = 'span',
|
||||
multiline = false,
|
||||
className = '',
|
||||
style,
|
||||
...rest
|
||||
}: InlineEditProps) {
|
||||
const { isAuthenticated, isEditMode } = useAdmin()
|
||||
const editing = isAuthenticated && isEditMode
|
||||
const ref = useRef<HTMLElement>(null)
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
// همگامسازی متن فقط وقتی المان در حال ویرایش نیست (پرش نشانگر رخ ندهد)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (el && !focused && el.innerText !== value) el.innerText = value
|
||||
}, [value, focused])
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<Tag className={className} style={style} {...rest}>
|
||||
{value}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
const commit = async () => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
setFocused(false)
|
||||
const next = el.innerText.replace(/\u00a0/g, ' ')
|
||||
if (next === value) return
|
||||
el.innerText = value // بازگشت فوری به مقدار فعلی؛ در صورت موفقیت، بهروزرسانی خوشبینانه متن تازه میآید
|
||||
await onSave(next)
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
||||
if (!multiline && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
;(e.currentTarget as HTMLElement).blur()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (ref.current) ref.current.innerText = value
|
||||
;(e.currentTarget as HTMLElement).blur()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag
|
||||
ref={ref as never}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
data-inline-edit
|
||||
aria-label={rest['aria-label']}
|
||||
className={`${className} cursor-text rounded-sm transition-[outline-color,background-color] duration-150 outline-1 outline-dashed outline-transparent hover:bg-copper/5 hover:outline-copper/40 focus:bg-copper/5 focus:outline-copper focus:outline-solid`}
|
||||
style={style}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={commit}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{value}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import DatePicker from 'react-multi-date-picker'
|
||||
import type { DateObject } from 'react-multi-date-picker'
|
||||
import persian from 'react-date-object/calendars/persian'
|
||||
import persian_fa from 'react-date-object/locales/persian_fa'
|
||||
import { toISODate, toJalaliDateObject } from '../../utils/date'
|
||||
|
||||
type JalaliDatePickerProps = {
|
||||
/** مقدار ذخیرهشده — ISO میلادی (YYYY-MM-DD) */
|
||||
value: string
|
||||
/** تغییر مقدار — همیشه ISO میلادی برمیگرداند تا ذخیرهسازی یکدست بماند */
|
||||
onChange: (isoDate: string) => void
|
||||
id?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخابگر تاریخ شمسی (react-multi-date-picker) با تقویم و locale فارسی.
|
||||
* نمایش جلالی است اما مقدار ذخیرهشده ISO میلادی میماند.
|
||||
*/
|
||||
export function JalaliDatePicker({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
placeholder = 'انتخاب تاریخ',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: JalaliDatePickerProps) {
|
||||
return (
|
||||
<DatePicker
|
||||
id={id}
|
||||
value={toJalaliDateObject(value)}
|
||||
onChange={(selected) => {
|
||||
const picked = Array.isArray(selected) ? selected[0] : selected
|
||||
if (!picked) return
|
||||
onChange(toISODate(picked as DateObject))
|
||||
}}
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
format="YYYY/MM/DD"
|
||||
calendarPosition="bottom-right"
|
||||
className={`aramesh-datepicker ${className}`}
|
||||
containerClassName="w-full"
|
||||
inputClass="w-full cursor-pointer border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
mobileLabels={{ OK: 'تأیید', CANCEL: 'انصراف' }}
|
||||
portal
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link2Icon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import { buildLinkRel, normalizeHref } from '../../utils/link'
|
||||
|
||||
export type LinkModalValue = {
|
||||
href: string
|
||||
newTab: boolean
|
||||
nofollow: boolean
|
||||
}
|
||||
|
||||
type LinkModalProps = {
|
||||
/** مقادیر اولیه — از لینک فعال زیر نشانگر یا خالی برای لینک تازه */
|
||||
initial: LinkModalValue
|
||||
/** ویرایش لینک موجود (دکمه «حذف لینک» نمایش داده میشود) */
|
||||
editing: boolean
|
||||
onApply: (value: LinkModalValue) => void
|
||||
onRemove: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FIELD_CLASS =
|
||||
'mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||
|
||||
/** مدت انیمیشن ورود/خروج — با duration-300 کلاسها هماهنگ است */
|
||||
const TRANSITION_MS = 300
|
||||
|
||||
/**
|
||||
* مودال ثبت و ویرایش لینک — انیمیشن نرم fade و scale با کلاسهای Tailwind.
|
||||
* ورود با یک فریم تاخیر (تا کلاسهای بسته اعمال شوند) و خروج پیش از بستن مودال اجرا میشود.
|
||||
* جایگزین پنجره پیشفرض مرورگر برای لینک شده است.
|
||||
*/
|
||||
export function LinkModal({ initial, editing, onApply, onRemove, onClose }: LinkModalProps) {
|
||||
const [href, setHref] = useState(initial.href)
|
||||
const [newTab, setNewTab] = useState(initial.newTab)
|
||||
const [nofollow, setNofollow] = useState(initial.nofollow)
|
||||
const [error, setError] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
// انیمیشن ورود: ابتدا حالت بسته رندر میشود، سپس به حالت باز میرود
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => setVisible(true))
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [])
|
||||
|
||||
/** بستن نرم: اول انیمیشن خروج کامل میشود، بعد عملیات و unmount */
|
||||
const closeWith = (action?: () => void) => {
|
||||
if (closing) return
|
||||
setClosing(true)
|
||||
setVisible(false)
|
||||
window.setTimeout(() => {
|
||||
action?.()
|
||||
onClose()
|
||||
}, TRANSITION_MS)
|
||||
}
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const normalized = normalizeHref(href)
|
||||
if (!normalized) {
|
||||
setError('نشانی معتبر نیست — نمونه: https://example.com یا /contact')
|
||||
return
|
||||
}
|
||||
closeWith(() => onApply({ href: normalized, newTab, nofollow }))
|
||||
}
|
||||
|
||||
const relPreview = buildLinkRel({ newTab, nofollow })
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[97] flex items-center justify-center p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="ثبت لینک"
|
||||
>
|
||||
{/* پسزمینه تار — fade نرم */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-char/70 backdrop-blur-sm transition-opacity duration-300 ease-out ${
|
||||
visible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onClick={() => closeWith()}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* کارت مودال — fade + scale نرم */}
|
||||
<form
|
||||
onSubmit={submit}
|
||||
dir="rtl"
|
||||
className={`relative w-full max-w-md border border-ivory/10 bg-ink p-7 shadow-2xl transition-all duration-300 ease-out ${
|
||||
visible ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-95 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||
<Link2Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{editing ? 'ویرایش لینک' : 'افزودن لینک'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith()}
|
||||
aria-label="بستن"
|
||||
className="text-ivory/50 transition-colors hover:text-ivory"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* نشانی */}
|
||||
<label htmlFor="link-href" className="mt-6 block text-xs font-light text-ivory/60">
|
||||
نشانی پیوند (URL)
|
||||
</label>
|
||||
<input
|
||||
id="link-href"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
autoFocus
|
||||
value={href}
|
||||
onChange={(event) => {
|
||||
setHref(event.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className={FIELD_CLASS}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 border-r-2 border-red-400/70 pr-3 text-[11px] font-light leading-6 text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* باز شدن در تب جدید */}
|
||||
<label className="mt-5 flex cursor-pointer items-center gap-3 text-sm font-light text-ivory/80">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newTab}
|
||||
onChange={(event) => setNewTab(event.target.checked)}
|
||||
className="h-4 w-4 accent-copper"
|
||||
/>
|
||||
باز شدن در تب جدید
|
||||
<span className="text-[10px] font-light text-ivory/35" dir="ltr">target="_blank"</span>
|
||||
</label>
|
||||
|
||||
{/* نوع لینک از نظر سئو */}
|
||||
<fieldset className="mt-5">
|
||||
<legend className="text-xs font-light text-ivory/60">انتقال اعتبار سئو (rel)</legend>
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
{[
|
||||
{ key: 'dofollow', label: 'Dofollow', hint: 'اعتبار سئو به مقصد منتقل میشود', value: false },
|
||||
{ key: 'nofollow', label: 'Nofollow', hint: 'گوگل این لینک را دنبال نکند', value: true },
|
||||
].map((option) => (
|
||||
<label
|
||||
key={option.key}
|
||||
className={`flex cursor-pointer items-start gap-3 border px-4 py-3 transition-colors duration-150 ${
|
||||
nofollow === option.value
|
||||
? 'border-copper/60 bg-copper/10'
|
||||
: 'border-ivory/12 hover:border-ivory/25'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="link-rel"
|
||||
checked={nofollow === option.value}
|
||||
onChange={() => setNofollow(option.value)}
|
||||
className="mt-0.5 h-4 w-4 accent-copper"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-light text-ivory">{option.label}</span>
|
||||
<span className="text-[11px] font-light text-ivory/45">{option.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{relPreview && (
|
||||
<p className="mt-4 text-[10px] font-light text-ivory/35" dir="ltr">
|
||||
rel="{relPreview}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* دکمهها */}
|
||||
<div className="mt-7 flex items-center justify-between gap-3">
|
||||
{editing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith(onRemove)}
|
||||
className="flex items-center gap-1.5 border border-red-400/40 px-4 py-2.5 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
حذف لینک
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith()}
|
||||
className="border border-ivory/20 px-5 py-2.5 text-xs font-light text-ivory/70 transition-colors hover:border-ivory/40 hover:text-ivory"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-copper px-6 py-2.5 text-xs font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
{editing ? 'ذخیره لینک' : 'افزودن لینک'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Select from 'react-select'
|
||||
import CreatableSelect from 'react-select/creatable'
|
||||
import type { ClassNamesConfig, GroupBase, MultiValueRemoveProps } from 'react-select'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
export type LuxeOption = { label: string; value: string }
|
||||
|
||||
type LuxeClassNames<IsMulti extends boolean> = ClassNamesConfig<
|
||||
LuxeOption,
|
||||
IsMulti,
|
||||
GroupBase<LuxeOption>
|
||||
>
|
||||
|
||||
const CONTROL_CLASS_NAMES = (isFocused: boolean): string =>
|
||||
`!min-h-[42px] !rounded-none !border !bg-char !transition-colors ${
|
||||
isFocused
|
||||
? '!border-copper !shadow-[0_0_0_1px_rgba(213,135,76,0.35)]'
|
||||
: '!border-ivory/15 !shadow-none hover:!border-copper/50'
|
||||
}`
|
||||
|
||||
const OPTION_CLASS_NAMES = (isSelected: boolean, isFocused: boolean): string =>
|
||||
`!cursor-pointer !rounded-none !text-[13px] ${
|
||||
isSelected
|
||||
? '!bg-copper/25 !font-bold !text-copper'
|
||||
: isFocused
|
||||
? '!bg-copper/15 !text-ivory'
|
||||
: '!bg-transparent !text-ivory/85'
|
||||
}`
|
||||
|
||||
/**
|
||||
* کلاسهای Tailwind برای همه اسلاتهای react-select — تم تیره پروژه.
|
||||
* react-select برای هر اسلات تابع میخواهد، پس همه با تابع برگردانده میشوند.
|
||||
*/
|
||||
function makeClassNames<IsMulti extends boolean>(
|
||||
extras?: Partial<LuxeClassNames<IsMulti>>,
|
||||
): LuxeClassNames<IsMulti> {
|
||||
return {
|
||||
control: (state) => CONTROL_CLASS_NAMES(state.isFocused),
|
||||
option: (state) => OPTION_CLASS_NAMES(state.isSelected, state.isFocused),
|
||||
valueContainer: () => '!gap-1 !px-3 !py-0.5',
|
||||
placeholder: () => '!text-[13px] !text-ivory/35',
|
||||
singleValue: () => '!text-[13px] !text-ivory',
|
||||
input: () => '!text-[13px] !text-ivory',
|
||||
indicatorsContainer: () => '!text-ivory/40',
|
||||
dropdownIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||
clearIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||
indicatorSeparator: () => '!bg-ivory/15',
|
||||
menu: () => '!mt-1 !rounded-none !border !border-ivory/10 !bg-char !shadow-lift',
|
||||
menuList: () => '!p-1',
|
||||
noOptionsMessage: () => '!text-xs !text-ivory/45',
|
||||
loadingMessage: () => '!text-xs !text-ivory/45',
|
||||
multiValue: () => '!m-0.5 !rounded-full !border !border-copper/40 !bg-copper/15 !py-0.5 !pe-0 !ps-2',
|
||||
multiValueLabel: () => '!text-xs !text-copper',
|
||||
...extras,
|
||||
}
|
||||
}
|
||||
|
||||
/** دکمه ضربدر حذف برچسب انتخابشده — همیشه واضح و قابل کلیک */
|
||||
function TagRemoveButton({ innerProps }: MultiValueRemoveProps<LuxeOption, true>) {
|
||||
return (
|
||||
<div
|
||||
{...innerProps}
|
||||
role="button"
|
||||
aria-label="حذف برچسب"
|
||||
title="حذف برچسب"
|
||||
className="mx-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-copper transition-colors duration-150 hover:bg-copper hover:text-char"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const portalTarget = (): HTMLElement | undefined =>
|
||||
typeof document === 'undefined' ? undefined : document.body
|
||||
|
||||
const uniqueOptions = (values: string[]): LuxeOption[] =>
|
||||
[...new Set(values.filter(Boolean))].map((item) => ({ label: item, value: item }))
|
||||
|
||||
type LuxeSelectProps = {
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
isClearable?: boolean
|
||||
isDisabled?: boolean
|
||||
inputId?: string
|
||||
}
|
||||
|
||||
/** react-select تکی با تم تیره — گزینه فعلی حتی اگر در لیست نباشد حفظ میشود */
|
||||
export function LuxeSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder = 'انتخاب کنید…',
|
||||
isClearable = true,
|
||||
isDisabled = false,
|
||||
inputId,
|
||||
}: LuxeSelectProps) {
|
||||
const selectOptions = useMemo(
|
||||
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||
[options, value],
|
||||
)
|
||||
|
||||
return (
|
||||
<Select<LuxeOption, false>
|
||||
inputId={inputId}
|
||||
classNamePrefix="luxe"
|
||||
options={selectOptions}
|
||||
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||
onChange={(option) => onChange(option?.value ?? '')}
|
||||
placeholder={placeholder}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
classNames={makeClassNames<false>()}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type LuxeCreatableSelectProps = {
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
inputId?: string
|
||||
/** متن گزینه «ساخت مقدار جدید» در فهرست بازشو */
|
||||
createLabel?: (input: string) => string
|
||||
}
|
||||
|
||||
/** react-select تکی و قابل ساخت — کاربر با کلیک روی گزینه ساخت، مقدار تازه میسازد */
|
||||
export function LuxeCreatableSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder = 'انتخاب یا نوشتن مورد جدید…',
|
||||
inputId,
|
||||
createLabel = (input) => `افزودن «${input}»`,
|
||||
}: LuxeCreatableSelectProps) {
|
||||
const selectOptions = useMemo(
|
||||
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||
[options, value],
|
||||
)
|
||||
|
||||
return (
|
||||
<CreatableSelect<LuxeOption, false>
|
||||
inputId={inputId}
|
||||
classNamePrefix="luxe"
|
||||
options={selectOptions}
|
||||
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||
onChange={(option) => onChange(option?.value?.trim() ?? '')}
|
||||
placeholder={placeholder}
|
||||
formatCreateLabel={(input) => createLabel(input)}
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
classNames={makeClassNames<false>()}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
isClearable
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type CategoryOption = { label: string; value: string }
|
||||
|
||||
/** کلید نگهداری دستههای ساختهشده در مرورگر */
|
||||
const CREATED_CATEGORIES_KEY = 'aramesh_article_categories'
|
||||
|
||||
/** دستههایی که ادمین ساخته است — برای مقالههای بعدی هم در فهرست میمانند */
|
||||
function readCreatedCategories(): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CREATED_CATEGORIES_KEY)
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : []
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === 'string')
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function storeCreatedCategory(category: string): void {
|
||||
try {
|
||||
const current = readCreatedCategories()
|
||||
if (current.includes(category)) return
|
||||
window.localStorage.setItem(CREATED_CATEGORIES_KEY, JSON.stringify([...current, category]))
|
||||
} catch {
|
||||
/* حافظه مرورگر در دسترس نیست — بیصدا رد شو */
|
||||
}
|
||||
}
|
||||
|
||||
type CategorySelectProps = {
|
||||
/** مقدار انتخابشده — یک شیء {value,label} یا null */
|
||||
value: CategoryOption | null
|
||||
/** تغییر انتخاب — null یعنی فقط انتخاب پاک شده است (دکمه X) */
|
||||
onChange: (option: CategoryOption | null) => void
|
||||
/** دستههای پایه */
|
||||
options: string[]
|
||||
/** متن گزینه «ساخت دسته جدید» در فهرست بازشو */
|
||||
createLabel?: (input: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخابگر تکمقداری دستهبندی (react-select Creatable).
|
||||
* - isMulti=false و isClearable: دکمه X فقط انتخاب جاری را پاک میکند و گزینهها دستنخورده میمانند.
|
||||
* - ساخت دسته جدید: به انتهای فهرست گزینهها اضافه و برای مقاله جاری انتخاب میشود و
|
||||
* در همین مرورگر برای مقالههای بعدی نگه داشته میشود.
|
||||
*/
|
||||
export function CategorySelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
createLabel = (input) => `ایجاد دسته جدید: ${input}`,
|
||||
}: CategorySelectProps) {
|
||||
const [createdOptions, setCreatedOptions] = useState<string[]>(() => readCreatedCategories())
|
||||
|
||||
// گزینههای پایه + دستههای ساختهشده (همیشه در انتهای فهرست)
|
||||
const selectOptions = useMemo(
|
||||
() => uniqueOptions([...options, ...createdOptions.filter((item) => !options.includes(item))]),
|
||||
[options, createdOptions],
|
||||
)
|
||||
|
||||
/** ساخت دسته جدید: افزودن به انتهای فهرست + انتخاب خودکار برای مقاله جاری */
|
||||
const handleCreate = (input: string) => {
|
||||
const category = input.trim()
|
||||
if (!category) return
|
||||
setCreatedOptions((previous) =>
|
||||
previous.includes(category) ? previous : [...previous, category],
|
||||
)
|
||||
storeCreatedCategory(category)
|
||||
onChange({ label: category, value: category })
|
||||
}
|
||||
|
||||
return (
|
||||
<CreatableSelect<CategoryOption, false>
|
||||
classNamePrefix="luxe"
|
||||
isMulti={false}
|
||||
isClearable
|
||||
options={selectOptions}
|
||||
value={value}
|
||||
onChange={(option) => onChange(option ? { label: option.label, value: option.value } : null)}
|
||||
onCreateOption={handleCreate}
|
||||
formatCreateLabel={(input) => createLabel(input)}
|
||||
placeholder="انتخاب یا نوشتن دسته جدید…"
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
classNames={makeClassNames<false>()}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type LuxeTagsSelectProps = {
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
placeholder?: string
|
||||
}/** هشتگها با react-select قابل ساخت — هر برچسب دکمه ضربدر حذف مستقل دارد */
|
||||
export function LuxeTagsSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'هشتگ بنویسید و Enter بزنید…',
|
||||
}: LuxeTagsSelectProps) {
|
||||
const options = useMemo(() => uniqueOptions(value), [value])
|
||||
|
||||
/** پاکسازی و حذف تکراریها هنگام افزودن یا حذف برچسب */
|
||||
const applyTags = (next: readonly LuxeOption[]) => {
|
||||
const tags = next.map((option) => option.value.trim()).filter(Boolean)
|
||||
onChange([...new Set(tags)])
|
||||
}
|
||||
|
||||
return (
|
||||
<CreatableSelect<LuxeOption, true>
|
||||
classNamePrefix="luxe"
|
||||
isMulti
|
||||
options={options}
|
||||
value={options}
|
||||
onChange={applyTags}
|
||||
placeholder={placeholder}
|
||||
formatCreateLabel={(input) => `افزودن هشتگ «${input}»`}
|
||||
noOptionsMessage={() => 'هشتگی یافت نشد'}
|
||||
closeMenuOnSelect={false}
|
||||
components={{ MultiValueRemove: TagRemoveButton }}
|
||||
classNames={makeClassNames<true>({ multiValueRemove: () => '!bg-transparent' })}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useMemo } from 'react'
|
||||
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
||||
import type { ArticleBodyDoc } from '../../data/site'
|
||||
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
|
||||
|
||||
export type SeoInput = {
|
||||
title: string
|
||||
slug: string
|
||||
excerpt: string
|
||||
body: ArticleBodyDoc | string[]
|
||||
keyphrase: string
|
||||
}
|
||||
|
||||
type Status = 'good' | 'ok' | 'bad'
|
||||
type Check = { label: string; status: Status; hint: string }
|
||||
|
||||
// ---------- محاسبه بررسیها ----------
|
||||
|
||||
export function useSeoChecks(input: SeoInput): Check[] {
|
||||
return useMemo(() => {
|
||||
const text = docToText(input.body)
|
||||
const words = text.split(/\s+/).filter(Boolean)
|
||||
const kp = input.keyphrase.trim()
|
||||
const has = (s: string, needle: string) => s.includes(needle)
|
||||
const checks: Check[] = []
|
||||
|
||||
// عنوان
|
||||
const tLen = input.title.trim().length
|
||||
if (tLen === 0) checks.push({ label: 'عنوان مقاله', status: 'bad', hint: 'برای مقاله عنوان بنویسید.' })
|
||||
else if (tLen >= 20 && tLen <= 60) checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'good', hint: 'طول عنوان مناسب است.' })
|
||||
else if (tLen >= 10 && tLen <= 70) checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'ok', hint: 'طول پیشنهادی عنوان بین ۲۰ تا ۶۰ نویسه است.' })
|
||||
else checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'bad', hint: 'طول عنوان خارج از بازه ۱۰ تا ۷۰ نویسه است.' })
|
||||
|
||||
// کلمه کلیدی هدف
|
||||
if (!kp) {
|
||||
checks.push({ label: 'کلمه کلیدی هدف', status: 'ok', hint: 'کلمه کلیدی هدف را بنویسید تا حضور آن در متن بررسی شود.' })
|
||||
} else {
|
||||
checks.push(
|
||||
has(input.title, kp)
|
||||
? { label: 'کلمه کلیدی در عنوان', status: 'good', hint: 'کلمه کلیدی در عنوان هست.' }
|
||||
: { label: 'کلمه کلیدی در عنوان', status: 'bad', hint: `کلمه «${kp}» در عنوان نیست.` },
|
||||
)
|
||||
const firstP = firstParagraph(input.body)
|
||||
checks.push(
|
||||
has(firstP, kp)
|
||||
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
|
||||
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
|
||||
)
|
||||
const h2s = docHeadings(input.body, 2)
|
||||
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
|
||||
else
|
||||
checks.push(
|
||||
h2s.some((h) => has(h, kp))
|
||||
? { label: 'کلمه کلیدی در تیترها', status: 'good', hint: 'کلمه کلیدی در یکی از تیترها هست.' }
|
||||
: { label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'کلمه کلیدی در هیچ تیتر H2 نیست.' },
|
||||
)
|
||||
}
|
||||
|
||||
// چکیده (توضیح متا)
|
||||
const eLen = input.excerpt.trim().length
|
||||
if (eLen === 0) checks.push({ label: 'چکیده (توضیح متا)', status: 'bad', hint: 'چکیده بنویسید — روی کارت مقاله و توضیح متای گوگل نمایش داده میشود.' })
|
||||
else if (eLen >= 70 && eLen <= 160) checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'good', hint: 'طول چکیده برای توضیح متا ایدهآل است.' })
|
||||
else if (eLen >= 40 && eLen <= 200) checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'ok', hint: 'طول پیشنهادی چکیده بین ۷۰ تا ۱۶۰ نویسه است.' })
|
||||
else checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'bad', hint: 'چکیده خیلی کوتاه یا بلندتر از ۲۰۰ نویسه است.' })
|
||||
|
||||
// طول متن
|
||||
if (words.length >= 150) checks.push({ label: `طول متن (${words.length} واژه)`, status: 'good', hint: 'طول متن برای محتوای ارزشمند کافی است.' })
|
||||
else if (words.length >= 80) checks.push({ label: `طول متن (${words.length} واژه)`, status: 'ok', hint: 'متن کمی کوتاه است — حداقل ۱۵۰ واژه پیشنهاد میشود.' })
|
||||
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
|
||||
|
||||
// تیتر H2
|
||||
const h2s = docHeadings(input.body, 2)
|
||||
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
|
||||
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخشبندی کنید.' })
|
||||
|
||||
// تصاویر
|
||||
const imgs = docImages(input.body)
|
||||
if (imgs.length === 0)
|
||||
checks.push({ label: 'تصاویر', status: 'ok', hint: 'تصویری داخل متن نیست — افزودن تصویر به جذابیت مقاله کمک میکند.' })
|
||||
else if (imgs.every((i) => i.alt.trim()))
|
||||
checks.push({ label: `تصاویر (${imgs.length} تصویر با متن جایگزین)`, status: 'good', hint: 'همه تصاویر متن جایگزین دارند.' })
|
||||
else
|
||||
checks.push({ label: 'تصاویر', status: 'bad', hint: 'برای همه تصاویر، متن جایگزین (alt) بنویسید.' })
|
||||
|
||||
// لینکها
|
||||
const links = docLinks(input.body)
|
||||
if (links.length > 0) checks.push({ label: `پیوندها (${links.length} لینک)`, status: 'good', hint: 'متن پیوند دارد.' })
|
||||
else checks.push({ label: 'پیوندها', status: 'ok', hint: 'یک لینک مرتبط (داخلی به پروژه/خدمات یا خارجی) اضافه کنید.' })
|
||||
|
||||
// نشانی
|
||||
if (/^article-\d+$/.test(input.slug))
|
||||
checks.push({ label: 'نشانی (slug)', status: 'bad', hint: 'نشانی پیشفرض را به یک نشانی خوانا (انگلیسی) تغییر دهید.' })
|
||||
else if (input.slug.length > 96)
|
||||
checks.push({ label: 'نشانی (slug)', status: 'ok', hint: 'نشانی خیلی بلند است — کوتاهش کنید.' })
|
||||
else
|
||||
checks.push({ label: 'نشانی (slug)', status: 'good', hint: 'نشانی مناسب است.' })
|
||||
|
||||
return checks
|
||||
}, [input])
|
||||
}
|
||||
|
||||
const STATUS_STYLE = {
|
||||
good: { icon: CheckCircle2Icon, color: 'text-emerald-400' },
|
||||
ok: { icon: AlertCircleIcon, color: 'text-amber-400' },
|
||||
bad: { icon: XCircleIcon, color: 'text-red-400' },
|
||||
} as const
|
||||
|
||||
/** پنل بررسی سئو — فهرست سبز/نارنجی/قرمز به سبک یوست، سازگار با فارسی */
|
||||
export function SeoChecklist({ input }: { input: SeoInput }) {
|
||||
const checks = useSeoChecks(input)
|
||||
const good = checks.filter((c) => c.status === 'good').length
|
||||
|
||||
return (
|
||||
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="بررسی سئو">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-bold text-copper">بررسی سئو</h2>
|
||||
<span className="text-xs font-light text-ivory/50">
|
||||
{good} از {checks.length} مورد سبز
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-4 grid gap-2.5">
|
||||
{checks.map((c) => {
|
||||
const S = STATUS_STYLE[c.status]
|
||||
return (
|
||||
<li key={c.label} className="flex items-start gap-3">
|
||||
<S.icon className={`mt-1 h-4 w-4 shrink-0 ${S.color}`} aria-hidden="true" />
|
||||
<div>
|
||||
<p className="text-sm font-light text-ivory/85">{c.label}</p>
|
||||
<p className="text-xs font-light leading-6 text-ivory/45">{c.hint}</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { setBlockType, splitBlock } from '@tiptap/pm/commands'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import type { ResolvedPos } from '@tiptap/pm/model'
|
||||
|
||||
/** قالب بلوک جاری — پاراگراف یا یکی از سطوح تیتر */
|
||||
export type BlockFormat = 'paragraph' | 1 | 2 | 3
|
||||
|
||||
type LineBounds = {
|
||||
/** ابتدای بلوک جاری */
|
||||
start: number
|
||||
/** پایان بلوک جاری */
|
||||
end: number
|
||||
/** ابتدای خط جاری (پس از نزدیکترین شکست نرم قبل از نشانگر) */
|
||||
lineStart: number
|
||||
/** پایان خط جاری (نزدیکترین شکست نرم پس از نشانگر) */
|
||||
lineEnd: number
|
||||
/** موقعیت شکست نرم قبل از خط جاری — null یعنی خط، ابتدای بلوک است */
|
||||
before: number | null
|
||||
/** موقعیت شکست نرم بعد از خط جاری — null یعنی خط، انتهای بلوک است */
|
||||
after: number | null
|
||||
}
|
||||
|
||||
/** مرزهای خط جاری داخل بلوک جاری — خطوطی که با شکست نرم (<br>) از هم جدا شدهاند */
|
||||
function lineBounds($from: ResolvedPos): LineBounds {
|
||||
const start = $from.start()
|
||||
const end = $from.end()
|
||||
let lineStart = start
|
||||
let lineEnd = end
|
||||
let before: number | null = null
|
||||
let after: number | null = null
|
||||
|
||||
$from.parent.forEach((node, offset) => {
|
||||
if (node.type.name !== 'hardBreak') return
|
||||
const pos = start + offset
|
||||
if (pos < $from.pos) {
|
||||
lineStart = pos + 1
|
||||
before = pos
|
||||
} else if (after === null) {
|
||||
lineEnd = pos
|
||||
after = pos
|
||||
}
|
||||
})
|
||||
|
||||
return { start, end, lineStart, lineEnd, before, after }
|
||||
}
|
||||
|
||||
/**
|
||||
* جدا کردن خط جاری از خطوط چسبیده با شکست نرم (Shift+Enter یا متن چسباندهشده از Word).
|
||||
* بدون این جداسازی، عوض کردن قالب یک خط، قالب همه خطوط همان بلوک را با هم عوض میکند.
|
||||
* اگر گزینش از خط عبور کند (قالببندی چندخطیِ عمدی) دستنخورده میماند.
|
||||
*/
|
||||
function isolateCurrentLine(view: EditorView): boolean {
|
||||
const { state } = view
|
||||
const { selection } = state
|
||||
const bounds = lineBounds(selection.$from)
|
||||
|
||||
if (bounds.before === null && bounds.after === null) return false
|
||||
|
||||
const selectionInsideLine =
|
||||
selection.empty ||
|
||||
(selection.from >= bounds.lineStart && selection.to <= bounds.lineEnd)
|
||||
if (!selectionInsideLine) return false
|
||||
|
||||
const { tr } = state
|
||||
if (bounds.after !== null) {
|
||||
tr.delete(bounds.after, bounds.after + 1)
|
||||
tr.split(bounds.after)
|
||||
}
|
||||
if (bounds.before !== null) {
|
||||
tr.delete(bounds.before, bounds.before + 1)
|
||||
tr.split(bounds.before)
|
||||
}
|
||||
if (!tr.docChanged) return false
|
||||
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* قالببندی بلوک جاری (تیتر ۱/۲/۳ یا پاراگراف) — فقط همان خطی که نشانگر در آن است
|
||||
* و به خطوط بالا و پایین کاری ندارد. کلیک دوباره روی همان سطح تیتر، آن را به
|
||||
* پاراگراف برمیگرداند (رفتار toggle).
|
||||
*/
|
||||
export function applyBlockFormat(view: EditorView, format: BlockFormat): boolean {
|
||||
view.focus()
|
||||
isolateCurrentLine(view)
|
||||
|
||||
const { state } = view
|
||||
const paragraph = state.schema.nodes.paragraph
|
||||
const heading = state.schema.nodes.heading
|
||||
if (!paragraph || !heading) return false
|
||||
|
||||
if (format === 'paragraph') return setBlockType(paragraph)(state, view.dispatch, view)
|
||||
|
||||
const current = state.selection.$from.parent
|
||||
const sameHeading = current.type === heading && current.attrs.level === format
|
||||
return sameHeading
|
||||
? setBlockType(paragraph)(state, view.dispatch, view)
|
||||
: setBlockType(heading, { level: format })(state, view.dispatch, view)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter داخل تیتر: بلوک تازه پاراگراف میشود (نه تیتر تازه) تا قالب تیتر به خط
|
||||
* بعدی سرایت نکند و مرز بلوکها تمیز بماند.
|
||||
* این هندلر پیش از keymap پیشفرض اجرا میشود و فقط همین حالت را میگیرد.
|
||||
*/
|
||||
export function handleHeadingEnter(view: EditorView, event: KeyboardEvent): boolean {
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { state } = view
|
||||
const { selection } = state
|
||||
const paragraph = state.schema.nodes.paragraph
|
||||
if (!paragraph || !selection.empty || selection.$from.parent.type.name !== 'heading') return false
|
||||
|
||||
// خط تازه با splitBlock ساخته و بعد به پاراگراف تبدیل میشود
|
||||
if (!splitBlock(state, view.dispatch, view)) return false
|
||||
setBlockType(paragraph)(view.state, view.dispatch, view)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type SmartTextFieldOptions = {
|
||||
/** مقدار ذخیرهشده در بیرون (منبع حقیقت) */
|
||||
value: string
|
||||
/** پیشنهاد خودکار — وقتی کاربر چیزی ننوشته، همین متن نمایش داده و ذخیره میشود */
|
||||
fallback?: string
|
||||
/** آخرین گزینه وقتی پیشنهادی هم وجود ندارد (مثل «Photo 1») */
|
||||
hardFallback?: string
|
||||
/** ذخیره مقدار نهایی — فقط وقتی نسبت به مقدار ذخیرهشده تغییر کرده باشد */
|
||||
onCommit?: (next: string) => void | Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* فیلد متنی با «پیشفرض هوشمند» برای Alt/Title تصویر:
|
||||
* - متن کاربر تا لحظه ذخیره فقط در حالت محلی میماند؛ در حین تایپ نه trim میشود و نه از
|
||||
* بیرون بازنویسی میشود، پس فاصله انتهای متن و جای نشانگر حفظ میشود.
|
||||
* - با فوکوس، پیشفرض خودکار پاک میشود تا کاربر بلافاصله تایپ کند.
|
||||
* - با بلور، اگر فیلد خالی بماند پیشفرض خودکار برمیگردد و مقدار نهایی ذخیره میشود.
|
||||
*/
|
||||
export function useSmartTextField({
|
||||
value,
|
||||
fallback = '',
|
||||
hardFallback = '',
|
||||
onCommit,
|
||||
}: SmartTextFieldOptions) {
|
||||
const [text, setText] = useState(value)
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
// تازهترین مقادیر در ref نگه داشته میشوند تا هندلرها به closure قدیمی وابسته نباشند
|
||||
const textRef = useRef(text)
|
||||
textRef.current = text
|
||||
const focusedRef = useRef(focused)
|
||||
focusedRef.current = focused
|
||||
const fallbackRef = useRef(fallback)
|
||||
fallbackRef.current = fallback
|
||||
const hardFallbackRef = useRef(hardFallback)
|
||||
hardFallbackRef.current = hardFallback
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
|
||||
// همگامسازی با مقدار ذخیرهشده از بیرون (بارگذاری مقاله، بهروزرسانی خوشبینانه)
|
||||
// اما هرگز در حین ویرایش کاربر — وگرنه نشانگر میپرد و فاصلهها میخورند
|
||||
useEffect(() => {
|
||||
if (focusedRef.current) return
|
||||
setText(value)
|
||||
}, [value])
|
||||
|
||||
/** مقدار نهایی فیلد: متن کاربر → پیشنهاد خودکار → پیشفرض ثابت */
|
||||
const resolve = useCallback((): string => {
|
||||
const current = textRef.current.trim()
|
||||
if (current) return current
|
||||
return fallbackRef.current.trim() || hardFallbackRef.current.trim()
|
||||
}, [])
|
||||
|
||||
const commit = useCallback(async (): Promise<void> => {
|
||||
const next = resolve()
|
||||
setText(next)
|
||||
if (onCommit && next !== valueRef.current.trim()) await onCommit(next)
|
||||
}, [onCommit, resolve])
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
setFocused(true)
|
||||
// پیشفرض خودکار متن واقعی نیست؛ با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند
|
||||
const current = textRef.current.trim()
|
||||
const auto = fallbackRef.current.trim() || hardFallbackRef.current.trim()
|
||||
if (!current || current === auto) setText('')
|
||||
}, [])
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
setFocused(false)
|
||||
void commit()
|
||||
}, [commit])
|
||||
|
||||
const onChange = useCallback((next: string) => {
|
||||
setText(next)
|
||||
}, [])
|
||||
|
||||
/** متن نمایشی — در نبود متن کاربر، پیشنهاد خودکار نشان داده میشود */
|
||||
const display = focused ? text : text !== '' ? text : fallback
|
||||
|
||||
return { display, focused, onFocus, onBlur, onChange, commit, resolve }
|
||||
}
|
||||
@@ -3,41 +3,67 @@ import { ArrowLeftIcon } from 'lucide-react';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { ButtonLink } from '../Button';
|
||||
import { ArticleCard } from '../ArticleCard';
|
||||
import { useSite } from '../../api';
|
||||
import { useSectionHeading, useSite } from '../../api';
|
||||
import { useLang } from '../../i18n/lang';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
|
||||
export function Articles() {
|
||||
const { articles } = useSite();
|
||||
const { href } = useLang();
|
||||
const heading = useSectionHeading('articles');
|
||||
const save = useFieldSaver();
|
||||
|
||||
// پیشنویسها فقط در صفحه وبلاگ (برای ادمین) دیده میشوند، نه در خانه
|
||||
const published = articles.filter((a) => a.status !== 'draft');
|
||||
|
||||
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
|
||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id);
|
||||
if (h) (h as Record<string, unknown>)[field] = value;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<Reveal className="max-w-2xl text-right">
|
||||
<p className="text-sm font-light text-copper">وبلاگ</p>
|
||||
<Reveal className="max-w-2xl ltr:text-left rtl:text-right">
|
||||
<p className="text-sm font-light text-copper">
|
||||
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
|
||||
</p>
|
||||
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
|
||||
آخرین مقالات
|
||||
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||
</h2>
|
||||
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
||||
یادداشتهای تیم معماری، اجرا و سرمایهگذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار میگذرد.
|
||||
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
||||
</p>
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
||||
{articles.slice(0, 3).map((article, i) =>
|
||||
<Reveal key={article.slug} delay={0.06 * i} className="h-full">
|
||||
<ArticleCard article={article} />
|
||||
{published.slice(0, 3).map((article, i) =>
|
||||
<Reveal key={article.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
||||
<ArticleCard article={article} />
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to="/blog" variant="outline" className="font-light">
|
||||
همه مقالات
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
|
||||
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<AddItemButton resource="articles" />
|
||||
</div>
|
||||
</div>
|
||||
</section>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ import { MoveHorizontalIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function BeforeAfter() {
|
||||
const { before_after } = useSite()
|
||||
const { t } = useLang()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState(52)
|
||||
const dragging = useRef(false)
|
||||
@@ -54,6 +57,10 @@ export function BeforeAfter() {
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<SingletonPill resource="before_after" label="ویرایش تصاویر و برچسبهای قبل/بعد" />
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -97,7 +104,7 @@ export function BeforeAfter() {
|
||||
<button
|
||||
type="button"
|
||||
role="slider"
|
||||
aria-label="مقایسه کانسپت و پروژه ساختهشده"
|
||||
aria-label={t('compareAria')}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(position)}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { PlusIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
type FaqProps = {
|
||||
hideHeading?: boolean;
|
||||
@@ -11,18 +13,20 @@ type FaqProps = {
|
||||
|
||||
export function Faq({ hideHeading = false }: FaqProps) {
|
||||
const { faqs } = useSite()
|
||||
const { t } = useLang()
|
||||
const [open, setOpen] = useState<number | null>(0)
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
||||
|
||||
|
||||
<div className={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"}>
|
||||
|
||||
|
||||
{!hideHeading && (
|
||||
<div className="lg:col-span-4">
|
||||
<SectionHeading
|
||||
title="سوالات متداول"
|
||||
lead="پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."
|
||||
title={t('fallbackFaqTitle')}
|
||||
lead={t('fallbackFaqLead')}
|
||||
headingKey="faq"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -32,43 +36,48 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
{faqs.map((faq, i) => {
|
||||
const isOpen = open === i
|
||||
return (
|
||||
<Reveal key={faq.question} delay={0.04 * i}>
|
||||
<li className="border-b border-ivory/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||
{faq.question}
|
||||
</span>
|
||||
<PlusIcon
|
||||
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
||||
isOpen ? 'rotate-45' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</li>
|
||||
<Reveal key={faq.id} delay={0.04 * i}>
|
||||
<EditableItem resource="faqs" item={faq}>
|
||||
<li className="border-b border-ivory/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||
{faq.question}
|
||||
</span>
|
||||
<PlusIcon
|
||||
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
||||
isOpen ? 'rotate-45' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</li>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="mt-8">
|
||||
<AddItemButton resource="faqs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,58 +5,77 @@ import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { ProjectCard } from '../ProjectCard'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
import type { ProjectStatus } from '../../data/site'
|
||||
|
||||
const FILTERS: { key: ProjectStatus; label: string }[] = [
|
||||
{ key: 'done', label: 'انجام شده' },
|
||||
{ key: 'progress', label: 'در حال انجام' },
|
||||
]
|
||||
|
||||
export function FeaturedProjects() {
|
||||
const { projects } = useSite()
|
||||
const { projects, status_labels } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const heading = useSectionHeading('featured_projects')
|
||||
const save = useFieldSaver()
|
||||
const saveStatusLabel = (key: string, value: string) => {
|
||||
const label = status_labels.find((s) => s.key === key)
|
||||
if (!label) return
|
||||
void save('status-labels', label.id, { label: value }, (d) => {
|
||||
const s = d.status_labels.find((x) => x.id === label.id)
|
||||
if (s) s.label = value
|
||||
})
|
||||
}
|
||||
|
||||
const FILTERS: { key: ProjectStatus; label: string }[] = [
|
||||
{ key: 'done', label: useStatusLabel('done') },
|
||||
{ key: 'progress', label: useStatusLabel('progress') },
|
||||
]
|
||||
const [status, setStatus] = useState<ProjectStatus>('done')
|
||||
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading
|
||||
title="پروژههای شاخص"
|
||||
lead="کارهایی که بیشترین چیز را درباره استاندارد ما میگویند؛ چه آنهایی که تحویل شدهاند و چه آنهایی که همین امروز در حال ساختاند."
|
||||
/>
|
||||
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
|
||||
|
||||
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||
{FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === filter.key}
|
||||
onClick={() => setStatus(filter.key)}
|
||||
className={`group relative overflow-hidden border px-7 py-3 text-sm transition-colors duration-300 ease-out ${
|
||||
status === filter.key
|
||||
? 'border-copper bg-copper font-bold text-char'
|
||||
: 'border-ivory/15 font-light text-ivory/65'
|
||||
}`}
|
||||
>
|
||||
{status !== filter.key && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-out group-hover:translate-y-0"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 transition-colors duration-300 ease-out group-hover:text-char">
|
||||
{filter.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{/* دکمههای فیلتر مینیمال و شیشهای (بدون کادر) */}
|
||||
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
|
||||
{FILTERS.map((filter, index) => {
|
||||
const isSelected = status === filter.key
|
||||
return (
|
||||
<React.Fragment key={filter.key}>
|
||||
{/* خط جداکننده بین دکمهها */}
|
||||
{index > 0 && (
|
||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => setStatus(filter.key)}
|
||||
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${
|
||||
isSelected
|
||||
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
|
||||
: 'bg-transparent text-ivory/60 hover:bg-white/5 hover:text-ivory'
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 transition-colors duration-300 ease-out">
|
||||
<InlineEdit
|
||||
value={filter.label}
|
||||
onSave={(v) => saveStatusLabel(filter.key, v)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 min-h-[420px]">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={status}
|
||||
key={status}
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
@@ -65,27 +84,46 @@ export function FeaturedProjects() {
|
||||
>
|
||||
{list.map((project, i) => (
|
||||
<motion.div
|
||||
key={project.slug}
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full"
|
||||
>
|
||||
<ProjectCard project={project} />
|
||||
<EditableItem resource="projects" item={project} className="h-full">
|
||||
<ProjectCard project={project} />
|
||||
</EditableItem>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to="/projects" variant="outline" className="font-light">
|
||||
همه پروژهها
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
{/* دکمه مشاهده همه پروژهها */}
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink
|
||||
to={href(heading.cta_href)}
|
||||
variant="outline"
|
||||
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
|
||||
>
|
||||
<InlineEdit
|
||||
value={heading.cta_label}
|
||||
onSave={(v) =>
|
||||
save('section-headings', heading.id, { cta_label: v }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) (h as unknown as Record<string, unknown>)["cta_label"] = v
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -3,17 +3,19 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { IMAGES } from '../../data/site'
|
||||
|
||||
const GRID = [IMAGES[0], IMAGES[1], IMAGES[2], IMAGES[3], IMAGES[4], IMAGES[1]]
|
||||
import { useSite } from '../../api'
|
||||
import { useLang, formatNumber } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Gallery() {
|
||||
const { gallery } = useSite()
|
||||
const { t, lang } = useLang()
|
||||
const [active, setActive] = useState<number | null>(null)
|
||||
|
||||
const close = useCallback(() => setActive(null), [])
|
||||
const step = useCallback((dir: number) => {
|
||||
setActive((prev) => (prev === null ? prev : (prev + dir + GRID.length) % GRID.length))
|
||||
}, [])
|
||||
setActive((prev) => (prev === null ? prev : (prev + dir + gallery.length) % gallery.length))
|
||||
}, [gallery.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (active === null) return
|
||||
@@ -30,33 +32,37 @@ export function Gallery() {
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading
|
||||
title="گالری تصاویر"
|
||||
lead="قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."
|
||||
title={t('fallbackGalleryTitle')}
|
||||
lead={t('fallbackGalleryLead')}
|
||||
headingKey="gallery"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
{GRID.map((src, i) => (
|
||||
<Reveal key={`${src}-${i}`} delay={0.04 * i}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`بزرگنمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={`تصویری از پروژههای گروه ساختمانی آرامش، شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
{gallery.map((image, i) => (
|
||||
<Reveal key={image.id} delay={0.04 * i}>
|
||||
<EditableItem resource="gallery" item={image}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`${t('zoomImagePrefix')} ${formatNumber(i + 1, lang)}`}
|
||||
>
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="gallery" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{active !== null && (
|
||||
{active !== null && gallery[active] && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -65,14 +71,14 @@ export function Gallery() {
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="نمایش تصویر"
|
||||
aria-label={t('viewImage')}
|
||||
onClick={close}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="absolute left-8 top-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
aria-label="بستن"
|
||||
className="absolute top-8 ltr:right-8 rtl:left-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -83,10 +89,10 @@ export function Gallery() {
|
||||
e.stopPropagation()
|
||||
step(-1)
|
||||
}}
|
||||
className="absolute right-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:right-10"
|
||||
aria-label="تصویر قبلی"
|
||||
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:right-6 md:rtl:right-10 ltr:left-6 md:ltr:left-10"
|
||||
aria-label={t('prevImage')}
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6" />
|
||||
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -94,15 +100,15 @@ export function Gallery() {
|
||||
e.stopPropagation()
|
||||
step(1)
|
||||
}}
|
||||
className="absolute left-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10"
|
||||
aria-label="تصویر بعدی"
|
||||
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:left-6 md:rtl:left-10 ltr:right-6 md:ltr:right-10"
|
||||
aria-label={t('nextImage')}
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6" />
|
||||
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
key={active}
|
||||
src={GRID[active]}
|
||||
src={gallery[active].src}
|
||||
alt=""
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
@@ -116,4 +122,4 @@ export function Gallery() {
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,31 @@ import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function Hero() {
|
||||
const { brand } = useSite()
|
||||
const { brand, hero } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const { scrollY } = useScroll()
|
||||
const imageY = useTransform(scrollY, [0, 700], [0, 110])
|
||||
const contentY = useTransform(scrollY, [0, 700], [0, -55])
|
||||
const overlay = useTransform(scrollY, [0, 600], [0.5, 0.78])
|
||||
|
||||
const saveHero = (field: string, value: unknown) =>
|
||||
save('hero', hero.id, { [field]: value }, (d) => {
|
||||
;(d.hero as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی گروه ساختمانی آرامش">
|
||||
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label={t('heroAria')}>
|
||||
{/* تصویر پسزمینه */}
|
||||
<motion.img
|
||||
src={brand.hero}
|
||||
alt="نمای شبانه برج مسکونی ساختهشده توسط گروه ساختمانی آرامش"
|
||||
alt={t('heroImageAlt')}
|
||||
style={{ y: imageY }}
|
||||
className="absolute inset-0 h-[114%] w-full object-cover"
|
||||
/>
|
||||
@@ -29,78 +41,113 @@ export function Hero() {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* محتوای اصلی هیرو */}
|
||||
<motion.div
|
||||
style={{ y: contentY }}
|
||||
className="relative mx-auto flex h-full max-w-site flex-col justify-center px-6 lg:px-10"
|
||||
>
|
||||
<div className="w-full max-w-[700px] lg:max-w-[58%]">
|
||||
{/* کیکر بالای تیتر */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="text-sm font-light text-copper"
|
||||
className="text-sm font-light text-copper tracking-wide"
|
||||
>
|
||||
از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال
|
||||
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} />
|
||||
</motion.p>
|
||||
|
||||
{/* تیتر اصلی */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-6 text-hero font-bold text-ivory"
|
||||
className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
|
||||
>
|
||||
ساختمانی که آرامش را
|
||||
<span className="text-copper"> معماری </span>
|
||||
میکند
|
||||
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} />
|
||||
</motion.h1>
|
||||
|
||||
{/* زیرتیتر */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/70"
|
||||
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/75"
|
||||
>
|
||||
طراحی، ساخت و سرمایهگذاری در پروژههای مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده میشود و در
|
||||
سالها دوام میآورد.
|
||||
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
|
||||
</motion.p>
|
||||
|
||||
{/* داک دکمهها: استایل کپسولی، شیشهای و دایرهای */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-10 flex flex-wrap items-center gap-4"
|
||||
>
|
||||
<ButtonLink to="/projects" variant="copper">
|
||||
مشاهده پروژهها
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
||||
<ButtonLink
|
||||
to={href(hero.cta_primary_href)}
|
||||
variant="copper"
|
||||
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
|
||||
>
|
||||
<span className="text-sm font-medium tracking-wide text-ivory">
|
||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||
</span>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 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>
|
||||
<ButtonLink to="/contact" variant="outline" className="font-light">
|
||||
مشاوره سرمایهگذاری
|
||||
|
||||
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
||||
<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)} />
|
||||
</ButtonLink>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* کادر آمار شناور (گوشه پایین چپ): شیشهای با لبههای گرد مدرن */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="absolute bottom-28 left-6 hidden w-60 border border-ivory/15 bg-char/60 p-6 backdrop-blur-md lg:block lg:left-10"
|
||||
className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10"
|
||||
>
|
||||
<p className="text-3xl font-bold text-copper">۴۷</p>
|
||||
<p className="text-3xl font-bold text-copper font-mono">
|
||||
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
|
||||
پروژه تحویلشده در بیستویک سال فعالیت حرفهای
|
||||
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||
<a
|
||||
href="#story"
|
||||
aria-label="رفتن به بخش بعدی"
|
||||
className="absolute bottom-8 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/50 transition-colors duration-200 ease-lux hover:text-copper"
|
||||
aria-label={t('scrollDown')}
|
||||
className="group absolute bottom-7 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/60 transition-colors duration-300 hover:text-copper"
|
||||
>
|
||||
<span className="text-xs font-light">اسکرول کنید</span>
|
||||
<ChevronDownIcon className="h-4 w-4 animate-bounce" />
|
||||
<span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100">
|
||||
<InlineEdit value={hero.scroll_label} onSave={(v) => saveHero('scroll_label', v)} />
|
||||
</span>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-ivory/20 bg-char/40 backdrop-blur-md transition-all duration-300 group-hover:border-copper group-hover:bg-char/70 group-hover:shadow-lg group-hover:shadow-copper/20">
|
||||
<ChevronDownIcon className="h-4 w-4 animate-bounce text-ivory group-hover:text-copper" />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* کنترلهای ادمین */}
|
||||
<div className="absolute left-6 top-24 z-20 flex flex-col gap-2 lg:left-10">
|
||||
<SingletonPill resource="hero" label="تنظیمات هیرو (واژه مسی، دکمهها)" />
|
||||
<SingletonPill
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی صفحه اصلی"
|
||||
select={(d) => d.seo_meta.find((s) => s.page_key === 'home')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,26 @@ import { CheckIcon, SendIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { Button } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
import { API_URL } from '../../config'
|
||||
|
||||
export function Newsletter() {
|
||||
const { newsletter } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
const saveContent = (field: string, value: string) =>
|
||||
save('newsletter', newsletter.id, { [field]: value }, (d) => {
|
||||
;(d.newsletter as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const showErrorAlert = (title: string, text: string) => {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
@@ -18,14 +31,14 @@ export function Newsletter() {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonText: t('understood'),
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-[8px] bg-ink/60',
|
||||
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
popup: 'font-pinar border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl bg-char/95',
|
||||
title: 'font-pinar font-bold text-lg text-ivory pt-2',
|
||||
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
|
||||
confirmButton: 'mt-6 bg-copper text-char font-bold px-10 py-3 transition-transform duration-300 ease-lux hover:scale-105',
|
||||
confirmButton: 'mt-6 rounded-full bg-copper text-char font-bold px-10 py-3 transition-all duration-300 ease-lux hover:scale-105 hover:shadow-[0_0_20px_rgba(197,143,92,0.5)]',
|
||||
},
|
||||
showClass: {
|
||||
popup: 'swal2-show',
|
||||
@@ -47,13 +60,13 @@ export function Newsletter() {
|
||||
|
||||
if (!emailValue) {
|
||||
setStatus('error')
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.')
|
||||
showErrorAlert(t('incompleteInfo'), t('emailRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!emailRegex.test(emailValue)) {
|
||||
setStatus('error')
|
||||
showErrorAlert('فرمت ایمیل نامعتبر است', 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).')
|
||||
showErrorAlert(t('emailInvalidTitle'), t('emailInvalidText'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,8 +82,8 @@ export function Newsletter() {
|
||||
setStatus('error')
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در عضویت', detail)
|
||||
typeof data?.detail === 'string' ? data.detail : t('emailCheck')
|
||||
showErrorAlert(t('subscribeErrorTitle'), detail)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,29 +91,35 @@ export function Newsletter() {
|
||||
setEmail('')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-copper py-16" aria-labelledby="newsletter-title">
|
||||
<div className="mx-auto grid max-w-site items-center gap-8 px-6 lg:grid-cols-2 lg:px-10">
|
||||
<section className="relative overflow-hidden bg-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" />
|
||||
|
||||
{/* هاله نور پسزمینه برای عمق بصری */}
|
||||
<div className="pointer-events-none absolute -right-20 -top-20 h-64 w-64 rounded-full bg-white/20 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-20 -left-20 h-64 w-64 rounded-full bg-black/15 blur-3xl" />
|
||||
|
||||
<div className="relative mx-auto grid max-w-site items-center gap-10 px-6 lg:grid-cols-2 lg:px-10">
|
||||
<Reveal>
|
||||
<h2 id="newsletter-title" className="text-2xl font-bold leading-relaxed text-char md:text-3xl">
|
||||
خبرنامه گروه ساختمانی آرامش
|
||||
<InlineEdit value={newsletter.title} onSave={(v) => saveContent('title', v)} />
|
||||
</h2>
|
||||
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/75">
|
||||
هر ماه یک ایمیل: پروژههای جدید، فرصتهای سرمایهگذاری و یادداشتهای تیم معماری.
|
||||
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/80">
|
||||
<InlineEdit multiline value={newsletter.lead} onSave={(v) => saveContent('lead', v)} />
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.08}>
|
||||
<form onSubmit={submit} className="flex flex-col gap-3 sm:flex-row" noValidate>
|
||||
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="newsletter-email" className="sr-only">
|
||||
نشانی ایمیل
|
||||
{newsletter.placeholder}
|
||||
</label>
|
||||
<input
|
||||
id="newsletter-email"
|
||||
@@ -111,21 +130,30 @@ export function Newsletter() {
|
||||
setEmail(e.target.value)
|
||||
if (status !== 'idle') setStatus('idle')
|
||||
}}
|
||||
placeholder="نشانی ایمیل شما"
|
||||
className="w-full border border-transparent bg-char px-5 py-4 text-left text-sm font-light text-ivory placeholder:text-ivory/40 transition-all duration-300 ease-lux focus:border-ivory focus:outline-none"
|
||||
placeholder={newsletter.placeholder}
|
||||
className="w-full rounded-full border border-black/15 bg-char/90 px-6 py-4 text-left text-sm font-light text-ivory placeholder:text-ivory/40 shadow-[inset_0_2px_4px_rgba(0,0,0,0.35)] backdrop-blur-xl transition-all duration-300 ease-lux focus:border-ivory focus:bg-char focus:shadow-[0_0_20px_rgba(255,255,255,0.25)] focus:outline-none"
|
||||
aria-invalid={status === 'error'}
|
||||
/>
|
||||
{status === 'done' && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-light text-char" role="status">
|
||||
<CheckIcon className="h-3.5 w-3.5" />
|
||||
عضویت شما ثبت شد.
|
||||
<p className="mt-2.5 flex items-center gap-1.5 px-3 text-xs font-medium text-char" role="status">
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
{newsletter.success_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="dark-ivory" disabled={sending}>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
{sending ? 'در حال ارسال…' : 'عضویت'}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="dark"
|
||||
disabled={sending}
|
||||
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
|
||||
>
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
<span>
|
||||
{sending ? newsletter.sending_label : (
|
||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</form>
|
||||
</Reveal>
|
||||
|
||||
@@ -1,42 +1,56 @@
|
||||
import React from 'react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ServiceIcon } from '../ServiceIcon'
|
||||
import { useSite } from '../../api'
|
||||
import { useSectionHeading, useSite } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Services() {
|
||||
const { services } = useSite()
|
||||
const heading = useSectionHeading('services')
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveHeading = (field: 'title' | 'lead', value: string) =>
|
||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) h[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<Reveal className="max-w-2xl text-right">
|
||||
|
||||
|
||||
<h2 id="services-title" className="text-section font-bold text-ivory">
|
||||
خدمات
|
||||
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||
</h2>
|
||||
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
||||
از نخستین خط روی کاغذ تا تحویل کلید، مدیریت سرمایه و طراحی محوطه؛ همه مراحل در یک مجموعه و زیر یک نظام
|
||||
کنترل کیفیت.
|
||||
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
||||
</p>
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{services.map((service, i) => (
|
||||
<Reveal key={service.title} delay={0.04 * i} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||
<ServiceIcon
|
||||
name={service.icon}
|
||||
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
|
||||
/>
|
||||
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
|
||||
</article>
|
||||
<Reveal key={service.id} delay={0.04 * i} className="h-full">
|
||||
<EditableItem resource="services" item={service} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||
<ServiceIcon
|
||||
name={service.icon}
|
||||
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
|
||||
/>
|
||||
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
|
||||
</article>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="services" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useSite } from '../../api';
|
||||
|
||||
function toFa(n: number) {
|
||||
return n.toLocaleString('fa-IR');
|
||||
}
|
||||
import { useLang, formatNumber } from '../../i18n/lang';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
|
||||
function Counter({ value }: {value: number;}) {
|
||||
const { lang } = useLang();
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const inView = useInView(ref, { once: true, margin: '-60px' });
|
||||
const [display, setDisplay] = useState(0);
|
||||
@@ -24,24 +25,45 @@ function Counter({ value }: {value: number;}) {
|
||||
return () => window.clearInterval(id);
|
||||
}, [inView, value]);
|
||||
|
||||
return <span ref={ref}>{toFa(display)}</span>;
|
||||
return <span ref={ref}>{formatNumber(display, lang)}</span>;
|
||||
}
|
||||
|
||||
export function Stats() {
|
||||
const { stats } = useSite();
|
||||
const { t } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
return (
|
||||
<section className="bg-char py-16" aria-label="آمار و ارقام">
|
||||
<section className="bg-char py-16" aria-label={t('statsAria')}>
|
||||
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10">
|
||||
{stats.map((stat) =>
|
||||
<div key={stat.label} className="text-center">
|
||||
<EditableItem key={stat.id} resource="stats" item={stat}>
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-copper md:text-5xl">
|
||||
<Counter value={stat.value} />
|
||||
{stat.suffix && <span className="mr-1 text-2xl">{stat.suffix}</span>}
|
||||
<InlineEdit
|
||||
value={stat.suffix}
|
||||
onSave={(v) => save('stats', stat.id, { suffix: v }, (d) => {
|
||||
const s = d.stats.find((x) => x.id === stat.id);
|
||||
if (s) s.suffix = v;
|
||||
})}
|
||||
className="ltr:ml-1 rtl:mr-1 text-2xl"
|
||||
/>
|
||||
</p>
|
||||
<p className="mt-3 text-sm font-light text-ivory/55">
|
||||
<InlineEdit
|
||||
value={stat.label}
|
||||
onSave={(v) => save('stats', stat.id, { label: v }, (d) => {
|
||||
const s = d.stats.find((x) => x.id === stat.id);
|
||||
if (s) s.label = v;
|
||||
})}
|
||||
/>
|
||||
</p>
|
||||
<p className="mt-3 text-sm font-light text-ivory/55">{stat.label}</p>
|
||||
</div>
|
||||
</EditableItem>
|
||||
)}
|
||||
<AddItemButton resource="stats" />
|
||||
</div>
|
||||
</section>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,36 @@ import React from 'react'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { IMAGES } from '../../data/site'
|
||||
|
||||
const BENTO = [
|
||||
{ src: IMAGES[0], alt: 'نمای برج مسکونی آرامش در تهران' },
|
||||
{ src: IMAGES[1], alt: 'آتریوم مرکزی مجتمع تجاری پارسه' },
|
||||
{ src: IMAGES[2], alt: 'ویلاهای ساختهشده در سواحل شمال' },
|
||||
{ src: IMAGES[3], alt: 'نمای شبانه برج دوقلوی نیاوران' },
|
||||
]
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
type StoryProps = {
|
||||
showLink?: boolean;
|
||||
showLabel?: boolean;
|
||||
showQuote?: boolean;
|
||||
alignTop?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Story({ showLink = true, showLabel = true, alignTop = false, children }: StoryProps) {
|
||||
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
|
||||
const { story } = useSite()
|
||||
const { href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveStory = (field: string, value: unknown) =>
|
||||
save('story', story.id, { [field]: value }, (d) => {
|
||||
;(d.story as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const saveParagraph = (index: number, value: string) => {
|
||||
const paragraphs = [...story.paragraphs]
|
||||
paragraphs[index] = value
|
||||
void saveStory('paragraphs', paragraphs)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="story" className="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'}`}>
|
||||
@@ -26,41 +39,59 @@ export function Story({ showLink = true, showLabel = true, alignTop = false, chi
|
||||
{showLabel && (
|
||||
<Reveal>
|
||||
<h2 id="story-title" className="text-section font-bold text-ivory">
|
||||
درباره ی ما
|
||||
<InlineEdit value={story.title} onSave={(v) => saveStory('title', v)} />
|
||||
</h2>
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.08}>
|
||||
<p className="mt-8 text-base font-light leading-9 text-ivory/65">
|
||||
گروه ساختمانی آرامش با یک دفتر کوچک معماری آغاز شد و امروز یکی از مجموعههای شناختهشده ساختوساز لوکس در ایران
|
||||
است. در تمام این سالها یک اصل تغییر نکرده است: ساختمان باید برای زندگی ساخته شود، نه برای فروش سریع.
|
||||
</p>
|
||||
<p className="mt-5 text-base font-light leading-9 text-ivory/65">
|
||||
هر پروژه با مطالعه زمین، نور، اقلیم و الگوی زندگی ساکنان آغاز میشود؛ سپس تیم طراحی، سازه و اجرا در یک
|
||||
فرآیند واحد آن را تا تحویل کلید پیش میبرند.
|
||||
</p>
|
||||
{story.paragraphs.map((paragraph, i) => (
|
||||
<p key={i} className={`text-base font-light leading-9 text-ivory/65 ${i === 0 ? 'mt-8' : 'mt-5'}`}>
|
||||
<InlineEdit multiline value={paragraph} onSave={(v) => saveParagraph(i, v)} />
|
||||
</p>
|
||||
))}
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.16}>
|
||||
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-copper">۹۴٪</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">تحویل بهموقع پروژهها</p>
|
||||
<p className="text-2xl font-bold text-copper">
|
||||
<InlineEdit value={story.stat1_value} onSave={(v) => saveStory('stat1_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">
|
||||
<InlineEdit value={story.stat1_label} onSave={(v) => saveStory('stat1_label', v)} />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-copper">۵ سال</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">پشتیبانی پس از تحویل</p>
|
||||
<p className="text-2xl font-bold text-copper">
|
||||
<InlineEdit value={story.stat2_value} onSave={(v) => saveStory('stat2_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">
|
||||
<InlineEdit value={story.stat2_label} onSave={(v) => saveStory('stat2_label', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{showQuote && story.quote && (
|
||||
<Reveal delay={0.2}>
|
||||
<div className="mt-12 border-r-2 border-copper bg-char/40 p-6">
|
||||
<p className="text-sm font-light leading-8 text-ivory/80">
|
||||
<InlineEdit multiline value={story.quote} onSave={(v) => saveStory('quote', v)} />
|
||||
</p>
|
||||
<p className="mt-5 text-sm font-bold text-copper">
|
||||
<InlineEdit value={story.quote_author} onSave={(v) => saveStory('quote_author', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{showLink && (
|
||||
<Reveal delay={0.22}>
|
||||
<ButtonLink to="/about" variant="outline" className="mt-10 font-light">
|
||||
داستان کامل ما
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light">
|
||||
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</Reveal>
|
||||
)}
|
||||
@@ -72,8 +103,9 @@ export function Story({ showLink = true, showLabel = true, alignTop = false, chi
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{BENTO.map((item, i) => (
|
||||
<div className="relative grid grid-cols-2 gap-4">
|
||||
<SingletonPill resource="story" label="تصاویر و نقلقول بخش درباره ما" className="absolute -top-10 ltr:left-0 rtl:right-0" />
|
||||
{story.bento.map((item, i) => (
|
||||
<Reveal key={item.src + i} delay={0.06 * i}>
|
||||
<div className="overflow-hidden">
|
||||
<img
|
||||
@@ -88,4 +120,4 @@ export function Story({ showLink = true, showLabel = true, alignTop = false, chi
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,35 +2,42 @@ import React from 'react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Team() {
|
||||
const { team } = useSite()
|
||||
const { t } = useLang()
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading
|
||||
title="تیم ما"
|
||||
lead="کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."
|
||||
title={t('fallbackTeamTitle')}
|
||||
lead={t('fallbackTeamLead')}
|
||||
headingKey="team"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{team.map((member, i) => (
|
||||
<Reveal key={member.name} delay={0.06 * i} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
|
||||
{member.name.trim().charAt(0)}
|
||||
</div>
|
||||
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
|
||||
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
|
||||
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
|
||||
{member.bio}
|
||||
</p>
|
||||
</article>
|
||||
<Reveal key={member.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="team" item={member} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
|
||||
{member.name.trim().charAt(0)}
|
||||
</div>
|
||||
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
|
||||
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
|
||||
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
|
||||
{member.bio}
|
||||
</p>
|
||||
</article>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="team" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,15 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
|
||||
import { SectionHeading } from '../SectionHeading';
|
||||
import { useSite } from '../../api';
|
||||
import { useLang } from '../../i18n/lang';
|
||||
import { EditableItem } from '../../admin/Editable';
|
||||
|
||||
export function Testimonials() {
|
||||
const { testimonials } = useSite();
|
||||
const { t } = useLang();
|
||||
const [index, setIndex] = useState(0);
|
||||
const item = testimonials[index];
|
||||
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))];
|
||||
if (!current) return null;
|
||||
|
||||
const go = (dir: number) => {
|
||||
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
|
||||
@@ -16,61 +20,67 @@ export function Testimonials() {
|
||||
return (
|
||||
<section className="border-y border-ivory/10 bg-char py-24 lg:py-32" aria-labelledby="testimonials-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading title="نظرات مشتریان" align="center" />
|
||||
<SectionHeading headingKey="testimonials" align="center" />
|
||||
|
||||
<div className="relative mx-auto mt-14 max-w-3xl text-center">
|
||||
<QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.blockquote
|
||||
key={item.name}
|
||||
key={current.id}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
||||
|
||||
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{item.quote}»</p>
|
||||
<footer className="mt-8">
|
||||
<p className="text-base font-bold text-copper">{item.name}</p>
|
||||
<p className="mt-1 text-sm font-light text-ivory/45">{item.role}</p>
|
||||
</footer>
|
||||
|
||||
<EditableItem resource="testimonials" item={current}>
|
||||
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{current.quote}»</p>
|
||||
<footer className="mt-8">
|
||||
<p className="text-base font-bold text-copper">{current.name}</p>
|
||||
<p className="mt-1 text-sm font-light text-ivory/45">{current.role}</p>
|
||||
</footer>
|
||||
</EditableItem>
|
||||
</motion.blockquote>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-10 flex items-center justify-center gap-4">
|
||||
<div className="mt-12 flex items-center justify-center gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(1)}
|
||||
aria-label="نظر قبلی"
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
||||
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
aria-label={t('prevTestimonial')}
|
||||
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5 ltr:rotate-180 transition-transform duration-300 ease-lux rtl:group-hover:translate-x-0.5 ltr:group-hover:-translate-x-0.5" />
|
||||
</button>
|
||||
<div className="flex gap-2" role="tablist" aria-label="انتخاب نظر">
|
||||
{testimonials.map((t, i) =>
|
||||
<button
|
||||
key={t.name}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`نظر ${t.name}`}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-1.5 transition-[width,background-color] duration-200 ease-lux ${
|
||||
i === index ? 'w-8 bg-copper' : 'w-3 bg-ivory/25'}`
|
||||
} />
|
||||
|
||||
)}
|
||||
<div className="flex items-center gap-2" role="tablist" aria-label={t('selectTestimonialAria')}>
|
||||
{testimonials.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`${t("testimonialByPrefix")} ${item.name}`}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-2 rounded-full transition-all duration-300 ease-lux ${
|
||||
i === index
|
||||
? 'w-8 bg-copper shadow-[0_0_12px_rgba(197,143,92,0.6)]'
|
||||
: 'w-2.5 bg-ivory/20 hover:bg-ivory/40'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(-1)}
|
||||
aria-label="نظر بعدی"
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
||||
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
aria-label={t('nextTestimonial')}
|
||||
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5 ltr:rotate-180 transition-transform duration-300 ease-lux rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>);
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||