Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddd986e037 | ||
|
|
35a558db20 | ||
|
|
5a6cb51333 | ||
|
|
dc200099ba | ||
|
|
0a55d5d0fe | ||
|
|
97dc728a4c | ||
|
|
87f38f1c0b | ||
|
|
8dab39ee83 | ||
|
|
32b6655a1e | ||
|
|
395aceb6f2 | ||
|
|
e76dbbf63d | ||
|
|
e0ebae4817 |
@@ -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
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# پشتیبانگیری محلی
|
||||||
|
backups/
|
||||||
|
|
||||||
# Node / Vite
|
# Node / Vite
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
@@ -23,3 +26,6 @@ Thumbs.db
|
|||||||
# Database
|
# Database
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
# تصاویر محلی — از پنل/کنسول MinIO مدیریت میشوند
|
||||||
|
frontend/public/images/
|
||||||
|
|||||||
@@ -2,35 +2,68 @@
|
|||||||
|
|
||||||
مونوریپوی سایت گروه ساختمانی آرامش شامل:
|
مونوریپوی سایت گروه ساختمانی آرامش شامل:
|
||||||
|
|
||||||
- **`frontend/`** — سایت React + Vite + Tailwind (همه محتوا از API خوانده میشود)
|
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متنها و تصاویر از API خوانده میشود)
|
||||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT
|
- **`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
|
├── frontend/ # سایت React
|
||||||
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
|
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
|
||||||
│ ├── Dockerfile # بیلد Node + سرو با nginx
|
│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy
|
||||||
│ └── nginx.conf # پروکسی /api به بکاند
|
│ └── Caddyfile # فقط سرو فایلهای استاتیک (مسیریابی روی Traefik است)
|
||||||
├── backend/ # بکاند FastAPI
|
├── backend/ # بکاند FastAPI
|
||||||
│ ├── app/ # کد منبع
|
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
|
||||||
|
│ ├── tests/ # تستهای API (pytest)
|
||||||
│ ├── requirements.txt
|
│ ├── requirements.txt
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── .env.example
|
│ └── .env.example
|
||||||
└── docker-compose.yml # اجرای کامل با یک دستور
|
├── scripts/backup.sh # پشتیبانگیری دیتابیس و تصاویر
|
||||||
|
├── docker-compose.yml # اجرای کامل با یک دستور
|
||||||
|
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
|
||||||
```
|
```
|
||||||
|
|
||||||
## اجرا با Docker (سادهترین راه)
|
## اجرا با Docker (سادهترین راه)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمیآید
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
- سایت: http://localhost:5173
|
- سایت: http://localhost:5173 (از طریق Traefik)
|
||||||
- مستندات API: http://localhost:8000/docs
|
- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی)
|
||||||
- دیتابیس PostgreSQL در کانتینر جدا (دادهها در volume ذخیره میشوند)
|
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در compose
|
||||||
- بکاند هنگام شروع، خودش جداول و دادههای اولیه سایت را seed میکند
|
- دیتابیس PostgreSQL و آبجکتاستور MinIO هر دو در کانتینر جدا با volume دائمی
|
||||||
- nginx درخواستهای `/api` را خودش به بکاند پروکسی میکند — نیازی به تنظیم CORS نیست
|
- بکاند هنگام شروع، خودش جداول را میسازد و **فقط جدولهای خالی** را seed میکند —
|
||||||
|
ارتقای نسخه دادههای فعلی را از بین نمیبرد
|
||||||
|
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را میسازد و خواندن عمومی را فعال میکند
|
||||||
|
|
||||||
|
## انتشار واقعی (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,23 +85,108 @@ uvicorn app.main:app --reload
|
|||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev # http://localhost:5173
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
آدرس بکاند در `frontend/src/config.ts` از `VITE_API_URL` خوانده میشود؛
|
در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند؛
|
||||||
اگر تنظیم نشود پیشفرض `http://localhost:8000` است.
|
پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `frontend/src/config.ts` از
|
||||||
|
`VITE_API_URL` خوانده میشود — پیشفرض خالی یعنی درخواستهای هممبدأ.
|
||||||
|
|
||||||
## امکانات بکاند
|
## امکانات بکاند
|
||||||
|
|
||||||
- **API عمومی**: برند، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم، تایملاین، آمار و...
|
- **API عمومی**: برند، منو، هیرو، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم،
|
||||||
|
تایملاین، آمار، دفاتر، مدلهای سرمایهگذاری و... — همه در دیتابیس
|
||||||
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
|
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
|
||||||
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`) و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`)
|
||||||
- همه محتوا در یک درخواست: `GET /api/site`
|
و سپس 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` است)
|
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml`
|
||||||
- رمز عبور ادمین با bcrypt هش میشود
|
با سینتکس `${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید
|
||||||
|
- رمز عبور ادمین با bcrypt هش میشود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
|
||||||
|
- ورود با محدودیت تلاش محافظت میشود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
|
||||||
- توکنهای JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
|
- توکنهای JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
|
||||||
|
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
|
||||||
|
- مقالههای پیشنویس در هیچ انتهای عمومی نمیآیند و در HTML تزریقشده سئو هم افشا نمیشوند
|
||||||
|
|||||||
@@ -15,3 +15,15 @@ CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
|
|||||||
# مشخصات ادمین پیشفرض که هنگام seed ساخته میشود
|
# مشخصات ادمین پیشفرض که هنگام seed ساخته میشود
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=aramesh123
|
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
|
||||||
|
|||||||
+42
-7
@@ -1,14 +1,15 @@
|
|||||||
# بکاند گروه ساختمانی آرامش (Aramesh Building Group API)
|
# بکاند گروه ساختمانی آرامش (Aramesh Building Group API)
|
||||||
|
|
||||||
بکاند سایت گروه ساختمانی آرامش — ساختهشده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT**.
|
بکاند سایت گروه ساختمانی آرامش — ساختهشده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT** + **MinIO** (ذخیره تصاویر) + **Traefik** (مسیریابی).
|
||||||
|
|
||||||
## امکانات
|
## امکانات
|
||||||
|
|
||||||
- **API عمومی محتوا**: برند، خدمات، پروژهها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایملاین، آمار، چرا آرامش، و بخش قبل/بعد
|
- **API عمومی محتوا**: برند، منو، هیرو، داستان، تیتر بخشها، هیرو صفحات، خدمات، پروژهها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایملاین، آمار، چرا آرامش، قبل/بعد، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، فرم تماس، خبرنامه و فوتر — **تمام متنهای سایت در دیتابیس است**
|
||||||
- **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`)
|
- **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`)
|
||||||
- **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`)
|
- **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`)
|
||||||
- **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیامهای تماس و مشترکین
|
- **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیامهای تماس و مشترکین
|
||||||
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار میگیرد
|
- **آپلود تصویر**: `POST /api/admin/upload` — فایل در MinIO ذخیره و URL نسبی `/media/...` برمیگردد
|
||||||
|
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار میگیرد (فقط جدولهای خالی پر میشوند — ارتقا بدون از دست رفتن داده)
|
||||||
- مستندات تعاملی Swagger در مسیر `/docs`
|
- مستندات تعاملی Swagger در مسیر `/docs`
|
||||||
|
|
||||||
## راهاندازی
|
## راهاندازی
|
||||||
@@ -85,6 +86,13 @@ Authorization: Bearer <access_token>
|
|||||||
|
|
||||||
> نام کاربری و رمز عبور پیشفرض از تنظیمات `.env` خوانده میشود — حتماً در محیط تولید تغییرشان دهید.
|
> نام کاربری و رمز عبور پیشفرض از تنظیمات `.env` خوانده میشود — حتماً در محیط تولید تغییرشان دهید.
|
||||||
|
|
||||||
|
تغییر رمز (با توکن):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/change-password
|
||||||
|
{ "current_password": "...", "new_password": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
## فهرست انتهاپوینتها
|
## فهرست انتهاپوینتها
|
||||||
|
|
||||||
### عمومی (بدون نیاز به توکن)
|
### عمومی (بدون نیاز به توکن)
|
||||||
@@ -96,8 +104,8 @@ Authorization: Bearer <access_token>
|
|||||||
| GET | `/api/services` | خدمات |
|
| GET | `/api/services` | خدمات |
|
||||||
| GET | `/api/projects` | همه پروژهها |
|
| GET | `/api/projects` | همه پروژهها |
|
||||||
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
|
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
|
||||||
| GET | `/api/articles` | همه مقالات |
|
| GET | `/api/articles` | مقالههای منتشرشده (پیشنویس نمیآید) |
|
||||||
| GET | `/api/articles/{slug}` | جزئیات یک مقاله |
|
| GET | `/api/articles/{slug}` | جزئیات یک مقاله منتشرشده |
|
||||||
| GET | `/api/testimonials` | نظرات مشتریان |
|
| GET | `/api/testimonials` | نظرات مشتریان |
|
||||||
| GET | `/api/faqs` | سوالات متداول |
|
| GET | `/api/faqs` | سوالات متداول |
|
||||||
| GET | `/api/team` | اعضای تیم |
|
| GET | `/api/team` | اعضای تیم |
|
||||||
@@ -108,7 +116,11 @@ Authorization: Bearer <access_token>
|
|||||||
| GET | `/api/site` | **همه محتوا در یک درخواست** |
|
| GET | `/api/site` | **همه محتوا در یک درخواست** |
|
||||||
| POST | `/api/contact` | ثبت پیام تماس |
|
| POST | `/api/contact` | ثبت پیام تماس |
|
||||||
| POST | `/api/newsletter` | عضویت در خبرنامه |
|
| 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)
|
### مدیریت (نیاز به توکن Bearer)
|
||||||
|
|
||||||
@@ -118,12 +130,35 @@ Authorization: Bearer <access_token>
|
|||||||
| متد | مسیر | توضیح |
|
| متد | مسیر | توضیح |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET/POST | `/api/admin/{resource}` | لیست / ساخت |
|
| 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` فقط خواندهنشدهها) |
|
| GET | `/api/admin/messages` | پیامهای تماس (با `?unread_only=true` فقط خواندهنشدهها) |
|
||||||
| PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خواندهشدن |
|
| PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خواندهشدن |
|
||||||
| DELETE | `/api/admin/messages/{id}` | حذف پیام |
|
| DELETE | `/api/admin/messages/{id}` | حذف پیام |
|
||||||
| GET | `/api/admin/subscribers` | مشترکین خبرنامه |
|
| GET | `/api/admin/subscribers` | مشترکین خبرنامه |
|
||||||
| DELETE | `/api/admin/subscribers/{id}` | حذف مشترک |
|
| 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)
|
||||||
|
- تصاویر عمومی و قابل نمایشاند؛ آپلود فقط با توکن ادمین انجام میشود.
|
||||||
|
|
||||||
## اتصال به فرانتاند
|
## اتصال به فرانتاند
|
||||||
|
|
||||||
|
|||||||
+208
-17
@@ -1,40 +1,103 @@
|
|||||||
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن 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.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import models, schemas
|
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 ..database import get_db
|
||||||
|
from ..rate_limit import clear as clear_rate_limit
|
||||||
|
from ..rate_limit import ensure_allowed, record_failure
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
ArticleIn,
|
ArticleIn,
|
||||||
ArticleOut,
|
ArticleOut,
|
||||||
|
ArticlePatch,
|
||||||
BeforeAfterIn,
|
BeforeAfterIn,
|
||||||
BeforeAfterOut,
|
BeforeAfterOut,
|
||||||
|
BeforeAfterPatch,
|
||||||
BrandIn,
|
BrandIn,
|
||||||
BrandOut,
|
BrandOut,
|
||||||
|
BrandPatch,
|
||||||
|
ChangePasswordIn,
|
||||||
|
ContactSectionIn,
|
||||||
|
ContactSectionOut,
|
||||||
|
ContactSectionPatch,
|
||||||
ContactMessageOut,
|
ContactMessageOut,
|
||||||
FaqIn,
|
FaqIn,
|
||||||
FaqOut,
|
FaqOut,
|
||||||
|
FaqPatch,
|
||||||
|
FooterIn,
|
||||||
|
FooterOut,
|
||||||
|
FooterPatch,
|
||||||
|
GalleryImageIn,
|
||||||
|
GalleryImageOut,
|
||||||
|
GalleryImagePatch,
|
||||||
|
HeroIn,
|
||||||
|
HeroOut,
|
||||||
|
HeroPatch,
|
||||||
|
InvestmentModelIn,
|
||||||
|
InvestmentModelOut,
|
||||||
|
InvestmentModelPatch,
|
||||||
LoginIn,
|
LoginIn,
|
||||||
MessageReadIn,
|
MessageReadIn,
|
||||||
|
NavItemIn,
|
||||||
|
NavItemOut,
|
||||||
|
NavItemPatch,
|
||||||
|
NewsletterSectionIn,
|
||||||
|
NewsletterOut,
|
||||||
|
NewsletterSectionPatch,
|
||||||
|
OfficeIn,
|
||||||
|
OfficeOut,
|
||||||
|
OfficePatch,
|
||||||
|
PageHeroIn,
|
||||||
|
PageHeroOut,
|
||||||
|
PageHeroPatch,
|
||||||
|
ProcessStepIn,
|
||||||
|
ProcessStepOut,
|
||||||
|
ProcessStepPatch,
|
||||||
ProjectIn,
|
ProjectIn,
|
||||||
ProjectOut,
|
ProjectOut,
|
||||||
|
ProjectPatch,
|
||||||
|
ReorderIn,
|
||||||
|
SeoMetaIn,
|
||||||
|
SeoMetaOut,
|
||||||
|
SeoMetaPatch,
|
||||||
|
SectionHeadingIn,
|
||||||
|
SectionHeadingOut,
|
||||||
|
SectionHeadingPatch,
|
||||||
ServiceIn,
|
ServiceIn,
|
||||||
ServiceOut,
|
ServiceOut,
|
||||||
|
ServicePatch,
|
||||||
StatIn,
|
StatIn,
|
||||||
StatOut,
|
StatOut,
|
||||||
|
StatPatch,
|
||||||
|
StatusLabelIn,
|
||||||
|
StatusLabelOut,
|
||||||
|
StatusLabelPatch,
|
||||||
|
StoryIn,
|
||||||
|
StoryOut,
|
||||||
|
StoryPatch,
|
||||||
SubscriberOut,
|
SubscriberOut,
|
||||||
TeamMemberIn,
|
TeamMemberIn,
|
||||||
TeamMemberOut,
|
TeamMemberOut,
|
||||||
|
TeamMemberPatch,
|
||||||
TestimonialIn,
|
TestimonialIn,
|
||||||
TestimonialOut,
|
TestimonialOut,
|
||||||
|
TestimonialPatch,
|
||||||
TimelineItemIn,
|
TimelineItemIn,
|
||||||
TimelineItemOut,
|
TimelineItemOut,
|
||||||
|
TimelineItemPatch,
|
||||||
TokenOut,
|
TokenOut,
|
||||||
WhyUsItemIn,
|
WhyUsItemIn,
|
||||||
WhyUsItemOut,
|
WhyUsItemOut,
|
||||||
|
WhyUsItemPatch,
|
||||||
)
|
)
|
||||||
|
|
||||||
# روتر عمومی برای ورود — بدون نیاز به توکن
|
# روتر عمومی برای ورود — بدون نیاز به توکن
|
||||||
@@ -45,26 +108,52 @@ router = APIRouter(dependencies=[Depends(get_current_admin)])
|
|||||||
|
|
||||||
|
|
||||||
@auth_router.post("/auth/login", response_model=TokenOut)
|
@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()
|
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):
|
if user is None or not verify_password(payload.password, user.password_hash):
|
||||||
|
record_failure(request)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="نام کاربری یا رمز عبور اشتباه است",
|
detail="نام کاربری یا رمز عبور اشتباه است",
|
||||||
)
|
)
|
||||||
|
clear_rate_limit(request)
|
||||||
return TokenOut(access_token=create_access_token(user.username))
|
return TokenOut(access_token=create_access_token(user.username))
|
||||||
|
|
||||||
|
|
||||||
def register_crud(path: str, model, out_schema, create_schema) -> None:
|
@auth_router.post("/auth/change-password")
|
||||||
"""ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت."""
|
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])
|
@router.get(f"/{path}", response_model=list[out_schema])
|
||||||
def list_items(db: Session = Depends(get_db)):
|
def list_items(db: Session = Depends(get_db)):
|
||||||
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)
|
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
||||||
def create_item(payload: create_schema, db: Session = Depends(get_db)):
|
def create_item(payload: create_schema, db: Session = Depends(get_db)):
|
||||||
item = model(**payload.model_dump())
|
item = model(**payload.model_dump())
|
||||||
|
if hasattr(model, "position"):
|
||||||
|
# آیتم جدید در انتهای لیست قرار میگیرد
|
||||||
|
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
|
||||||
db.add(item)
|
db.add(item)
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -96,6 +185,38 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
|
|||||||
db.refresh(item)
|
db.refresh(item)
|
||||||
return 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)
|
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
||||||
def delete_item(item_id: int, db: Session = Depends(get_db)):
|
def delete_item(item_id: int, db: Session = Depends(get_db)):
|
||||||
item = db.get(model, item_id)
|
item = db.get(model, item_id)
|
||||||
@@ -106,17 +227,87 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# CRUD محتوا
|
# CRUD محتوا
|
||||||
register_crud("brand", models.Brand, BrandOut, BrandIn)
|
register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch)
|
||||||
register_crud("services", models.Service, ServiceOut, ServiceIn)
|
register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch)
|
||||||
register_crud("projects", models.Project, ProjectOut, ProjectIn)
|
register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch)
|
||||||
register_crud("articles", models.Article, ArticleOut, ArticleIn)
|
register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch)
|
||||||
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn)
|
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch)
|
||||||
register_crud("faqs", models.Faq, FaqOut, FaqIn)
|
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
|
||||||
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn)
|
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
|
||||||
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn)
|
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
|
||||||
register_crud("stats", models.Stat, StatOut, StatIn)
|
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch)
|
||||||
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn)
|
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
|
||||||
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn)
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# پیامهای تماس
|
# پیامهای تماس
|
||||||
|
|||||||
+46
-19
@@ -37,12 +37,12 @@ def get_brand(db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/services", response_model=list[ServiceOut])
|
@router.get("/services", response_model=list[ServiceOut])
|
||||||
def get_services(db: Session = Depends(get_db)):
|
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])
|
@router.get("/projects", response_model=list[ProjectOut])
|
||||||
def get_projects(db: Session = Depends(get_db)):
|
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)
|
@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])
|
@router.get("/articles", response_model=list[ArticleOut])
|
||||||
def get_articles(db: Session = Depends(get_db)):
|
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)
|
@router.get("/articles/{slug}", response_model=ArticleOut)
|
||||||
def get_article(slug: str, db: Session = Depends(get_db)):
|
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:
|
if article is None:
|
||||||
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||||
return article
|
return article
|
||||||
@@ -68,32 +77,32 @@ def get_article(slug: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/testimonials", response_model=list[TestimonialOut])
|
@router.get("/testimonials", response_model=list[TestimonialOut])
|
||||||
def get_testimonials(db: Session = Depends(get_db)):
|
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])
|
@router.get("/faqs", response_model=list[FaqOut])
|
||||||
def get_faqs(db: Session = Depends(get_db)):
|
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])
|
@router.get("/team", response_model=list[TeamMemberOut])
|
||||||
def get_team(db: Session = Depends(get_db)):
|
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])
|
@router.get("/timeline", response_model=list[TimelineItemOut])
|
||||||
def get_timeline(db: Session = Depends(get_db)):
|
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])
|
@router.get("/stats", response_model=list[StatOut])
|
||||||
def get_stats(db: Session = Depends(get_db)):
|
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])
|
@router.get("/why-us", response_model=list[WhyUsItemOut])
|
||||||
def get_why_us(db: Session = Depends(get_db)):
|
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)
|
@router.get("/before-after", response_model=BeforeAfterOut)
|
||||||
@@ -109,14 +118,32 @@ def get_site(db: Session = Depends(get_db)):
|
|||||||
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانتاند."""
|
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانتاند."""
|
||||||
return {
|
return {
|
||||||
"brand": db.query(models.Brand).first(),
|
"brand": db.query(models.Brand).first(),
|
||||||
"services": db.query(models.Service).all(),
|
"nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(),
|
||||||
"projects": db.query(models.Project).order_by(models.Project.id).all(),
|
"hero": db.query(models.Hero).first(),
|
||||||
"articles": db.query(models.Article).order_by(models.Article.id).all(),
|
"story": db.query(models.StorySection).first(),
|
||||||
"testimonials": db.query(models.Testimonial).all(),
|
"section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(),
|
||||||
"faqs": db.query(models.Faq).all(),
|
"page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(),
|
||||||
"team": db.query(models.TeamMember).all(),
|
"services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(),
|
||||||
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(),
|
"projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(),
|
||||||
"stats": db.query(models.Stat).all(),
|
# فقط مقالههای منتشرشده — پیشنویسها از انتهای مدیریتی خوانده میشوند
|
||||||
"why_us": db.query(models.WhyUsItem).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(),
|
"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_username: str = "admin"
|
||||||
admin_password: str = "aramesh123"
|
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()
|
settings = Settings()
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ from . import models # noqa: F401 — ثبت مدلها پیش از ساخت
|
|||||||
from .api import admin, forms, public
|
from .api import admin, forms, public
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import Base, engine
|
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)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Aramesh Building Group API",
|
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(forms.router, prefix="/api", tags=["فرمها"])
|
||||||
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
|
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
|
||||||
app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"])
|
app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"])
|
||||||
|
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
|
||||||
|
app.include_router(seo_router, tags=["سئو"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""مهاجرتهای سبک دیتابیس — 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",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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 ''")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 run_all() -> None:
|
||||||
|
run_ddl_migrations()
|
||||||
|
migrate_article_bodies()
|
||||||
@@ -35,6 +35,8 @@ class Brand(Base):
|
|||||||
class Service(Base):
|
class Service(Base):
|
||||||
__tablename__ = "services"
|
__tablename__ = "services"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
desc: Mapped[str] = mapped_column(Text, default="")
|
desc: Mapped[str] = mapped_column(Text, default="")
|
||||||
@@ -44,6 +46,8 @@ class Service(Base):
|
|||||||
class Project(Base):
|
class Project(Base):
|
||||||
__tablename__ = "projects"
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
@@ -63,6 +67,8 @@ class Project(Base):
|
|||||||
class Article(Base):
|
class Article(Base):
|
||||||
__tablename__ = "articles"
|
__tablename__ = "articles"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
@@ -72,11 +78,17 @@ class Article(Base):
|
|||||||
read: Mapped[str] = mapped_column(String(32), default="")
|
read: Mapped[str] = mapped_column(String(32), default="")
|
||||||
excerpt: Mapped[str] = mapped_column(Text, default="")
|
excerpt: Mapped[str] = mapped_column(Text, default="")
|
||||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||||
|
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||||
|
seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
|
||||||
|
|
||||||
class Testimonial(Base):
|
class Testimonial(Base):
|
||||||
__tablename__ = "testimonials"
|
__tablename__ = "testimonials"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(128))
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
role: Mapped[str] = mapped_column(String(128), default="")
|
role: Mapped[str] = mapped_column(String(128), default="")
|
||||||
@@ -86,6 +98,8 @@ class Testimonial(Base):
|
|||||||
class Faq(Base):
|
class Faq(Base):
|
||||||
__tablename__ = "faqs"
|
__tablename__ = "faqs"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
question: Mapped[str] = mapped_column(Text)
|
question: Mapped[str] = mapped_column(Text)
|
||||||
answer: Mapped[str] = mapped_column(Text, default="")
|
answer: Mapped[str] = mapped_column(Text, default="")
|
||||||
@@ -94,6 +108,8 @@ class Faq(Base):
|
|||||||
class TeamMember(Base):
|
class TeamMember(Base):
|
||||||
__tablename__ = "team"
|
__tablename__ = "team"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(128))
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
role: Mapped[str] = mapped_column(String(128), default="")
|
role: Mapped[str] = mapped_column(String(128), default="")
|
||||||
@@ -103,6 +119,8 @@ class TeamMember(Base):
|
|||||||
class TimelineItem(Base):
|
class TimelineItem(Base):
|
||||||
__tablename__ = "timeline"
|
__tablename__ = "timeline"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
year: Mapped[str] = mapped_column(String(32))
|
year: Mapped[str] = mapped_column(String(32))
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
@@ -113,6 +131,8 @@ class TimelineItem(Base):
|
|||||||
class Stat(Base):
|
class Stat(Base):
|
||||||
__tablename__ = "stats"
|
__tablename__ = "stats"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
value: Mapped[int] = mapped_column(Integer)
|
value: Mapped[int] = mapped_column(Integer)
|
||||||
suffix: Mapped[str] = mapped_column(String(16), default="")
|
suffix: Mapped[str] = mapped_column(String(16), default="")
|
||||||
@@ -122,6 +142,8 @@ class Stat(Base):
|
|||||||
class WhyUsItem(Base):
|
class WhyUsItem(Base):
|
||||||
__tablename__ = "why_us"
|
__tablename__ = "why_us"
|
||||||
|
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
desc: Mapped[str] = mapped_column(Text, default="")
|
desc: Mapped[str] = mapped_column(Text, default="")
|
||||||
@@ -139,6 +161,200 @@ class BeforeAfter(Base):
|
|||||||
after_label: Mapped[str] = mapped_column(String(128), default="")
|
after_label: 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))
|
||||||
|
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="")
|
||||||
|
title: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
title_highlight: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
subtitle: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
badge_value: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
badge_label: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
cta_primary_label: 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_href: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
scroll_label: 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="")
|
||||||
|
paragraphs: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
quote: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
quote_author: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
stat1_value: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
stat1_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
stat2_value: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
stat2_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
link_label: 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}]
|
||||||
|
|
||||||
|
|
||||||
|
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="")
|
||||||
|
title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
lead: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
image: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
cta_label: 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="")
|
||||||
|
lead: 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))
|
||||||
|
desc: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
points: 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="")
|
||||||
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
|
desc: 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))
|
||||||
|
address: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
hours: 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="")
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSectionContent(Base):
|
||||||
|
"""بخش فرم تماس — رکورد یگانه."""
|
||||||
|
|
||||||
|
__tablename__ = "contact_section"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
lead: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
address_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
phone_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
email_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
name_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
name_placeholder: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
phone_field_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
phone_placeholder: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
note_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
note_optional_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
note_placeholder: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
submit_label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
sending_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
success_title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
success_text: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
success_button_label: 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="")
|
||||||
|
lead: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
placeholder: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
button_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
sending_label: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
success_text: 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="")
|
||||||
|
pages_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
services_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
contact_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
copyright: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
tagline: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
socials: 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="")
|
||||||
|
description: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
class ContactMessage(Base):
|
class ContactMessage(Base):
|
||||||
__tablename__ = "contact_messages"
|
__tablename__ = "contact_messages"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
+346
-4
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime
|
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
|
||||||
|
|
||||||
# ---------- پایه ----------
|
# ---------- پایه ----------
|
||||||
|
|
||||||
@@ -58,7 +58,9 @@ class ArticleOut(ORMModel):
|
|||||||
image: str
|
image: str
|
||||||
read: str
|
read: str
|
||||||
excerpt: str
|
excerpt: str
|
||||||
body: list[str]
|
body: dict
|
||||||
|
status: str
|
||||||
|
seo_keyphrase: str
|
||||||
|
|
||||||
|
|
||||||
class TestimonialOut(ORMModel):
|
class TestimonialOut(ORMModel):
|
||||||
@@ -112,6 +114,144 @@ class BeforeAfterOut(ORMModel):
|
|||||||
after_label: str
|
after_label: str
|
||||||
|
|
||||||
|
|
||||||
|
class NavItemOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
label: str
|
||||||
|
href: str
|
||||||
|
|
||||||
|
|
||||||
|
class HeroOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
kicker: str
|
||||||
|
title: str
|
||||||
|
title_highlight: str
|
||||||
|
subtitle: str
|
||||||
|
badge_value: str
|
||||||
|
badge_label: str
|
||||||
|
cta_primary_label: str
|
||||||
|
cta_primary_href: str
|
||||||
|
cta_secondary_label: str
|
||||||
|
cta_secondary_href: str
|
||||||
|
scroll_label: str
|
||||||
|
|
||||||
|
|
||||||
|
class StoryOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
paragraphs: list[str]
|
||||||
|
quote: str
|
||||||
|
quote_author: str
|
||||||
|
stat1_value: str
|
||||||
|
stat1_label: str
|
||||||
|
stat2_value: str
|
||||||
|
stat2_label: str
|
||||||
|
link_label: str
|
||||||
|
link_href: str
|
||||||
|
bento: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class SectionHeadingOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
title: str
|
||||||
|
lead: str
|
||||||
|
image: str
|
||||||
|
cta_label: str
|
||||||
|
cta_href: str
|
||||||
|
|
||||||
|
|
||||||
|
class PageHeroOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
lead: str
|
||||||
|
image: str
|
||||||
|
|
||||||
|
|
||||||
|
class InvestmentModelOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
desc: str
|
||||||
|
points: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessStepOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
step: str
|
||||||
|
title: str
|
||||||
|
desc: str
|
||||||
|
|
||||||
|
|
||||||
|
class OfficeOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
city: str
|
||||||
|
address: str
|
||||||
|
hours: str
|
||||||
|
|
||||||
|
|
||||||
|
class GalleryImageOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
src: str
|
||||||
|
alt: str
|
||||||
|
|
||||||
|
|
||||||
|
class StatusLabelOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSectionOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
label: str
|
||||||
|
title: str
|
||||||
|
lead: str
|
||||||
|
address_label: str
|
||||||
|
phone_label: str
|
||||||
|
email_label: str
|
||||||
|
name_label: str
|
||||||
|
name_placeholder: str
|
||||||
|
phone_field_label: str
|
||||||
|
phone_placeholder: str
|
||||||
|
note_label: str
|
||||||
|
note_optional_label: str
|
||||||
|
note_placeholder: str
|
||||||
|
submit_label: str
|
||||||
|
sending_label: str
|
||||||
|
success_title: str
|
||||||
|
success_text: str
|
||||||
|
success_button_label: str
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
lead: str
|
||||||
|
placeholder: str
|
||||||
|
button_label: str
|
||||||
|
sending_label: str
|
||||||
|
success_text: str
|
||||||
|
|
||||||
|
|
||||||
|
class FooterOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
about_text: str
|
||||||
|
pages_heading: str
|
||||||
|
services_heading: str
|
||||||
|
contact_heading: str
|
||||||
|
copyright: str
|
||||||
|
tagline: str
|
||||||
|
socials: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class SeoMetaOut(ORMModel):
|
||||||
|
id: int
|
||||||
|
page_key: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
class ContactMessageOut(ORMModel):
|
class ContactMessageOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
@@ -154,6 +294,11 @@ class TokenOut(BaseModel):
|
|||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordIn(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
class BrandIn(BaseModel):
|
class BrandIn(BaseModel):
|
||||||
name: str = ""
|
name: str = ""
|
||||||
logo: str = ""
|
logo: str = ""
|
||||||
@@ -187,6 +332,18 @@ class ProjectIn(BaseModel):
|
|||||||
body: list[str] = []
|
body: 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):
|
class ArticleIn(BaseModel):
|
||||||
slug: str = Field(min_length=1, max_length=128)
|
slug: str = Field(min_length=1, max_length=128)
|
||||||
title: str = Field(min_length=1, max_length=256)
|
title: str = Field(min_length=1, max_length=256)
|
||||||
@@ -195,7 +352,10 @@ class ArticleIn(BaseModel):
|
|||||||
image: str = ""
|
image: str = ""
|
||||||
read: str = ""
|
read: str = ""
|
||||||
excerpt: str = ""
|
excerpt: str = ""
|
||||||
body: list[str] = []
|
body: 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)
|
||||||
|
|
||||||
|
|
||||||
class TestimonialIn(BaseModel):
|
class TestimonialIn(BaseModel):
|
||||||
@@ -242,5 +402,187 @@ class BeforeAfterIn(BaseModel):
|
|||||||
after_label: str = ""
|
after_label: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class NavItemIn(BaseModel):
|
||||||
|
label: str = Field(min_length=1, max_length=64)
|
||||||
|
href: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class HeroIn(BaseModel):
|
||||||
|
kicker: str = ""
|
||||||
|
title: str = ""
|
||||||
|
title_highlight: str = ""
|
||||||
|
subtitle: str = ""
|
||||||
|
badge_value: str = ""
|
||||||
|
badge_label: str = ""
|
||||||
|
cta_primary_label: str = ""
|
||||||
|
cta_primary_href: str = ""
|
||||||
|
cta_secondary_label: str = ""
|
||||||
|
cta_secondary_href: str = ""
|
||||||
|
scroll_label: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class StoryIn(BaseModel):
|
||||||
|
title: str = ""
|
||||||
|
paragraphs: list[str] = []
|
||||||
|
quote: str = ""
|
||||||
|
quote_author: str = ""
|
||||||
|
stat1_value: str = ""
|
||||||
|
stat1_label: str = ""
|
||||||
|
stat2_value: str = ""
|
||||||
|
stat2_label: str = ""
|
||||||
|
link_label: str = ""
|
||||||
|
link_href: str = ""
|
||||||
|
bento: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SectionHeadingIn(BaseModel):
|
||||||
|
key: str = Field(min_length=1, max_length=64)
|
||||||
|
label: str = ""
|
||||||
|
title: str = ""
|
||||||
|
lead: str = ""
|
||||||
|
image: str = ""
|
||||||
|
cta_label: str = ""
|
||||||
|
cta_href: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PageHeroIn(BaseModel):
|
||||||
|
key: str = Field(min_length=1, max_length=32)
|
||||||
|
title: str = ""
|
||||||
|
lead: str = ""
|
||||||
|
image: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class InvestmentModelIn(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=256)
|
||||||
|
desc: str = ""
|
||||||
|
points: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessStepIn(BaseModel):
|
||||||
|
step: str = ""
|
||||||
|
title: str = Field(min_length=1, max_length=256)
|
||||||
|
desc: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class OfficeIn(BaseModel):
|
||||||
|
city: str = Field(min_length=1, max_length=64)
|
||||||
|
address: str = ""
|
||||||
|
hours: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class GalleryImageIn(BaseModel):
|
||||||
|
src: str = ""
|
||||||
|
alt: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class StatusLabelIn(BaseModel):
|
||||||
|
key: str = Field(min_length=1, max_length=16)
|
||||||
|
label: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSectionIn(BaseModel):
|
||||||
|
label: str = ""
|
||||||
|
title: str = ""
|
||||||
|
lead: str = ""
|
||||||
|
address_label: str = ""
|
||||||
|
phone_label: str = ""
|
||||||
|
email_label: str = ""
|
||||||
|
name_label: str = ""
|
||||||
|
name_placeholder: str = ""
|
||||||
|
phone_field_label: str = ""
|
||||||
|
phone_placeholder: str = ""
|
||||||
|
note_label: str = ""
|
||||||
|
note_optional_label: str = ""
|
||||||
|
note_placeholder: str = ""
|
||||||
|
submit_label: str = ""
|
||||||
|
sending_label: str = ""
|
||||||
|
success_title: str = ""
|
||||||
|
success_text: str = ""
|
||||||
|
success_button_label: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterSectionIn(BaseModel):
|
||||||
|
title: str = ""
|
||||||
|
lead: str = ""
|
||||||
|
placeholder: str = ""
|
||||||
|
button_label: str = ""
|
||||||
|
sending_label: str = ""
|
||||||
|
success_text: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class FooterIn(BaseModel):
|
||||||
|
about_text: str = ""
|
||||||
|
pages_heading: str = ""
|
||||||
|
services_heading: str = ""
|
||||||
|
contact_heading: str = ""
|
||||||
|
copyright: str = ""
|
||||||
|
tagline: str = ""
|
||||||
|
socials: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SeoMetaIn(BaseModel):
|
||||||
|
page_key: str = Field(min_length=1, max_length=32)
|
||||||
|
title: str = ""
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class SeoMetaPatch(BaseModel):
|
||||||
|
page_key: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class MessageReadIn(BaseModel):
|
class MessageReadIn(BaseModel):
|
||||||
is_read: bool = True
|
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
|
||||||
|
date: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
image: Optional[str] = None
|
||||||
|
read: Optional[str] = None
|
||||||
|
excerpt: Optional[str] = None
|
||||||
|
body: Optional[dict] = None
|
||||||
|
status: Optional[Literal["draft", "published"]] = None
|
||||||
|
seo_keyphrase: Optional[str] = None
|
||||||
|
_validate_body = field_validator("body")(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]
|
||||||
|
|||||||
+49
-39
@@ -1,16 +1,50 @@
|
|||||||
"""اسکریپت seed دیتابیس.
|
"""اسکریپت seed دیتابیس.
|
||||||
|
|
||||||
اجرا:
|
اجرا:
|
||||||
python -m app.seed # فقط اگر دیتابیس خالی باشد
|
python -m app.seed # فقط جدولهای خالی پر میشوند
|
||||||
python -m app.seed --force # حذف کامل دادهها و ساخت مجدد
|
python -m app.seed --force # حذف کامل دادهها و ساخت مجدد
|
||||||
|
|
||||||
|
پر کردن هر جدول مستقل است: روی یک دیتابیس موجود، فقط جدولهای جدید یا خالی
|
||||||
|
seed میشوند و دادههای قبلی دستنخورده میمانند.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from . import models, seed_data
|
from . import models, seed_data
|
||||||
from .auth import hash_password
|
from .auth import hash_password
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import Base, SessionLocal, engine
|
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, "سئوی صفحات"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def seed(force: bool = False) -> None:
|
def seed(force: bool = False) -> None:
|
||||||
@@ -21,32 +55,19 @@ def seed(force: bool = False) -> None:
|
|||||||
print("جداول قبلی حذف شدند.")
|
print("جداول قبلی حذف شدند.")
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
if not force and db.query(models.Service).count() > 0:
|
for model, rows, label in CONTENT:
|
||||||
print("دیتابیس قبلاً seed شده است. برای بازنشانی کامل: python -m app.seed --force")
|
if db.query(model).count() > 0:
|
||||||
return
|
continue
|
||||||
|
for idx, row in enumerate(rows):
|
||||||
db.add(models.Brand(**seed_data.BRAND))
|
payload = dict(row)
|
||||||
|
if hasattr(model, "position") and "position" not in payload:
|
||||||
for item in seed_data.SERVICES:
|
payload["position"] = idx
|
||||||
db.add(models.Service(**item))
|
# بدنه مقالات در seed بهصورت لیست پاراگراف است — به سند تبدیل میشود
|
||||||
for item in seed_data.STATS:
|
if model is models.Article and isinstance(payload.get("body"), list):
|
||||||
db.add(models.Stat(**item))
|
payload["body"] = paragraphs_to_doc(payload["body"])
|
||||||
for item in seed_data.PROJECTS:
|
db.add(model(**payload))
|
||||||
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))
|
|
||||||
|
|
||||||
if db.query(models.AdminUser).count() == 0:
|
if db.query(models.AdminUser).count() == 0:
|
||||||
db.add(
|
db.add(
|
||||||
@@ -59,19 +80,8 @@ def seed(force: bool = False) -> None:
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
counts = {
|
for model, _, label in CONTENT:
|
||||||
"خدمات": db.query(models.Service).count(),
|
print(f" {label}: {db.query(model).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} رکورد")
|
|
||||||
print("✔ seed با موفقیت انجام شد.")
|
print("✔ seed با موفقیت انجام شد.")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -289,3 +289,170 @@ FAQS = [
|
|||||||
{"question": "خدمات پس از تحویل شامل چه مواردی میشود؟", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دورهای سالانه و تضمین اجرای نما و عایقکاری."},
|
{"question": "خدمات پس از تحویل شامل چه مواردی میشود؟", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دورهای سالانه و تضمین اجرای نما و عایقکاری."},
|
||||||
{"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,239 @@
|
|||||||
|
"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریقشده.
|
||||||
|
|
||||||
|
فرانتاند یک SPA است؛ خزندههای اجتماعی (تلگرام، واتساپ و...) جاوااسکریپت اجرا نمیکنند.
|
||||||
|
این ماژول همان index.html بیلد فرانتاند را میخواند و پیش از ارسال، تگهای
|
||||||
|
title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
||||||
|
بازدیدکنندههای عادی همان SPA همیشگی را میبینند و خزندهها متای درست را میخوانند.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
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 _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,
|
||||||
|
og_image: str = "",
|
||||||
|
og_type: str = "website",
|
||||||
|
jsonld: dict | None = None,
|
||||||
|
noscript: str = "",
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""index.html فرانتاند با متای تزریقشده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
|
||||||
|
title = title.strip() or "گروه ساختمانی آرامش"
|
||||||
|
description = description.strip()
|
||||||
|
canonical = _absolute(path)
|
||||||
|
image = _absolute(og_image)
|
||||||
|
esc = html.escape
|
||||||
|
|
||||||
|
# تگ 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 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:
|
||||||
|
# بیلد فرانتاند سوار نشده — صفحه حداقلی با خودِ متا تا خزندهها دست خالی نمانند
|
||||||
|
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
|
||||||
|
return HTMLResponse(
|
||||||
|
f'<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">'
|
||||||
|
f"<title>{esc(title)}</title>{block}</head><body>{body}</body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
|
||||||
|
raw = raw.replace("</head>", f" {block}\n </head>", 1)
|
||||||
|
if noscript:
|
||||||
|
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
|
||||||
|
return HTMLResponse(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _brand_name(db: Session) -> str:
|
||||||
|
brand = db.query(models.Brand).first()
|
||||||
|
return brand.name if brand else "گروه ساختمانی آرامش"
|
||||||
|
|
||||||
|
|
||||||
|
def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None:
|
||||||
|
return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 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")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sitemap.xml", response_class=Response)
|
||||||
|
def sitemap(db: Session = Depends(get_db)):
|
||||||
|
urls = list(STATIC_PATHS)
|
||||||
|
urls += [
|
||||||
|
f"/blog/{a.slug}"
|
||||||
|
for a in db.query(models.Article)
|
||||||
|
.filter(models.Article.status == "published")
|
||||||
|
.order_by(models.Article.position, models.Article.id)
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
urls += [
|
||||||
|
f"/projects/{p.slug}"
|
||||||
|
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
||||||
|
]
|
||||||
|
items = "\n".join(
|
||||||
|
f" <url><loc>{html.escape(_absolute(u))}</loc></url>" for u in urls
|
||||||
|
)
|
||||||
|
xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\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",
|
||||||
|
og_image=meta.image if meta else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
||||||
|
jsonld = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Article",
|
||||||
|
"headline": article.title,
|
||||||
|
"description": article.excerpt,
|
||||||
|
"image": _absolute(article.image),
|
||||||
|
"inLanguage": "fa",
|
||||||
|
"author": {"@type": "Organization", "name": brand},
|
||||||
|
"publisher": {"@type": "Organization", "name": brand},
|
||||||
|
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
||||||
|
}
|
||||||
|
noscript = f"<h1>{html.escape(article.title)}</h1><p>{html.escape(first_text)}</p>"
|
||||||
|
return _shell(
|
||||||
|
title=f"{article.title} | {brand}",
|
||||||
|
description=article.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)
|
||||||
|
return _shell(
|
||||||
|
title=f"{project.title} | {brand}",
|
||||||
|
description=project.summary,
|
||||||
|
path=f"/projects/{project.slug}",
|
||||||
|
og_image=project.image,
|
||||||
|
jsonld={
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebPage",
|
||||||
|
"name": project.title,
|
||||||
|
"description": project.summary,
|
||||||
|
"image": _absolute(project.image),
|
||||||
|
"inLanguage": "fa",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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
|
fastapi==0.115.8
|
||||||
psycopg[binary]>=3.2.3
|
uvicorn[standard]==0.34.0
|
||||||
pydantic>=2.10.4
|
sqlalchemy==2.0.36
|
||||||
pydantic-settings>=2.6.1
|
psycopg[binary]==3.2.3
|
||||||
PyJWT>=2.10.1
|
pydantic==2.10.4
|
||||||
bcrypt>=4.2.1
|
pydantic-settings==2.6.1
|
||||||
email-validator>=2.2.0
|
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,182 @@
|
|||||||
|
"""تستهای 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
|
||||||
@@ -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`) || 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:
|
||||||
+128
-14
@@ -1,10 +1,28 @@
|
|||||||
|
# مقادیر محرمانه از فایل .env ریشه خوانده میشوند — از .env.example کپی کنید:
|
||||||
|
# cp .env.example .env
|
||||||
|
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README).
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند) و /media (MinIO)
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- --providers.docker=true
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
- --entrypoints.web.address=:80
|
||||||
|
ports:
|
||||||
|
# پورت به 8080 تغییر کرد تا با پورت 5173 سرور لوکال Vite تداخل نکند
|
||||||
|
- "${TRAEFIK_HTTP_PORT:-8080}:80"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: aramesh
|
POSTGRES_USER: aramesh
|
||||||
POSTGRES_PASSWORD: aramesh
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||||
POSTGRES_DB: arameshplaza
|
POSTGRES_DB: arameshplaza
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- db_data:/var/lib/postgresql/data
|
||||||
@@ -14,31 +32,127 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
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
|
||||||
|
- "9000:9000" # دسترسی لوکال به تصاویر و مدیا
|
||||||
|
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
|
||||||
|
|
||||||
|
# یکبار اجرا میشود: ساخت باکت و بازگذاشتن خواندن عمومی برای سرو تصاویر
|
||||||
|
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
|
||||||
|
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'
|
||||||
|
"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000" # اتصال مستقیم بکاند به ویندوز برای فرانتاند لوکال
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://aramesh:aramesh@db:5432/arameshplaza
|
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
||||||
SECRET_KEY: change-me-to-a-long-random-string
|
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
|
||||||
CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]'
|
CORS_ORIGINS: ${CORS_ORIGINS:-'["http://localhost:5173","http://127.0.0.1:5173"]'}
|
||||||
ADMIN_USERNAME: admin
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
ADMIN_PASSWORD: aramesh123
|
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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
minio:
|
||||||
- "8000:8000"
|
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
|
||||||
|
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || 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:
|
frontend:
|
||||||
build:
|
build: ./frontend
|
||||||
context: ./frontend
|
|
||||||
args:
|
|
||||||
VITE_API_URL: ""
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
- minio
|
||||||
- "5173:80"
|
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:
|
volumes:
|
||||||
db_data:
|
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"
|
||||||
|
}
|
||||||
+6
-19
@@ -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 ./
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
RUN npm ci
|
COPY dist /srv
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
+30
-5
@@ -1,8 +1,33 @@
|
|||||||
# 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)
|
سایت React + Vite + Tailwind — تمام متنها و تصاویر از بکاند (`GET /api/site`) خوانده میشود.
|
||||||
|
|
||||||
## Getting Started
|
## اجرا
|
||||||
|
|
||||||
1. Run `npm install`
|
```bash
|
||||||
2. Run `npm run dev`
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند —
|
||||||
|
پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `src/config.ts` از `VITE_API_URL`
|
||||||
|
خوانده میشود؛ پیشفرض خالی یعنی درخواستهای هممبدأ.
|
||||||
|
|
||||||
|
## ساخت
|
||||||
|
|
||||||
|
بیلد روی هاست انجام میشود (کانتینر فقط با Caddy فایلهای `dist/` را سرو میکند):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
> نکته: بکاند برای تزریق متای سئو در صفحات `/blog` و `/projects` همین پوشه `dist/` را
|
||||||
|
> (در compose به `/srv/frontend` سوار است) میخواند — بعد از هر تغییر فرانت، `npm run build`
|
||||||
|
> و سپس ریاستارت بکاند کافی است.
|
||||||
|
|
||||||
|
## بخشهای کلیدی
|
||||||
|
|
||||||
|
- `src/api.tsx` — بارگذاری داده سایت و ادغام پیشنویس مقالهها برای ادمین
|
||||||
|
- `src/admin/` — لایه ویرایش زنده (دوقلوی دیجیتال): نوار مدیریت، کشوی موجودیتها، صفحه نوشتن مقاله
|
||||||
|
- `src/seo.ts` — تگهای title / description / OG / canonical / JSON-LD هر صفحه
|
||||||
|
- `src/data/site.ts` — انواع داده مشترک
|
||||||
|
|||||||
+10
-2
@@ -1,10 +1,18 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,28 +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 ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?)$ {
|
|
||||||
expires 30d;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+680
-3
@@ -9,6 +9,12 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.3",
|
"@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",
|
"framer-motion": "^11.5.4",
|
||||||
"lucide-react": "0.522.0",
|
"lucide-react": "0.522.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -922,6 +928,34 @@
|
|||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"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",
|
||||||
|
"optional": true,
|
||||||
|
"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",
|
||||||
|
"optional": true,
|
||||||
|
"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",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@humanwhocodes/config-array": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||||
@@ -1427,6 +1461,465 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -1506,14 +1999,12 @@
|
|||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.31",
|
"version": "18.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@@ -1524,7 +2015,6 @@
|
|||||||
"version": "18.3.7",
|
"version": "18.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
@@ -1537,6 +2027,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "5.62.0",
|
"version": "5.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
||||||
@@ -2578,6 +3074,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -3168,6 +3673,12 @@
|
|||||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -3393,6 +3904,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/p-limit": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
@@ -3721,6 +4238,145 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -3952,6 +4608,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
@@ -4393,6 +5055,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -4460,6 +5131,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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.3",
|
"@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",
|
"framer-motion": "^11.5.4",
|
||||||
"lucide-react": "0.522.0",
|
"lucide-react": "0.522.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
+45
-6
@@ -16,6 +16,11 @@ import { ArticleDetail } from './pages/ArticleDetail';
|
|||||||
import { FaqPage } from './pages/FaqPage';
|
import { FaqPage } from './pages/FaqPage';
|
||||||
import { ContactPage } from './pages/ContactPage';
|
import { ContactPage } from './pages/ContactPage';
|
||||||
import { SiteProvider, useSiteData } from './api';
|
import { SiteProvider, useSiteData } 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';
|
||||||
|
|
||||||
function ScrollToTop() {
|
function ScrollToTop() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -35,8 +40,11 @@ function AnimatedRoutes() {
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
||||||
|
|
||||||
<Routes location={location}>
|
<Routes location={location}>
|
||||||
|
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||||
|
<Route path="/admin/write" element={<ArticleWritePage />} />
|
||||||
|
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
@@ -54,6 +62,38 @@ function AnimatedRoutes() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppChrome() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const { isAuthenticated } = useAdmin();
|
||||||
|
const { data } = useSiteData();
|
||||||
|
const isAdminPage = pathname.startsWith('/admin');
|
||||||
|
|
||||||
|
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحههای جزئیات که خودشان ست میکنند
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdminPage || !data) 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,
|
||||||
|
});
|
||||||
|
}, [pathname, data, isAdminPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isAdminPage && <Header />}
|
||||||
|
<AnimatedRoutes />
|
||||||
|
{!isAdminPage && <Footer />}
|
||||||
|
{!isAdminPage && <FloatingContact />}
|
||||||
|
{isAuthenticated && !isAdminPage && <AdminBar />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
const { data, error, reload } = useSiteData();
|
const { data, error, reload } = useSiteData();
|
||||||
|
|
||||||
@@ -77,10 +117,7 @@ function AppShell() {
|
|||||||
) : data ? (
|
) : data ? (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Header />
|
<AppChrome />
|
||||||
<AnimatedRoutes />
|
|
||||||
<Footer />
|
|
||||||
<FloatingContact />
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +132,9 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteProvider>
|
<SiteProvider>
|
||||||
<AppShell />
|
<AdminProvider>
|
||||||
|
<AppShell />
|
||||||
|
</AdminProvider>
|
||||||
</SiteProvider>
|
</SiteProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
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 } = 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>
|
||||||
|
|
||||||
|
<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,162 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||||
|
const { reload } = useSiteData()
|
||||||
|
|
||||||
|
const toggleEditMode = useCallback(() => {
|
||||||
|
setIsEditMode((v) => {
|
||||||
|
sessionStorage.setItem('aramesh_edit_mode', v ? '0' : '1')
|
||||||
|
return !v
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پرکاربردترین کمکساز ویرایش: یک فیلد واحد را PATCH میکند و پس از موفقیت
|
||||||
|
* همان تغییر را بهصورت خوشبینانه در درخت داده سایت اعمال میکند.
|
||||||
|
*/
|
||||||
|
export function useFieldSaver() {
|
||||||
|
const { runSave } = useAdmin()
|
||||||
|
const { updateSite } = useSiteData()
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(
|
||||||
|
resource: string,
|
||||||
|
id: number,
|
||||||
|
changes: Record<string, unknown>,
|
||||||
|
apply: (data: SiteData) => void,
|
||||||
|
): Promise<boolean> =>
|
||||||
|
runSave(async () => {
|
||||||
|
await adminApi.patch(resource, id, changes)
|
||||||
|
updateSite(apply)
|
||||||
|
}),
|
||||||
|
[runSave, updateSite],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,375 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
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 type { ArticleBodyDoc } from '../data/site'
|
||||||
|
|
||||||
|
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||||||
|
|
||||||
|
type Draft = {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
date: string
|
||||||
|
category: string
|
||||||
|
image: string
|
||||||
|
read: string
|
||||||
|
excerpt: string
|
||||||
|
body: ArticleBodyDoc
|
||||||
|
status: 'draft' | 'published'
|
||||||
|
seo_keyphrase: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_TEXT: Record<string, string> = {
|
||||||
|
idle: 'ذخیره شد',
|
||||||
|
saving: 'در حال ذخیره…',
|
||||||
|
error: 'خطا در ذخیره',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* صفحه تمامعرض نوشتن/ویرایش مقاله — محیط نوشتن متمرکز بدون چرخه سایت.
|
||||||
|
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||||
|
*/
|
||||||
|
export function ArticleWritePage() {
|
||||||
|
const { isAuthenticated, saveStatus, runSave } = 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: '',
|
||||||
|
date: new Date().toLocaleDateString('fa-IR'),
|
||||||
|
category: 'یادداشت',
|
||||||
|
image: '',
|
||||||
|
read: '۵ دقیقه',
|
||||||
|
excerpt: '',
|
||||||
|
body: EMPTY_DOC,
|
||||||
|
status: 'draft',
|
||||||
|
seo_keyphrase: '',
|
||||||
|
})
|
||||||
|
const [createdId, setCreatedId] = useState<number | null>(null)
|
||||||
|
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
|
||||||
|
setDraft({
|
||||||
|
...article,
|
||||||
|
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
|
||||||
|
})
|
||||||
|
setCreatedId(article.id)
|
||||||
|
// 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 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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={`/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>
|
||||||
|
|
||||||
|
{/* عنوان */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...textField('title')}
|
||||||
|
placeholder="عنوان مقاله…"
|
||||||
|
aria-label="عنوان مقاله"
|
||||||
|
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="mt-4 h-px w-28 bg-copper" />
|
||||||
|
|
||||||
|
{/* تصویر شاخص */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<ImageField
|
||||||
|
label="تصویر شاخص مقاله"
|
||||||
|
value={draft.image}
|
||||||
|
onSave={async (url) => {
|
||||||
|
queueSave({ image: url })
|
||||||
|
return true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* متن */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<ArticleEditor
|
||||||
|
doc={draft.body as unknown as Record<string, unknown>}
|
||||||
|
onChange={(json) => queueSave({ body: json })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* چکیده و متادیتا */}
|
||||||
|
<SeoChecklist
|
||||||
|
input={{
|
||||||
|
title: draft.title,
|
||||||
|
slug: draft.slug,
|
||||||
|
excerpt: 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>
|
||||||
|
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
{...textField('excerpt')}
|
||||||
|
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-light text-ivory/50">دسته</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...textField('category')}
|
||||||
|
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>
|
||||||
|
<label className="text-xs font-light text-ivory/50">تاریخ</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...textField('date')}
|
||||||
|
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>
|
||||||
|
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...textField('read')}
|
||||||
|
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>
|
||||||
|
<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,339 @@
|
|||||||
|
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 type { EntityConfig, FieldConfig } from './entities'
|
||||||
|
import { ImageField } from '../components/admin/ImageField'
|
||||||
|
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 } = useAdmin()
|
||||||
|
const { reload } = useSiteData()
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const flushRef = useRef<() => void>(() => {})
|
||||||
|
|
||||||
|
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">
|
||||||
|
{config.fields.map((field) => (
|
||||||
|
<FieldControl
|
||||||
|
key={field.name}
|
||||||
|
field={field}
|
||||||
|
value={item[field.name]}
|
||||||
|
onSave={(value) => saveField(field.name, value)}
|
||||||
|
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,
|
||||||
|
onSave,
|
||||||
|
registerFlush,
|
||||||
|
}: {
|
||||||
|
field: FieldConfig
|
||||||
|
value: unknown
|
||||||
|
onSave: (value: unknown) => Promise<boolean>
|
||||||
|
registerFlush?: (fn: () => void) => void
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState<string>(String(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 ?? '')} onSave={(url) => onSave(url)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'wysiwyg') {
|
||||||
|
return <WysiwygField field={field} value={value} onSave={onSave} registerFlush={registerFlush} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'list') {
|
||||||
|
return <ListField field={field} value={value} onSave={onSave} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'textarea') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||||
|
<textarea
|
||||||
|
dir="auto"
|
||||||
|
rows={4}
|
||||||
|
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'}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
||||||
|
function ListField({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
field: FieldConfig
|
||||||
|
value: unknown
|
||||||
|
onSave: (value: unknown) => Promise<boolean>
|
||||||
|
}) {
|
||||||
|
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>}
|
||||||
|
<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,
|
||||||
|
}: {
|
||||||
|
field: FieldConfig
|
||||||
|
value: unknown
|
||||||
|
onSave: (value: unknown) => Promise<boolean>
|
||||||
|
registerFlush?: (fn: () => void) => void
|
||||||
|
}) {
|
||||||
|
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
|
||||||
|
value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||||
|
)
|
||||||
|
const 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>
|
||||||
|
<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,203 @@
|
|||||||
|
import type { SiteData } from '../api'
|
||||||
|
|
||||||
|
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg'
|
||||||
|
|
||||||
|
export type FieldConfig = {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
type: FieldType
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EntityConfig = {
|
||||||
|
/** مسیر منبع در پنل مدیریت، مثل services یا why-us */
|
||||||
|
resource: string
|
||||||
|
/** کلید همان منبع در پاسخ GET /api/site */
|
||||||
|
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): FieldConfig => ({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
hint,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** پیکربندی همه منابع قابل ویرایش — هم مجموعهها و هم رکوردهای یگانه */
|
||||||
|
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')],
|
||||||
|
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
resource: 'projects', dataKey: 'projects', title: 'پروژهها', kind: 'collection',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [
|
||||||
|
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('location', 'موقعیت'),
|
||||||
|
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'),
|
||||||
|
F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'),
|
||||||
|
F('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('image', 'تصویر شاخص', 'image'),
|
||||||
|
F('category', 'دسته'),
|
||||||
|
F('date', 'تاریخ'),
|
||||||
|
F('read', 'زمان مطالعه'),
|
||||||
|
F('slug', 'نشانی (slug)'),
|
||||||
|
],
|
||||||
|
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', 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')],
|
||||||
|
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
|
||||||
|
titleKey: 'label',
|
||||||
|
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number'), 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'), F('alt', 'متن جایگزین')],
|
||||||
|
defaults: { src: '', alt: 'تصویر گالری' },
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
|
||||||
|
titleKey: 'label',
|
||||||
|
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
|
||||||
|
defaults: { label: 'آیتم جدید', href: '/' },
|
||||||
|
},
|
||||||
|
|
||||||
|
seo_meta: {
|
||||||
|
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||||
|
titleKey: 'page_key',
|
||||||
|
fields: [
|
||||||
|
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact'),
|
||||||
|
F('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'), F('hero', 'تصویر هیرو', 'image'), F('contact_bg', 'پسزمینه بخش تماس', 'image'), F('phone', 'تلفن نمایشی'), F('phone_href', 'لینک تلفن'), F('email', 'ایمیل'), F('address', 'نشانی', 'textarea')],
|
||||||
|
defaults: {},
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی'), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم'), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||||
|
defaults: {},
|
||||||
|
},
|
||||||
|
story: {
|
||||||
|
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [F('quote', 'نقلقول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقلقول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه')],
|
||||||
|
defaults: {},
|
||||||
|
},
|
||||||
|
before_after: {
|
||||||
|
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||||
|
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 getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
|
||||||
|
return data[config.dataKey as keyof SiteData] as never
|
||||||
|
}
|
||||||
+184
-4
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { Article, Project, Service } from './data/site'
|
import type { Article, Project, Service } from './data/site'
|
||||||
import { API_URL } from './config'
|
import { API_URL } from './config'
|
||||||
|
import { getToken } from './admin/adminClient'
|
||||||
|
|
||||||
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
||||||
export type BrandInfo = {
|
export type BrandInfo = {
|
||||||
@@ -32,8 +33,109 @@ export type BeforeAfterInfo = {
|
|||||||
after_label: string
|
after_label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NavItem = { id: number; label: string; href: string }
|
||||||
|
|
||||||
|
export type HeroContent = {
|
||||||
|
id: number
|
||||||
|
kicker: string
|
||||||
|
title: string
|
||||||
|
title_highlight: string
|
||||||
|
subtitle: string
|
||||||
|
badge_value: string
|
||||||
|
badge_label: string
|
||||||
|
cta_primary_label: string
|
||||||
|
cta_primary_href: string
|
||||||
|
cta_secondary_label: string
|
||||||
|
cta_secondary_href: string
|
||||||
|
scroll_label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoryContent = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
paragraphs: string[]
|
||||||
|
quote: string
|
||||||
|
quote_author: string
|
||||||
|
stat1_value: string
|
||||||
|
stat1_label: string
|
||||||
|
stat2_value: string
|
||||||
|
stat2_label: string
|
||||||
|
link_label: string
|
||||||
|
link_href: string
|
||||||
|
bento: { src: string; alt: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SectionHeadingContent = {
|
||||||
|
id: number
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
title: string
|
||||||
|
lead: string
|
||||||
|
image: string
|
||||||
|
cta_label: string
|
||||||
|
cta_href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageHeroContent = { id: number; key: string; title: string; lead: string; image: string }
|
||||||
|
|
||||||
|
export type InvestmentModelItem = { id: number; title: string; desc: string; points: string[] }
|
||||||
|
export type ProcessStepItem = { id: number; step: string; title: string; desc: string }
|
||||||
|
export type OfficeItem = { id: number; city: string; address: string; hours: string }
|
||||||
|
export type GalleryImageItem = { id: number; src: string; alt: string }
|
||||||
|
export type StatusLabelItem = { id: number; key: string; label: string }
|
||||||
|
|
||||||
|
export type ContactSectionContent = {
|
||||||
|
id: number
|
||||||
|
label: string
|
||||||
|
title: string
|
||||||
|
lead: string
|
||||||
|
address_label: string
|
||||||
|
phone_label: string
|
||||||
|
email_label: string
|
||||||
|
name_label: string
|
||||||
|
name_placeholder: string
|
||||||
|
phone_field_label: string
|
||||||
|
phone_placeholder: string
|
||||||
|
note_label: string
|
||||||
|
note_optional_label: string
|
||||||
|
note_placeholder: string
|
||||||
|
submit_label: string
|
||||||
|
sending_label: string
|
||||||
|
success_title: string
|
||||||
|
success_text: string
|
||||||
|
success_button_label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewsletterContent = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
lead: string
|
||||||
|
placeholder: string
|
||||||
|
button_label: string
|
||||||
|
sending_label: string
|
||||||
|
success_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SocialItem = { label: string; href: string; icon: string }
|
||||||
|
|
||||||
|
export type FooterContent = {
|
||||||
|
id: number
|
||||||
|
about_text: string
|
||||||
|
pages_heading: string
|
||||||
|
services_heading: string
|
||||||
|
contact_heading: string
|
||||||
|
copyright: string
|
||||||
|
tagline: string
|
||||||
|
socials: SocialItem[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface SiteData {
|
export interface SiteData {
|
||||||
brand: BrandInfo
|
brand: BrandInfo
|
||||||
|
nav: NavItem[]
|
||||||
|
hero: HeroContent
|
||||||
|
story: StoryContent
|
||||||
|
section_headings: SectionHeadingContent[]
|
||||||
|
page_heroes: PageHeroContent[]
|
||||||
services: Service[]
|
services: Service[]
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
articles: Article[]
|
articles: Article[]
|
||||||
@@ -44,15 +146,31 @@ export interface SiteData {
|
|||||||
stats: StatItem[]
|
stats: StatItem[]
|
||||||
why_us: WhyUsItem[]
|
why_us: WhyUsItem[]
|
||||||
before_after: BeforeAfterInfo
|
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; description: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SiteContextValue {
|
interface SiteContextValue {
|
||||||
data: SiteData | null
|
data: SiteData | null
|
||||||
error: boolean
|
error: boolean
|
||||||
reload: () => void
|
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 }) {
|
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||||
const [data, setData] = useState<SiteData | null>(null)
|
const [data, setData] = useState<SiteData | null>(null)
|
||||||
@@ -66,9 +184,27 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
return res.json() as Promise<SiteData>
|
return res.json() as Promise<SiteData>
|
||||||
})
|
})
|
||||||
.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 (!cancelled) {
|
||||||
if (json.brand && json.before_after) setData(json)
|
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
||||||
else setError(true)
|
else setError(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -88,7 +224,20 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
|||||||
load()
|
load()
|
||||||
}, [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 {
|
export function useSiteData(): SiteContextValue {
|
||||||
@@ -103,3 +252,34 @@ export function useSite(): SiteData {
|
|||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return data?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** هیرو بالای صفحه بر اساس key تعریفشده در پنل مدیریت */
|
||||||
|
export function usePageHero(key: string): PageHeroContent {
|
||||||
|
const { data } = useContext(SiteContext)
|
||||||
|
return (
|
||||||
|
data?.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)
|
||||||
|
return data?.status_labels.find((s) => s.key === key)?.label ?? key
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,39 +7,43 @@ export function ArticleCard({ article }: { article: Article }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/blog/${article.slug}`}
|
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"
|
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||||
aria-label={`خواندن مقاله ${article.title}`}
|
aria-label={`خواندن مقاله ${article.title}`}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={article.image}
|
src={article.image}
|
||||||
alt={`تصویر مقاله ${article.title}`}
|
alt={`تصویر مقاله ${article.title}`}
|
||||||
className="h-52 w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105 transform-gpu"
|
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
|
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||||
|
<div className="absolute right-4 top-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||||
|
{article.category}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col p-8">
|
<div className="flex flex-1 flex-col p-7">
|
||||||
<p className="text-xs font-light text-copper">{article.category}</p>
|
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||||
|
|
||||||
<h3 className="mt-4 text-lg font-bold leading-9 text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
|
||||||
{article.title}
|
{article.title}
|
||||||
</h3>
|
</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}
|
{article.excerpt}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
|
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
||||||
<span className="flex items-center gap-4">
|
<span className="flex items-center gap-3">
|
||||||
<span>{article.date}</span>
|
<span>{article.date}</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<ClockIcon className="h-3.5 w-3.5" />
|
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||||
{article.read}
|
{article.read}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2 text-copper">
|
|
||||||
|
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
|
||||||
ادامه مطلب
|
ادامه مطلب
|
||||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 ease-out group-hover:-translate-x-1.5" />
|
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,26 +9,33 @@ type BaseProps = {
|
|||||||
className?: string
|
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: {
|
copper: {
|
||||||
base: 'bg-copper text-char',
|
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
|
||||||
fill: 'bg-char',
|
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)]',
|
||||||
text: 'group-hover:text-ivory',
|
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: {
|
outline: {
|
||||||
base: 'border border-ivory/30 text-ivory',
|
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبهها
|
||||||
fill: 'bg-copper',
|
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)]',
|
||||||
text: 'group-hover:text-char',
|
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: {
|
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',
|
fill: 'bg-copper',
|
||||||
text: 'group-hover:text-char',
|
text: 'group-hover:text-char',
|
||||||
|
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
|
||||||
},
|
},
|
||||||
'dark-ivory': {
|
'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',
|
fill: 'bg-ivory',
|
||||||
text: 'group-hover:text-char',
|
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]
|
const style = STYLES[variant]
|
||||||
return (
|
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
|
<span
|
||||||
aria-hidden="true"
|
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
|
<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}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
@@ -49,8 +62,9 @@ function Inner({ children, variant = 'copper' }: { children: React.ReactNode; va
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشهای
|
||||||
const SHELL =
|
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({
|
export function ButtonLink({
|
||||||
to,
|
to,
|
||||||
@@ -58,8 +72,9 @@ export function ButtonLink({
|
|||||||
variant = 'copper',
|
variant = 'copper',
|
||||||
className = '',
|
className = '',
|
||||||
}: BaseProps & { to: string }) {
|
}: BaseProps & { to: string }) {
|
||||||
|
const style = STYLES[variant]
|
||||||
return (
|
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>
|
<Inner variant={variant}>{children}</Inner>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@@ -72,8 +87,9 @@ export function ButtonAnchor({
|
|||||||
className = '',
|
className = '',
|
||||||
...rest
|
...rest
|
||||||
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||||
|
const style = STYLES[variant]
|
||||||
return (
|
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>
|
<Inner variant={variant}>{children}</Inner>
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
@@ -85,8 +101,9 @@ export function Button({
|
|||||||
className = '',
|
className = '',
|
||||||
...rest
|
...rest
|
||||||
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
const style = STYLES[variant]
|
||||||
return (
|
return (
|
||||||
<button className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
|
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||||
<Inner variant={variant}>{children}</Inner>
|
<Inner variant={variant}>{children}</Inner>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
|
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
import { Reveal } from './Reveal'
|
import { Reveal } from './Reveal'
|
||||||
import { Button } from './Button'
|
import { Button } from './Button'
|
||||||
import { useSite } from '../api'
|
import { useSite } from '../api'
|
||||||
|
import { useFieldSaver } from '../admin/AdminContext'
|
||||||
|
import { InlineEdit } from './admin/InlineEdit'
|
||||||
|
import { SingletonPill } from '../admin/Editable'
|
||||||
import { API_URL } from '../config'
|
import { API_URL } from '../config'
|
||||||
|
|
||||||
export function ContactSection() {
|
export function ContactSection() {
|
||||||
const { brand } = useSite()
|
const { brand, contact_section: content } = useSite()
|
||||||
|
const save = useFieldSaver()
|
||||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||||
const [sent, setSent] = useState(false)
|
const [sent, setSent] = useState(false)
|
||||||
const [sending, setSending] = 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) => {
|
const showErrorAlert = (title: string, text: string) => {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'warning',
|
icon: 'warning',
|
||||||
@@ -23,11 +37,11 @@ export function ContactSection() {
|
|||||||
confirmButtonText: 'متوجه شدم',
|
confirmButtonText: 'متوجه شدم',
|
||||||
buttonsStyling: false,
|
buttonsStyling: false,
|
||||||
customClass: {
|
customClass: {
|
||||||
container: 'backdrop-blur-[8px] bg-ink/60',
|
container: 'backdrop-blur-md bg-ink/70',
|
||||||
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
|
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',
|
title: 'font-pinar font-bold text-lg text-ivory pt-2',
|
||||||
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
|
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: {
|
showClass: {
|
||||||
popup: 'swal2-show',
|
popup: 'swal2-show',
|
||||||
@@ -36,7 +50,7 @@ export function ContactSection() {
|
|||||||
hideClass: {
|
hideClass: {
|
||||||
popup: 'swal2-hide',
|
popup: 'swal2-hide',
|
||||||
backdrop: 'swal2-backdrop-hide',
|
backdrop: 'swal2-backdrop-hide',
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,53 +104,73 @@ export function ContactSection() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
<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="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="mx-auto grid max-w-6xl items-stretch gap-8 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="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>
|
<Reveal>
|
||||||
<p className="text-sm font-light text-copper">تماس با ما</p>
|
<p className="text-sm font-light tracking-wide text-copper">
|
||||||
<h2 id="contact-title" className="mt-5 text-section font-bold text-ivory">
|
<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>
|
</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>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<ul className="mt-10 space-y-6">
|
<ul className="mt-10 space-y-6">
|
||||||
<li className="flex items-start gap-4">
|
<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>
|
<div>
|
||||||
<p className="text-sm font-bold text-ivory">دفتر مرکزی</p>
|
<p className="text-sm font-bold text-ivory">
|
||||||
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">{brand.address}</p>
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li className="flex items-start gap-4">
|
<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>
|
<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
|
<a
|
||||||
href={brand.phone_href}
|
href={brand.phone_href}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||||
>
|
>
|
||||||
{brand.phone}
|
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li className="flex items-start gap-4">
|
<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>
|
<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
|
<a
|
||||||
href={`mailto:${brand.email}`}
|
href={`mailto:${brand.email}`}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||||
>
|
>
|
||||||
{brand.email}
|
<InlineEdit value={brand.email} onSave={(v) => saveBrand('email', v)} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -144,13 +178,19 @@ export function ContactSection() {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</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 ? (
|
{sent ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
|
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
|
||||||
<CheckCircle2Icon className="h-12 w-12 text-copper" />
|
<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)]">
|
||||||
<h3 className="mt-6 text-xl font-bold text-ivory">درخواست شما ثبت شد</h3>
|
<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">
|
<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>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -161,28 +201,28 @@ export function ContactSection() {
|
|||||||
setForm({ name: '', phone: '', note: '' })
|
setForm({ name: '', phone: '', note: '' })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ثبت درخواست جدید
|
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="contact-name"
|
id="contact-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
placeholder="نام و نام خانوادگی"
|
placeholder={content.name_placeholder}
|
||||||
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
|
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="contact-phone" className="block text-sm font-bold text-ivory/90">
|
<label htmlFor="contact-phone" className="block text-sm font-medium text-ivory/90">
|
||||||
شماره تلفن <span className="text-copper">*</span>
|
<InlineEdit value={content.phone_field_label} onSave={(v) => saveContent('phone_field_label', v)} /> <span className="text-copper">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="contact-phone"
|
id="contact-phone"
|
||||||
@@ -191,28 +231,40 @@ export function ContactSection() {
|
|||||||
required
|
required
|
||||||
value={form.phone}
|
value={form.phone}
|
||||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||||
placeholder="09xx xxx xxxx"
|
placeholder={content.phone_placeholder}
|
||||||
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-right text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
|
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-right text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label htmlFor="contact-note" className="block text-sm font-bold text-ivory/90">
|
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
|
||||||
توضیحات <span className="font-light text-ivory/50">(اختیاری)</span>
|
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{content.note_optional_label}</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="contact-note"
|
id="contact-note"
|
||||||
rows={6}
|
rows={5}
|
||||||
value={form.note}
|
value={form.note}
|
||||||
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||||
placeholder="درباره پروژه یا درخواست خود بنویسید…"
|
placeholder={content.note_placeholder}
|
||||||
className="mt-3 w-full resize-none border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light leading-8 text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
|
className="mt-3 w-full resize-none rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light leading-8 text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2 flex justify-start">
|
||||||
<Button type="submit" variant="copper" className="w-full md:w-auto md:px-12" disabled={sending}>
|
<Button
|
||||||
{sending ? 'در حال ارسال…' : 'ارسال درخواست مشاوره'}
|
type="submit"
|
||||||
|
variant="copper"
|
||||||
|
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
||||||
|
disabled={sending}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
content.sending_label
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||||
|
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,14 +4,27 @@ import { useSite } from '../api';
|
|||||||
|
|
||||||
export function FloatingContact() {
|
export function FloatingContact() {
|
||||||
const { brand } = useSite();
|
const { brand } = useSite();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={brand.phone_href}
|
href={brand.phone_href}
|
||||||
aria-label="تماس تلفنی با گروه ساختمانی آرامش"
|
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">
|
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.3),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 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" />
|
<span
|
||||||
</a>);
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-full border border-copper/60 animate-ping opacity-30"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* خط انعکاس نوری کریستال در بالای گوی */}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/25 to-transparent"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* آیکون تلفن با انیمیشن چرخش ظریف در زمان هاور */}
|
||||||
|
<PhoneIcon className="relative z-10 h-6 w-6 transition-transform duration-300 ease-lux group-hover:rotate-12" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,68 +1,109 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||||
import { NAV } from '../data/site';
|
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
|
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() {
|
export function Footer() {
|
||||||
const { brand, services } = useSite();
|
const { brand, services, nav, footer } = useSite();
|
||||||
|
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 (
|
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="mx-auto max-w-site px-6 py-16 lg:px-10">
|
||||||
<div className="grid gap-12 lg:grid-cols-12">
|
<div className="grid gap-12 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4">
|
||||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
<div className="relative w-fit">
|
||||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/55">
|
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||||
گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمانهایی میسازد که قرار است چند نسل در آنها زندگی کنند؛ معماری آرام،
|
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||||
اجرای دقیق و سرمایهگذاری شفاف.
|
</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>
|
</p>
|
||||||
<div className="mt-6 flex gap-3">
|
{/* آیکونهای شبکههای اجتماعی: گویهای شیشهای شناور */}
|
||||||
{[
|
<div className="mt-8 flex items-center gap-3">
|
||||||
{ Icon: InstagramIcon, label: 'اینستاگرام' },
|
{footer.socials.map(({ label, href, icon }) => {
|
||||||
{ Icon: LinkedinIcon, label: 'لینکدین' },
|
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
||||||
{ Icon: SendIcon, label: 'تلگرام' }].
|
return (
|
||||||
map(({ Icon, label }) =>
|
<a
|
||||||
<a
|
key={label}
|
||||||
key={label}
|
href={href}
|
||||||
href="#"
|
aria-label={label}
|
||||||
aria-label={label}
|
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
|
||||||
className="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 transition-transform duration-300 group-hover:scale-110" />
|
||||||
<Icon className="h-4 w-4" />
|
</a>
|
||||||
</a>
|
);
|
||||||
)}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
<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">
|
<ul className="mt-5 space-y-3">
|
||||||
{NAV.map((item) =>
|
{nav.map((item) => (
|
||||||
<li key={item.to}>
|
<EditableItem key={item.id} resource="nav" item={item}>
|
||||||
<Link
|
<li>
|
||||||
to={item.to}
|
<Link
|
||||||
className="text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper">
|
to={item.href}
|
||||||
|
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||||
{item.label}
|
>
|
||||||
</Link>
|
<InlineEdit
|
||||||
</li>
|
value={item.label}
|
||||||
)}
|
onSave={(v) => saveNav(item.id, { label: v })}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</EditableItem>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div className="mt-4">
|
||||||
|
<AddItemButton resource="nav" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-3">
|
<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">
|
<ul className="mt-5 space-y-3">
|
||||||
{services.map((service) =>
|
{services.map((service) => (
|
||||||
<li key={service.title} className="text-sm font-light text-ivory/60">
|
<li key={service.id} className="text-sm font-light text-ivory/60">
|
||||||
{service.title}
|
{service.title}
|
||||||
</li>
|
</li>
|
||||||
)}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-3">
|
<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">
|
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
|
||||||
<li className="flex items-start gap-3">
|
<li className="flex items-start gap-3">
|
||||||
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
||||||
@@ -84,11 +125,28 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<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>طراحی و ساخت با دقتِ معماری</p>
|
<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>بازگشت به بالا</span>
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
||||||
|
<ArrowUpIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>);
|
</footer>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||||
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
||||||
import { NAV } from "../data/site";
|
|
||||||
import { useSite } from "../api";
|
import { useSite } from "../api";
|
||||||
import { use } from "framer-motion/m";
|
import { useFieldSaver } from "../admin/AdminContext";
|
||||||
|
import { InlineEdit } from "./admin/InlineEdit";
|
||||||
|
import { EditableItem, SingletonPill } from "../admin/Editable";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { brand } = useSite();
|
const { brand, nav } = useSite();
|
||||||
|
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 [scrolled, setScrolled] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -23,75 +31,82 @@ export function Header() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<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
|
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"
|
: "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">
|
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
|
||||||
<Link
|
<div className="relative flex items-center">
|
||||||
to="/"
|
<Link
|
||||||
className="flex items-center gap-3"
|
to="/"
|
||||||
aria-label={brand.name}
|
className="flex items-center gap-3"
|
||||||
>
|
aria-label={brand.name}
|
||||||
<img
|
>
|
||||||
src={brand.logo}
|
<img
|
||||||
alt={brand.name}
|
src={brand.logo}
|
||||||
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
|
alt={brand.name}
|
||||||
scrolled ? "h-14" : "h-20"
|
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
|
||||||
}`}
|
scrolled ? "h-14" : "h-20"
|
||||||
/>
|
}`}
|
||||||
</Link>
|
/>
|
||||||
|
</Link>
|
||||||
|
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
|
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
|
||||||
<ul className="flex items-center gap-7">
|
<ul className="flex items-center gap-7">
|
||||||
{NAV.map((item) => (
|
{nav.map((item) => (
|
||||||
<li key={item.to}>
|
<EditableItem key={item.id} resource="nav" item={item}>
|
||||||
<NavLink
|
<li>
|
||||||
to={item.to}
|
<NavLink
|
||||||
className={({ isActive }) =>
|
to={item.href}
|
||||||
`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)] ${
|
className={({ isActive }) =>
|
||||||
isActive ? "text-copper" : "text-ivory/80"
|
`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 }) => (
|
>
|
||||||
<>
|
{({ isActive }) => (
|
||||||
{item.label}
|
<>
|
||||||
<span
|
<InlineEdit
|
||||||
className={`absolute -bottom-0.5 left-0 right-0 h-px w-full origin-center bg-copper transition-transform duration-300 ease-lux ${
|
value={item.label}
|
||||||
isActive
|
onSave={(v) => saveNav(item.id, { label: v })}
|
||||||
? "scale-x-100"
|
/>
|
||||||
: "scale-x-0 group-hover:scale-x-100"
|
<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>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
</EditableItem>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{/* دکمه تماس کپسولی شیشهای لوکس */}
|
||||||
<a
|
<a
|
||||||
href={brand.phone_href}
|
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
|
<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">
|
||||||
aria-hidden="true"
|
<PhoneIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
||||||
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>
|
</span>
|
||||||
|
<span dir="ltr" className="tracking-wide">{brand.phone}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{/* دکمه منوی موبایل دایرهای شیشهای */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
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"
|
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/5 text-ivory backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 xl:hidden"
|
||||||
aria-label={open ? "بستن منو" : "باز کردن منو"}
|
aria-label={open ? "بستن منو" : "باز کردن منو"}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
@@ -107,26 +122,31 @@ export function Header() {
|
|||||||
{open && (
|
{open && (
|
||||||
<nav
|
<nav
|
||||||
aria-label="ناوبری موبایل"
|
aria-label="ناوبری موبایل"
|
||||||
className="border-t border-ivory/10 bg-ink/98 px-6 py-4 xl:hidden"
|
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">
|
<ul className="grid gap-1">
|
||||||
{NAV.map((item) => (
|
{nav.map((item) => (
|
||||||
<li key={item.to}>
|
<EditableItem key={item.id} resource="nav" item={item}>
|
||||||
<NavLink
|
<li>
|
||||||
to={item.to}
|
<NavLink
|
||||||
className={({ isActive }) =>
|
to={item.href}
|
||||||
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
|
className={({ isActive }) =>
|
||||||
isActive ? "text-copper" : "text-ivory/80"
|
`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>
|
<InlineEdit
|
||||||
</li>
|
value={item.label}
|
||||||
|
onSave={(v) => saveNav(item.id, { label: v })}
|
||||||
|
/>
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
</EditableItem>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,52 +2,88 @@ import React from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { ChevronLeftIcon } from 'lucide-react'
|
import { ChevronLeftIcon } from 'lucide-react'
|
||||||
|
import { usePageHero } from '../api'
|
||||||
|
import { useFieldSaver } from '../admin/AdminContext'
|
||||||
|
import { SingletonPill } from '../admin/Editable'
|
||||||
|
import { InlineEdit } from './admin/InlineEdit'
|
||||||
|
import { ImageField } from './admin/ImageField'
|
||||||
|
|
||||||
type PageHeroProps = {
|
type PageHeroProps = {
|
||||||
title: string
|
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایشپذیر میشوند */
|
||||||
lead: string
|
pageKey: string
|
||||||
image: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageHero({ title, lead, image }: PageHeroProps) {
|
export function PageHero({ pageKey }: PageHeroProps) {
|
||||||
|
const hero = usePageHero(pageKey)
|
||||||
|
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 (
|
return (
|
||||||
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
|
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
|
||||||
<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 bg-ink/50" aria-hidden="true" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
|
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||||
<Link to="/" className="transition-colors hover:text-copper">
|
<Link to="/" className="transition-colors hover:text-copper">
|
||||||
صفحه اصلی
|
صفحه اصلی
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||||
<span className="text-copper">{title}</span>
|
<span className="text-copper">{hero.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0, y: 24 }}
|
initial={{ opacity: 0, y: 24 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
|
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.h1>
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }}
|
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>
|
</motion.p>
|
||||||
<div className="mt-8 h-px w-28 bg-copper" />
|
<div className="mt-8 h-px w-28 bg-copper" />
|
||||||
|
|
||||||
|
{/* ویرایش تصویر پسزمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
|
||||||
|
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
|
||||||
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { BRAND } from '../data/site'
|
import { useSiteData } from '../api'
|
||||||
|
|
||||||
|
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری دادهها از دیتابیس استفاده میشود
|
||||||
|
const FALLBACK_LOGO = '/images/logo.png'
|
||||||
|
const FALLBACK_NAME = 'گروه ساختمانی آرامش'
|
||||||
|
|
||||||
export function Preloader() {
|
export function Preloader() {
|
||||||
|
const { data } = useSiteData()
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
|
|
||||||
|
const logo = data?.brand.logo || FALLBACK_LOGO
|
||||||
|
const name = data?.brand.name || FALLBACK_NAME
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
const total = 1800
|
const total = 1800
|
||||||
@@ -39,13 +47,13 @@ export function Preloader() {
|
|||||||
>
|
>
|
||||||
<div className="relative h-36 w-36">
|
<div className="relative h-36 w-36">
|
||||||
<img
|
<img
|
||||||
src={BRAND.logo}
|
src={logo}
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-0 h-full w-full object-contain opacity-[0.12]"
|
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)` }}>
|
<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>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-x-0 h-px bg-copper"
|
className="absolute inset-x-0 h-px bg-copper"
|
||||||
@@ -64,10 +72,10 @@ export function Preloader() {
|
|||||||
transition={{ duration: 0.5, delay: 0.2 }}
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
className="mt-4 text-sm font-light tracking-widest text-copper font-pinar"
|
className="mt-4 text-sm font-light tracking-widest text-copper font-pinar"
|
||||||
>
|
>
|
||||||
{BRAND.name}
|
{name}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,55 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ArrowLeftIcon } from 'lucide-react'
|
||||||
|
import { useStatusLabel } from '../api'
|
||||||
|
|
||||||
export function ProjectCard({ project }: any) {
|
export function ProjectCard({ project }: any) {
|
||||||
const isOngoing = project.status === 'progress'
|
const isOngoing = project.status === 'progress'
|
||||||
|
const statusText = useStatusLabel(project.status)
|
||||||
const statusText = isOngoing ? 'در حال انجام' : 'انجام شده'
|
|
||||||
|
|
||||||
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
||||||
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="group flex h-full flex-col overflow-hidden 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">
|
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
||||||
<Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
|
<Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
|
||||||
|
|
||||||
|
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشهای */}
|
||||||
<div className="relative aspect-[4/3] w-full overflow-hidden">
|
<div className="relative aspect-[4/3] w-full overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={project.image}
|
src={project.image}
|
||||||
alt={project.title}
|
alt={project.title}
|
||||||
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
<div className={`absolute 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 right-4 top-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}>
|
||||||
|
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
|
||||||
{statusText}
|
{statusText}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* گوی دکمه شیشهای شناور برای ورود به پروژه */}
|
||||||
|
<div className="absolute bottom-4 left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
|
||||||
|
<ArrowLeftIcon className="h-5 w-5 transition-transform duration-300 group-hover:-translate-x-0.5" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col p-8">
|
<div className="flex flex-1 flex-col p-7">
|
||||||
|
|
||||||
<div className="text-center">
|
<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}
|
{project.title}
|
||||||
</h3>
|
</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}
|
{project.location}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
|
||||||
<span>سال: {project.year}</span>
|
<span>سال: {project.year}</span>
|
||||||
<span>{project.area} متر مربع</span>
|
<span>{project.area} متر مربع</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,29 +1,58 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Reveal } from './Reveal'
|
import { Reveal } from './Reveal'
|
||||||
|
import { useSectionHeading } from '../api'
|
||||||
|
import { useFieldSaver } from '../admin/AdminContext'
|
||||||
|
import { InlineEdit } from './admin/InlineEdit'
|
||||||
|
|
||||||
type SectionHeadingProps = {
|
type SectionHeadingProps = {
|
||||||
title: string
|
/** در صورت ارسال، تیتر و توضیح از دیتابیس خوانده و بهصورت درجا ویرایشپذیر میشوند */
|
||||||
|
headingKey?: string
|
||||||
|
title?: string
|
||||||
lead?: string
|
lead?: string
|
||||||
align?: 'right' | 'center'
|
align?: 'right' | 'center'
|
||||||
tone?: 'copper' | 'ivory'
|
tone?: 'copper' | 'ivory'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SectionHeading({
|
export function SectionHeading({
|
||||||
|
headingKey,
|
||||||
title,
|
title,
|
||||||
lead,
|
lead,
|
||||||
align = 'right',
|
align = 'right',
|
||||||
tone = 'ivory',
|
tone = 'ivory',
|
||||||
}: SectionHeadingProps) {
|
}: 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 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 (
|
return (
|
||||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
|
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
|
||||||
<h2 className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}>
|
<InlineEdit
|
||||||
{title}
|
as="h2"
|
||||||
</h2>
|
value={currentTitle}
|
||||||
|
onSave={(v) => saveField('title', v)}
|
||||||
{lead && <p className="mt-5 text-base font-light leading-9 text-ivory/60">{lead}</p>}
|
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' : ''}`} />
|
<div className={`mt-6 h-px w-24 bg-copper/70 ${isCenter ? 'mx-auto' : ''}`} />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { EditorContent, useEditor } from '@tiptap/react'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import Link from '@tiptap/extension-link'
|
||||||
|
import {
|
||||||
|
BoldIcon,
|
||||||
|
ItalicIcon,
|
||||||
|
Heading2Icon,
|
||||||
|
Heading3Icon,
|
||||||
|
ImagePlusIcon,
|
||||||
|
Link2Icon,
|
||||||
|
ListIcon,
|
||||||
|
ListOrderedIcon,
|
||||||
|
QuoteIcon,
|
||||||
|
Redo2Icon,
|
||||||
|
StrikethroughIcon,
|
||||||
|
UnderlineIcon,
|
||||||
|
Undo2Icon,
|
||||||
|
Loader2Icon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import Swal from 'sweetalert2'
|
||||||
|
import { useAdmin } from '../../admin/AdminContext'
|
||||||
|
import { adminApi } from '../../admin/adminClient'
|
||||||
|
|
||||||
|
/** اکستنشنهای مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
|
||||||
|
export const ARTICLE_EXTENSIONS = [
|
||||||
|
StarterKit.configure({
|
||||||
|
link: false,
|
||||||
|
heading: { levels: [2, 3] },
|
||||||
|
}),
|
||||||
|
Image.configure({ allowBase64: false }),
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||||
|
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
type ArticleEditorProps = {
|
||||||
|
doc: Record<string, unknown>
|
||||||
|
onChange: (json: Record<string, unknown>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
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 }: ArticleEditorProps) {
|
||||||
|
const { runSave } = useAdmin()
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: ARTICLE_EXTENSIONS,
|
||||||
|
content: doc && Object.keys(doc).length ? doc : undefined,
|
||||||
|
immediatelyRender: false,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
dir: 'rtl',
|
||||||
|
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
||||||
|
'data-wysiwyg': 'true',
|
||||||
|
},
|
||||||
|
handlePaste: (_event_ignored, event) => {
|
||||||
|
const file = event.clipboardData?.files?.[0]
|
||||||
|
if (file && file.type.startsWith('image/')) {
|
||||||
|
event.preventDefault()
|
||||||
|
void uploadAndInsert(file)
|
||||||
|
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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function uploadAndInsert(file: File) {
|
||||||
|
if (!editor) return
|
||||||
|
setUploading(true)
|
||||||
|
await runSave(async () => {
|
||||||
|
const { url } = await adminApi.upload(file)
|
||||||
|
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run()
|
||||||
|
})
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setLink = () => {
|
||||||
|
if (!editor) return
|
||||||
|
if (editor.isActive('link')) {
|
||||||
|
editor.chain().focus().unsetLink().run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void Swal.fire({
|
||||||
|
title: 'نشانی لینک',
|
||||||
|
input: 'text',
|
||||||
|
inputValue: 'https://',
|
||||||
|
background: '#1e1a16',
|
||||||
|
color: '#F5F3EF',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'ثبت',
|
||||||
|
cancelButtonText: 'انصراف',
|
||||||
|
buttonsStyling: false,
|
||||||
|
customClass: {
|
||||||
|
popup: 'font-pinar border border-ivory/10',
|
||||||
|
title: 'font-pinar font-bold text-ivory',
|
||||||
|
input: 'font-pinar',
|
||||||
|
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5 mx-2',
|
||||||
|
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||||
|
},
|
||||||
|
}).then((result) => {
|
||||||
|
if (!result.isConfirmed || !result.value) return
|
||||||
|
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-ivory/15 bg-char">
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5 border-b border-ivory/10 px-2 py-1.5">
|
||||||
|
<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('heading', { level: 2 })}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
>
|
||||||
|
<Heading2Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
label="تیتر کوچک"
|
||||||
|
active={editor.isActive('heading', { level: 3 })}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
|
>
|
||||||
|
<Heading3Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ToolButton
|
||||||
|
label="لیست نقطهای"
|
||||||
|
active={editor.isActive('bulletList')}
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
>
|
||||||
|
<ListIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
label="لیست شمارهای"
|
||||||
|
active={editor.isActive('orderedList')}
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
>
|
||||||
|
<ListOrderedIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
label="نقلقول"
|
||||||
|
active={editor.isActive('blockquote')}
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
>
|
||||||
|
<QuoteIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ToolButton label="لینک" active={editor.isActive('link')} onClick={setLink}>
|
||||||
|
<Link2Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||||||
|
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
||||||
|
<Undo2Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
||||||
|
<Redo2Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
data-editor-file-input
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) void uploadAndInsert(file)
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||||
|
import { adminApi } from '../../admin/adminClient'
|
||||||
|
import { useAdmin } from '../../admin/AdminContext'
|
||||||
|
|
||||||
|
type ImageFieldProps = {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onSave: (url: string) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش استفاده میشود.
|
||||||
|
* پس از آپلود موفق، URL نسبی /media/... ذخیره میشود.
|
||||||
|
*/
|
||||||
|
export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
||||||
|
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
|
||||||
|
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('ذخیره نشد')
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
{value ? (
|
||||||
|
<img
|
||||||
|
src={value}
|
||||||
|
alt={label}
|
||||||
|
className="h-16 w-24 border border-ivory/15 object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
|
||||||
|
</button>
|
||||||
|
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
void pick(e.target.files?.[0])
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,208 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
||||||
|
import type { ArticleBodyDoc } from '../../data/site'
|
||||||
|
|
||||||
|
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 }
|
||||||
|
|
||||||
|
// ---------- استخراج متن/ساختار از سند TipTap ----------
|
||||||
|
|
||||||
|
function docToText(body: SeoInput['body']): string {
|
||||||
|
if (Array.isArray(body)) return body.join(' ')
|
||||||
|
const out: string[] = []
|
||||||
|
const walk = (node: unknown) => {
|
||||||
|
if (!node || typeof node !== 'object') return
|
||||||
|
const n = node as { type?: string; text?: string; content?: unknown[] }
|
||||||
|
if (n.type === 'text' && n.text) out.push(n.text)
|
||||||
|
for (const c of n.content ?? []) walk(c)
|
||||||
|
}
|
||||||
|
walk(body)
|
||||||
|
return out.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function docHeadings(body: SeoInput['body']): string[] {
|
||||||
|
if (Array.isArray(body)) return []
|
||||||
|
const out: string[] = []
|
||||||
|
const walk = (node: unknown) => {
|
||||||
|
if (!node || typeof node !== 'object') return
|
||||||
|
const n = node as { type?: string; text?: string; attrs?: { level?: number }; content?: unknown[] }
|
||||||
|
if (n.type === 'heading' && n.attrs?.level === 2) {
|
||||||
|
const texts: string[] = []
|
||||||
|
const collect = (inner: unknown) => {
|
||||||
|
if (!inner || typeof inner !== 'object') return
|
||||||
|
const t = inner as { type?: string; text?: string; content?: unknown[] }
|
||||||
|
if (t.type === 'text' && t.text) texts.push(t.text)
|
||||||
|
for (const c of t.content ?? []) collect(c)
|
||||||
|
}
|
||||||
|
collect(n)
|
||||||
|
out.push(texts.join(''))
|
||||||
|
}
|
||||||
|
for (const c of n.content ?? []) walk(c)
|
||||||
|
}
|
||||||
|
walk(body)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function docImages(body: SeoInput['body']): { src: string; alt: string }[] {
|
||||||
|
if (Array.isArray(body)) return []
|
||||||
|
const out: { src: string; alt: string }[] = []
|
||||||
|
const walk = (node: unknown) => {
|
||||||
|
if (!node || typeof node !== 'object') return
|
||||||
|
const n = node as { type?: string; attrs?: { src?: string; alt?: string }; content?: unknown[] }
|
||||||
|
if (n.type === 'image') out.push({ src: n.attrs?.src ?? '', alt: n.attrs?.alt ?? '' })
|
||||||
|
for (const c of n.content ?? []) walk(c)
|
||||||
|
}
|
||||||
|
walk(body)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function docLinks(body: SeoInput['body']): string[] {
|
||||||
|
if (Array.isArray(body)) return []
|
||||||
|
const out: string[] = []
|
||||||
|
const walk = (node: unknown) => {
|
||||||
|
if (!node || typeof node !== 'object') return
|
||||||
|
const n = node as { type?: string; marks?: { type?: string; attrs?: { href?: string } }[]; content?: unknown[] }
|
||||||
|
for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) out.push(m.attrs.href)
|
||||||
|
for (const c of n.content ?? []) walk(c)
|
||||||
|
}
|
||||||
|
walk(body)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstParagraph(body: SeoInput['body']): string {
|
||||||
|
if (Array.isArray(body)) return body[0] ?? ''
|
||||||
|
const p = (body as { content?: { type?: string; content?: unknown[] }[] }).content?.find(
|
||||||
|
(c) => c.type === 'paragraph',
|
||||||
|
)
|
||||||
|
return docToText({ type: 'doc', content: p ? [p] : [] } as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- محاسبه بررسیها ----------
|
||||||
|
|
||||||
|
export function useSeoChecks(input: SeoInput): Check[] {
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,41 +3,65 @@ import { ArrowLeftIcon } from 'lucide-react';
|
|||||||
import { Reveal } from '../Reveal';
|
import { Reveal } from '../Reveal';
|
||||||
import { ButtonLink } from '../Button';
|
import { ButtonLink } from '../Button';
|
||||||
import { ArticleCard } from '../ArticleCard';
|
import { ArticleCard } from '../ArticleCard';
|
||||||
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 Articles() {
|
export function Articles() {
|
||||||
const { articles } = useSite();
|
const { articles } = useSite();
|
||||||
|
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 (
|
return (
|
||||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
|
<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">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<Reveal className="max-w-2xl text-right">
|
<Reveal className="max-w-2xl text-right">
|
||||||
<p className="text-sm font-light text-copper">وبلاگ</p>
|
<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">
|
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
|
||||||
آخرین مقالات
|
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
||||||
یادداشتهای تیم معماری، اجرا و سرمایهگذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار میگذرد.
|
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
||||||
{articles.slice(0, 3).map((article, i) =>
|
{published.slice(0, 3).map((article, i) =>
|
||||||
<Reveal key={article.slug} delay={0.06 * i} className="h-full">
|
<Reveal key={article.id} delay={0.06 * i} className="h-full">
|
||||||
<ArticleCard article={article} />
|
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
||||||
|
<ArticleCard article={article} />
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Reveal delay={0.1}>
|
{heading.cta_label && heading.cta_href && (
|
||||||
<div className="mt-12 flex justify-center">
|
<Reveal delay={0.1}>
|
||||||
<ButtonLink to="/blog" variant="outline" className="font-light">
|
<div className="mt-12 flex justify-center">
|
||||||
همه مقالات
|
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||||
</ButtonLink>
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
</div>
|
</ButtonLink>
|
||||||
</Reveal>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
<div className="mt-6">
|
||||||
|
<AddItemButton resource="articles" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MoveHorizontalIcon } from 'lucide-react'
|
|||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
|
import { SingletonPill } from '../../admin/Editable'
|
||||||
|
|
||||||
export function BeforeAfter() {
|
export function BeforeAfter() {
|
||||||
const { before_after } = useSite()
|
const { before_after } = useSite()
|
||||||
@@ -54,6 +55,10 @@ export function BeforeAfter() {
|
|||||||
align="center"
|
align="center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<SingletonPill resource="before_after" label="ویرایش تصاویر و برچسبهای قبل/بعد" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { PlusIcon } from 'lucide-react'
|
|||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
|
|
||||||
type FaqProps = {
|
type FaqProps = {
|
||||||
hideHeading?: boolean;
|
hideHeading?: boolean;
|
||||||
@@ -15,14 +16,15 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
||||||
|
|
||||||
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
|
<div className={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 && (
|
{!hideHeading && (
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="سوالات متداول"
|
title="سوالات متداول"
|
||||||
lead="پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."
|
lead="پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."
|
||||||
|
headingKey="faq"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -32,43 +34,48 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
{faqs.map((faq, i) => {
|
{faqs.map((faq, i) => {
|
||||||
const isOpen = open === i
|
const isOpen = open === i
|
||||||
return (
|
return (
|
||||||
<Reveal key={faq.question} delay={0.04 * i}>
|
<Reveal key={faq.id} delay={0.04 * i}>
|
||||||
<li className="border-b border-ivory/10">
|
<EditableItem resource="faqs" item={faq}>
|
||||||
<button
|
<li className="border-b border-ivory/10">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setOpen(isOpen ? null : i)}
|
type="button"
|
||||||
aria-expanded={isOpen}
|
onClick={() => setOpen(isOpen ? null : i)}
|
||||||
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
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 className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||||
</span>
|
{faq.question}
|
||||||
<PlusIcon
|
</span>
|
||||||
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
<PlusIcon
|
||||||
isOpen ? 'rotate-45' : ''
|
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
||||||
}`}
|
isOpen ? 'rotate-45' : ''
|
||||||
/>
|
}`}
|
||||||
</button>
|
/>
|
||||||
<AnimatePresence initial={false}>
|
</button>
|
||||||
{isOpen && (
|
<AnimatePresence initial={false}>
|
||||||
<motion.div
|
{isOpen && (
|
||||||
initial={{ height: 0, opacity: 0 }}
|
<motion.div
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
className="overflow-hidden"
|
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>
|
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
||||||
)}
|
</motion.div>
|
||||||
</AnimatePresence>
|
)}
|
||||||
</li>
|
</AnimatePresence>
|
||||||
|
</li>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div className="mt-8">
|
||||||
|
<AddItemButton resource="faqs" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,58 +5,72 @@ import { Reveal } from '../Reveal'
|
|||||||
import { ButtonLink } from '../Button'
|
import { ButtonLink } from '../Button'
|
||||||
import { ProjectCard } from '../ProjectCard'
|
import { ProjectCard } from '../ProjectCard'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
import { useSite } from '../../api'
|
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
import type { ProjectStatus } from '../../data/site'
|
import type { ProjectStatus } from '../../data/site'
|
||||||
|
|
||||||
const FILTERS: { key: ProjectStatus; label: string }[] = [
|
|
||||||
{ key: 'done', label: 'انجام شده' },
|
|
||||||
{ key: 'progress', label: 'در حال انجام' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function FeaturedProjects() {
|
export function FeaturedProjects() {
|
||||||
const { projects } = useSite()
|
const { projects, status_labels } = useSite()
|
||||||
|
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 [status, setStatus] = useState<ProjectStatus>('done')
|
||||||
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
|
<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">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<SectionHeading
|
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
|
||||||
title="پروژههای شاخص"
|
|
||||||
lead="کارهایی که بیشترین چیز را درباره استاندارد ما میگویند؛ چه آنهایی که تحویل شدهاند و چه آنهایی که همین امروز در حال ساختاند."
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
{/* دکمههای فیلتر کپسولی شیشهای مات */}
|
||||||
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||||
{FILTERS.map((filter) => (
|
{FILTERS.map((filter) => {
|
||||||
<button
|
const isSelected = status === filter.key
|
||||||
key={filter.key}
|
return (
|
||||||
type="button"
|
<button
|
||||||
role="tab"
|
key={filter.key}
|
||||||
aria-selected={status === filter.key}
|
type="button"
|
||||||
onClick={() => setStatus(filter.key)}
|
role="tab"
|
||||||
className={`group relative overflow-hidden border px-7 py-3 text-sm transition-colors duration-300 ease-out ${
|
aria-selected={isSelected}
|
||||||
status === filter.key
|
onClick={() => setStatus(filter.key)}
|
||||||
? 'border-copper bg-copper font-bold text-char'
|
className={`group relative flex items-center justify-center overflow-hidden rounded-full px-7 py-3 text-sm transition-all duration-300 ease-out active:scale-95 cursor-pointer select-none ${
|
||||||
: 'border-ivory/15 font-light text-ivory/65'
|
isSelected
|
||||||
}`}
|
? 'border border-copper bg-copper font-bold text-char shadow-[0_0_25px_rgba(197,143,92,0.45),inset_0_1px_1px_rgba(255,255,255,0.4)]'
|
||||||
>
|
: 'border border-white/15 bg-white/[0.04] font-light text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)] hover:border-copper/60 hover:bg-white/[0.08] hover:text-ivory hover:shadow-[0_0_20px_rgba(197,143,92,0.2)]'
|
||||||
{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="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 className="relative z-10 transition-colors duration-300 ease-out">
|
||||||
<span className="relative z-10 transition-colors duration-300 ease-out group-hover:text-char">
|
<InlineEdit
|
||||||
{filter.label}
|
value={filter.label}
|
||||||
</span>
|
onSave={(v) => saveStatusLabel(filter.key, v)}
|
||||||
</button>
|
/>
|
||||||
))}
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 min-h-[420px]">
|
<div className="mt-10 min-h-[420px]">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={status}
|
key={status}
|
||||||
initial={{ opacity: 0, y: 15 }}
|
initial={{ opacity: 0, y: 15 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -15 }}
|
exit={{ opacity: 0, y: -15 }}
|
||||||
@@ -65,27 +79,46 @@ export function FeaturedProjects() {
|
|||||||
>
|
>
|
||||||
{list.map((project, i) => (
|
{list.map((project, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={project.slug}
|
key={project.id}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
>
|
>
|
||||||
<ProjectCard project={project} />
|
<EditableItem resource="projects" item={project} className="h-full">
|
||||||
|
<ProjectCard project={project} />
|
||||||
|
</EditableItem>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Reveal delay={0.1}>
|
{/* دکمه مشاهده همه پروژهها: کپسولی شیشهای با گوی آیکون متحرک */}
|
||||||
<div className="mt-12 flex justify-center">
|
{heading.cta_label && heading.cta_href && (
|
||||||
<ButtonLink to="/projects" variant="outline" className="font-light">
|
<Reveal delay={0.1}>
|
||||||
همه پروژهها
|
<div className="mt-12 flex justify-center">
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ButtonLink
|
||||||
</ButtonLink>
|
to={heading.cta_href}
|
||||||
</div>
|
variant="outline"
|
||||||
</Reveal>
|
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.cta_label = v
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 group-hover:-translate-x-1 group-hover:bg-copper group-hover:text-char">
|
||||||
|
<ArrowLeftIcon className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import { AnimatePresence, motion } from 'framer-motion'
|
|||||||
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
import { IMAGES } from '../../data/site'
|
import { useSite } from '../../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
const GRID = [IMAGES[0], IMAGES[1], IMAGES[2], IMAGES[3], IMAGES[4], IMAGES[1]]
|
|
||||||
|
|
||||||
export function Gallery() {
|
export function Gallery() {
|
||||||
|
const { gallery } = useSite()
|
||||||
const [active, setActive] = useState<number | null>(null)
|
const [active, setActive] = useState<number | null>(null)
|
||||||
|
|
||||||
const close = useCallback(() => setActive(null), [])
|
const close = useCallback(() => setActive(null), [])
|
||||||
const step = useCallback((dir: number) => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (active === null) return
|
if (active === null) return
|
||||||
@@ -32,31 +32,35 @@ export function Gallery() {
|
|||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="گالری تصاویر"
|
title="گالری تصاویر"
|
||||||
lead="قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."
|
lead="قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."
|
||||||
|
headingKey="gallery"
|
||||||
align="center"
|
align="center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
|
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||||
{GRID.map((src, i) => (
|
{gallery.map((image, i) => (
|
||||||
<Reveal key={`${src}-${i}`} delay={0.04 * i}>
|
<Reveal key={image.id} delay={0.04 * i}>
|
||||||
<button
|
<EditableItem resource="gallery" item={image}>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setActive(i)}
|
type="button"
|
||||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
onClick={() => setActive(i)}
|
||||||
aria-label={`بزرگنمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
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}
|
<img
|
||||||
alt={`تصویری از پروژههای گروه ساختمانی آرامش، شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
src={image.src}
|
||||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
alt={image.alt}
|
||||||
/>
|
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="gallery" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{active !== null && (
|
{active !== null && gallery[active] && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
|
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -102,7 +106,7 @@ export function Gallery() {
|
|||||||
|
|
||||||
<motion.img
|
<motion.img
|
||||||
key={active}
|
key={active}
|
||||||
src={GRID[active]}
|
src={gallery[active].src}
|
||||||
alt=""
|
alt=""
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
@@ -116,4 +120,4 @@ export function Gallery() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,26 @@ import { motion, useScroll, useTransform } from 'framer-motion'
|
|||||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||||
import { ButtonLink } from '../Button'
|
import { ButtonLink } from '../Button'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
|
import { SingletonPill } from '../../admin/Editable'
|
||||||
|
|
||||||
export function Hero() {
|
export function Hero() {
|
||||||
const { brand } = useSite()
|
const { brand, hero } = useSite()
|
||||||
|
const save = useFieldSaver()
|
||||||
const { scrollY } = useScroll()
|
const { scrollY } = useScroll()
|
||||||
const imageY = useTransform(scrollY, [0, 700], [0, 110])
|
const imageY = useTransform(scrollY, [0, 700], [0, 110])
|
||||||
const contentY = useTransform(scrollY, [0, 700], [0, -55])
|
const contentY = useTransform(scrollY, [0, 700], [0, -55])
|
||||||
const overlay = useTransform(scrollY, [0, 600], [0.5, 0.78])
|
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 (
|
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="معرفی گروه ساختمانی آرامش">
|
||||||
|
{/* تصویر پسزمینه */}
|
||||||
<motion.img
|
<motion.img
|
||||||
src={brand.hero}
|
src={brand.hero}
|
||||||
alt="نمای شبانه برج مسکونی ساختهشده توسط گروه ساختمانی آرامش"
|
alt="نمای شبانه برج مسکونی ساختهشده توسط گروه ساختمانی آرامش"
|
||||||
@@ -29,78 +39,113 @@ export function Hero() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* محتوای اصلی هیرو */}
|
||||||
<motion.div
|
<motion.div
|
||||||
style={{ y: contentY }}
|
style={{ y: contentY }}
|
||||||
className="relative mx-auto flex h-full max-w-site flex-col justify-center px-6 lg:px-10"
|
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%]">
|
<div className="w-full max-w-[700px] lg:max-w-[58%]">
|
||||||
|
{/* کیکر بالای تیتر */}
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0, y: 18 }}
|
initial={{ opacity: 0, y: 18 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.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.p>
|
||||||
|
|
||||||
|
{/* تیتر اصلی */}
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0, y: 28 }}
|
initial={{ opacity: 0, y: 28 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.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]"
|
||||||
>
|
>
|
||||||
ساختمانی که آرامش را
|
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" />
|
||||||
<span className="text-copper"> معماری </span>
|
|
||||||
میکند
|
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
|
{/* زیرتیتر */}
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0, y: 22 }}
|
initial={{ opacity: 0, y: 22 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.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.p>
|
||||||
|
|
||||||
|
{/* داک دکمهها: استایل کپسولی، شیشهای و دایرهای */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 22 }}
|
initial={{ opacity: 0, y: 22 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="mt-10 flex flex-wrap items-center gap-4"
|
className="mt-10 flex flex-wrap items-center gap-4"
|
||||||
>
|
>
|
||||||
<ButtonLink to="/projects" variant="copper">
|
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
||||||
مشاهده پروژهها
|
<ButtonLink
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
to={hero.cta_primary_href}
|
||||||
|
variant="copper"
|
||||||
|
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium tracking-wide text-ivory">
|
||||||
|
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||||
|
</span>
|
||||||
|
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 group-hover:-translate-x-1">
|
||||||
|
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5]" />
|
||||||
|
</span>
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
<ButtonLink to="/contact" variant="outline" className="font-light">
|
|
||||||
مشاوره سرمایهگذاری
|
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
||||||
|
<ButtonLink
|
||||||
|
to={hero.cta_secondary_href}
|
||||||
|
variant="outline"
|
||||||
|
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
|
||||||
|
>
|
||||||
|
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* کادر آمار شناور (گوشه پایین چپ): شیشهای با لبههای گرد مدرن */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -24 }}
|
initial={{ opacity: 0, x: -24 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
|
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 left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:left-10"
|
||||||
>
|
>
|
||||||
<p className="text-3xl font-bold text-copper">۴۷</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">
|
<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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||||
<a
|
<a
|
||||||
href="#story"
|
href="#story"
|
||||||
aria-label="رفتن به بخش بعدی"
|
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"
|
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>
|
<span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100">
|
||||||
<ChevronDownIcon className="h-4 w-4 animate-bounce" />
|
<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>
|
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,24 @@ import { CheckIcon, SendIcon } from 'lucide-react'
|
|||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { Button } from '../Button'
|
import { Button } from '../Button'
|
||||||
|
import { useSite } from '../../api'
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
|
import { SingletonPill } from '../../admin/Editable'
|
||||||
import { API_URL } from '../../config'
|
import { API_URL } from '../../config'
|
||||||
|
|
||||||
export function Newsletter() {
|
export function Newsletter() {
|
||||||
|
const { newsletter } = useSite()
|
||||||
|
const save = useFieldSaver()
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
|
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) => {
|
const showErrorAlert = (title: string, text: string) => {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'warning',
|
icon: 'warning',
|
||||||
@@ -21,11 +32,11 @@ export function Newsletter() {
|
|||||||
confirmButtonText: 'متوجه شدم',
|
confirmButtonText: 'متوجه شدم',
|
||||||
buttonsStyling: false,
|
buttonsStyling: false,
|
||||||
customClass: {
|
customClass: {
|
||||||
container: 'backdrop-blur-[8px] bg-ink/60',
|
container: 'backdrop-blur-md bg-ink/70',
|
||||||
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
|
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',
|
title: 'font-pinar font-bold text-lg text-ivory pt-2',
|
||||||
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
|
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: {
|
showClass: {
|
||||||
popup: 'swal2-show',
|
popup: 'swal2-show',
|
||||||
@@ -85,22 +96,28 @@ export function Newsletter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-copper py-16" aria-labelledby="newsletter-title">
|
<section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
||||||
<div className="mx-auto grid max-w-site items-center gap-8 px-6 lg:grid-cols-2 lg:px-10">
|
<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>
|
<Reveal>
|
||||||
<h2 id="newsletter-title" className="text-2xl font-bold leading-relaxed text-char md:text-3xl">
|
<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>
|
</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>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal delay={0.08}>
|
<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">
|
<div className="flex-1">
|
||||||
<label htmlFor="newsletter-email" className="sr-only">
|
<label htmlFor="newsletter-email" className="sr-only">
|
||||||
نشانی ایمیل
|
{newsletter.placeholder}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="newsletter-email"
|
id="newsletter-email"
|
||||||
@@ -111,21 +128,30 @@ export function Newsletter() {
|
|||||||
setEmail(e.target.value)
|
setEmail(e.target.value)
|
||||||
if (status !== 'idle') setStatus('idle')
|
if (status !== 'idle') setStatus('idle')
|
||||||
}}
|
}}
|
||||||
placeholder="نشانی ایمیل شما"
|
placeholder={newsletter.placeholder}
|
||||||
className="w-full border border-transparent bg-char px-5 py-4 text-left text-sm font-light text-ivory placeholder:text-ivory/40 transition-all duration-300 ease-lux focus:border-ivory focus:outline-none"
|
className="w-full rounded-full border border-black/15 bg-char/90 px-6 py-4 text-left text-sm font-light text-ivory placeholder:text-ivory/40 shadow-[inset_0_2px_4px_rgba(0,0,0,0.35)] backdrop-blur-xl transition-all duration-300 ease-lux focus:border-ivory focus:bg-char focus:shadow-[0_0_20px_rgba(255,255,255,0.25)] focus:outline-none"
|
||||||
aria-invalid={status === 'error'}
|
aria-invalid={status === 'error'}
|
||||||
/>
|
/>
|
||||||
{status === 'done' && (
|
{status === 'done' && (
|
||||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-light text-char" role="status">
|
<p className="mt-2.5 flex items-center gap-1.5 px-3 text-xs font-medium text-char" role="status">
|
||||||
<CheckIcon className="h-3.5 w-3.5" />
|
<CheckIcon className="h-4 w-4" />
|
||||||
عضویت شما ثبت شد.
|
{newsletter.success_text}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" variant="dark-ivory" disabled={sending}>
|
<Button
|
||||||
<SendIcon className="h-4 w-4" />
|
type="submit"
|
||||||
{sending ? 'در حال ارسال…' : 'عضویت'}
|
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 group-hover:-translate-x-1" />
|
||||||
|
<span>
|
||||||
|
{sending ? newsletter.sending_label : (
|
||||||
|
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -1,42 +1,56 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { ServiceIcon } from '../ServiceIcon'
|
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() {
|
export function Services() {
|
||||||
const { services } = useSite()
|
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 (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
|
<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">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<Reveal className="max-w-2xl text-right">
|
<Reveal className="max-w-2xl text-right">
|
||||||
|
|
||||||
<h2 id="services-title" className="text-section font-bold text-ivory">
|
<h2 id="services-title" className="text-section font-bold text-ivory">
|
||||||
خدمات
|
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
||||||
از نخستین خط روی کاغذ تا تحویل کلید، مدیریت سرمایه و طراحی محوطه؛ همه مراحل در یک مجموعه و زیر یک نظام
|
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
||||||
کنترل کیفیت.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{services.map((service, i) => (
|
{services.map((service, i) => (
|
||||||
<Reveal key={service.title} delay={0.04 * i} className="h-full">
|
<Reveal key={service.id} 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">
|
<EditableItem resource="services" item={service} className="h-full">
|
||||||
<ServiceIcon
|
<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">
|
||||||
name={service.icon}
|
<ServiceIcon
|
||||||
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
|
name={service.icon}
|
||||||
/>
|
className="h-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 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||||
</h3>
|
{service.title}
|
||||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
|
</h3>
|
||||||
</article>
|
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
|
||||||
|
</article>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="services" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useInView } from 'framer-motion';
|
import { useInView } from 'framer-motion';
|
||||||
import { useSite } from '../../api';
|
import { useSite } from '../../api';
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext';
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||||
|
import { InlineEdit } from '../admin/InlineEdit';
|
||||||
|
|
||||||
function toFa(n: number) {
|
function toFa(n: number) {
|
||||||
return n.toLocaleString('fa-IR');
|
return n.toLocaleString('fa-IR');
|
||||||
@@ -29,19 +32,39 @@ function Counter({ value }: {value: number;}) {
|
|||||||
|
|
||||||
export function Stats() {
|
export function Stats() {
|
||||||
const { stats } = useSite();
|
const { stats } = useSite();
|
||||||
|
const save = useFieldSaver();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-char py-16" aria-label="آمار و ارقام">
|
<section className="bg-char py-16" aria-label="آمار و ارقام">
|
||||||
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10">
|
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10">
|
||||||
{stats.map((stat) =>
|
{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">
|
<p className="text-4xl font-bold text-copper md:text-5xl">
|
||||||
<Counter value={stat.value} />
|
<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="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>
|
||||||
<p className="mt-3 text-sm font-light text-ivory/55">{stat.label}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</EditableItem>
|
||||||
)}
|
)}
|
||||||
|
<AddItemButton resource="stats" />
|
||||||
</div>
|
</div>
|
||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,34 @@ import React from 'react'
|
|||||||
import { ArrowLeftIcon } from 'lucide-react'
|
import { ArrowLeftIcon } from 'lucide-react'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { ButtonLink } from '../Button'
|
import { ButtonLink } from '../Button'
|
||||||
import { IMAGES } from '../../data/site'
|
import { useSite } from '../../api'
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
const BENTO = [
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
{ src: IMAGES[0], alt: 'نمای برج مسکونی آرامش در تهران' },
|
import { SingletonPill } from '../../admin/Editable'
|
||||||
{ src: IMAGES[1], alt: 'آتریوم مرکزی مجتمع تجاری پارسه' },
|
|
||||||
{ src: IMAGES[2], alt: 'ویلاهای ساختهشده در سواحل شمال' },
|
|
||||||
{ src: IMAGES[3], alt: 'نمای شبانه برج دوقلوی نیاوران' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type StoryProps = {
|
type StoryProps = {
|
||||||
showLink?: boolean;
|
showLink?: boolean;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
|
showQuote?: boolean;
|
||||||
alignTop?: boolean;
|
alignTop?: boolean;
|
||||||
children?: React.ReactNode;
|
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 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 (
|
return (
|
||||||
<section id="story" className="bg-ink py-24 lg:py-32" aria-labelledby="story-title">
|
<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'}`}>
|
<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,40 +37,58 @@ export function Story({ showLink = true, showLabel = true, alignTop = false, chi
|
|||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<h2 id="story-title" className="text-section font-bold text-ivory">
|
<h2 id="story-title" className="text-section font-bold text-ivory">
|
||||||
درباره ی ما
|
<InlineEdit value={story.title} onSave={(v) => saveStory('title', v)} />
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Reveal delay={0.08}>
|
<Reveal delay={0.08}>
|
||||||
<p className="mt-8 text-base font-light leading-9 text-ivory/65">
|
{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>
|
</p>
|
||||||
<p className="mt-5 text-base font-light leading-9 text-ivory/65">
|
))}
|
||||||
هر پروژه با مطالعه زمین، نور، اقلیم و الگوی زندگی ساکنان آغاز میشود؛ سپس تیم طراحی، سازه و اجرا در یک
|
|
||||||
فرآیند واحد آن را تا تحویل کلید پیش میبرند.
|
|
||||||
</p>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal delay={0.16}>
|
<Reveal delay={0.16}>
|
||||||
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
|
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold text-copper">۹۴٪</p>
|
<p className="text-2xl font-bold text-copper">
|
||||||
<p className="mt-2 text-sm font-light text-ivory/55">تحویل بهموقع پروژهها</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold text-copper">۵ سال</p>
|
<p className="text-2xl font-bold text-copper">
|
||||||
<p className="mt-2 text-sm font-light text-ivory/55">پشتیبانی پس از تحویل</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</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 && (
|
{showLink && (
|
||||||
<Reveal delay={0.22}>
|
<Reveal delay={0.22}>
|
||||||
<ButtonLink to="/about" variant="outline" className="mt-10 font-light">
|
<ButtonLink to={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" />
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -72,8 +101,9 @@ export function Story({ showLink = true, showLabel = true, alignTop = false, chi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="relative grid grid-cols-2 gap-4">
|
||||||
{BENTO.map((item, i) => (
|
<SingletonPill resource="story" label="تصاویر و نقلقول بخش درباره ما" className="absolute -top-10 right-0" />
|
||||||
|
{story.bento.map((item, i) => (
|
||||||
<Reveal key={item.src + i} delay={0.06 * i}>
|
<Reveal key={item.src + i} delay={0.06 * i}>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<img
|
<img
|
||||||
@@ -88,4 +118,4 @@ export function Story({ showLink = true, showLabel = true, alignTop = false, chi
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
|
|
||||||
export function Team() {
|
export function Team() {
|
||||||
const { team } = useSite()
|
const { team } = useSite()
|
||||||
@@ -11,26 +12,30 @@ export function Team() {
|
|||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="تیم ما"
|
title="تیم ما"
|
||||||
lead="کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."
|
lead="کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."
|
||||||
|
headingKey="team"
|
||||||
align="center"
|
align="center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{team.map((member, i) => (
|
{team.map((member, i) => (
|
||||||
<Reveal key={member.name} delay={0.06 * i} className="h-full">
|
<Reveal key={member.id} 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">
|
<EditableItem resource="team" item={member} className="h-full">
|
||||||
<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">
|
<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">
|
||||||
{member.name.trim().charAt(0)}
|
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
|
||||||
</div>
|
{member.name.trim().charAt(0)}
|
||||||
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
|
</div>
|
||||||
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
|
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
|
||||||
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
|
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
|
||||||
{member.bio}
|
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
|
||||||
</p>
|
{member.bio}
|
||||||
</article>
|
</p>
|
||||||
|
</article>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="team" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { AnimatePresence, motion } from 'framer-motion';
|
|||||||
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
|
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
|
||||||
import { SectionHeading } from '../SectionHeading';
|
import { SectionHeading } from '../SectionHeading';
|
||||||
import { useSite } from '../../api';
|
import { useSite } from '../../api';
|
||||||
|
import { EditableItem } from '../../admin/Editable';
|
||||||
|
|
||||||
export function Testimonials() {
|
export function Testimonials() {
|
||||||
const { testimonials } = useSite();
|
const { testimonials } = useSite();
|
||||||
const [index, setIndex] = useState(0);
|
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) => {
|
const go = (dir: number) => {
|
||||||
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
|
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
|
||||||
@@ -16,23 +18,25 @@ export function Testimonials() {
|
|||||||
return (
|
return (
|
||||||
<section className="border-y border-ivory/10 bg-char py-24 lg:py-32" aria-labelledby="testimonials-title">
|
<section className="border-y border-ivory/10 bg-char py-24 lg:py-32" aria-labelledby="testimonials-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<SectionHeading title="نظرات مشتریان" align="center" />
|
<SectionHeading headingKey="testimonials" align="center" />
|
||||||
|
|
||||||
<div className="relative mx-auto mt-14 max-w-3xl text-center">
|
<div className="relative mx-auto mt-14 max-w-3xl text-center">
|
||||||
<QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
|
<QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.blockquote
|
<motion.blockquote
|
||||||
key={item.name}
|
key={current.id}
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -12 }}
|
exit={{ opacity: 0, y: -12 }}
|
||||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
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>
|
<EditableItem resource="testimonials" item={current}>
|
||||||
<footer className="mt-8">
|
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{current.quote}»</p>
|
||||||
<p className="text-base font-bold text-copper">{item.name}</p>
|
<footer className="mt-8">
|
||||||
<p className="mt-1 text-sm font-light text-ivory/45">{item.role}</p>
|
<p className="text-base font-bold text-copper">{current.name}</p>
|
||||||
</footer>
|
<p className="mt-1 text-sm font-light text-ivory/45">{current.role}</p>
|
||||||
|
</footer>
|
||||||
|
</EditableItem>
|
||||||
</motion.blockquote>
|
</motion.blockquote>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
@@ -42,13 +46,13 @@ export function Testimonials() {
|
|||||||
onClick={() => go(1)}
|
onClick={() => go(1)}
|
||||||
aria-label="نظر قبلی"
|
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">
|
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" />
|
<ChevronRightIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex gap-2" role="tablist" aria-label="انتخاب نظر">
|
<div className="flex gap-2" role="tablist" aria-label="انتخاب نظر">
|
||||||
{testimonials.map((t, i) =>
|
{testimonials.map((t, i) =>
|
||||||
<button
|
<button
|
||||||
key={t.name}
|
key={t.id}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={i === index}
|
aria-selected={i === index}
|
||||||
@@ -65,7 +69,7 @@ export function Testimonials() {
|
|||||||
onClick={() => go(-1)}
|
onClick={() => go(-1)}
|
||||||
aria-label="نظر بعدی"
|
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">
|
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" />
|
<ChevronLeftIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,4 +77,4 @@ export function Testimonials() {
|
|||||||
</div>
|
</div>
|
||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useRef } from 'react'
|
|||||||
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
||||||
import { useSite, type TimelineItem } from '../../api'
|
import { useSite, type TimelineItem } from '../../api'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||||
|
|
||||||
const ease = [0.23, 1, 0.32, 1] as const
|
const ease = [0.23, 1, 0.32, 1] as const
|
||||||
|
|
||||||
@@ -31,11 +32,13 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
|||||||
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
||||||
transition={{ duration: 0.55, ease }}
|
transition={{ duration: 0.55, ease }}
|
||||||
>
|
>
|
||||||
<div className="border border-ivory/10 bg-ink p-6 md:p-7">
|
<EditableItem resource="timeline" item={item}>
|
||||||
<span className="text-sm font-light text-copper">{item.year}</span>
|
<div className="border border-ivory/10 bg-ink p-6 md:p-7">
|
||||||
<h3 className="mt-3 text-lg font-bold text-ivory">{item.title}</h3>
|
<span className="text-sm font-light text-copper">{item.year}</span>
|
||||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/60">{item.desc}</p>
|
<h3 className="mt-3 text-lg font-bold text-ivory">{item.title}</h3>
|
||||||
</div>
|
<p className="mt-3 text-sm font-light leading-8 text-ivory/60">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
</EditableItem>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
@@ -53,11 +56,12 @@ export function Timeline() {
|
|||||||
return (
|
return (
|
||||||
<section className="bg-char py-24 md:py-32" aria-labelledby="timeline-title">
|
<section className="bg-char py-24 md:py-32" aria-labelledby="timeline-title">
|
||||||
<div className="mx-auto w-full max-w-site px-6 lg:px-10">
|
<div className="mx-auto w-full max-w-site px-6 lg:px-10">
|
||||||
|
|
||||||
<div className="mb-16">
|
<div className="mb-16">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="مسیر ما"
|
title="مسیر ما"
|
||||||
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژههای ما را ببینید."
|
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژههای ما را ببینید."
|
||||||
|
headingKey="timeline"
|
||||||
align="center"
|
align="center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,9 +79,12 @@ export function Timeline() {
|
|||||||
|
|
||||||
<ol className="flex flex-col gap-12 md:gap-16">
|
<ol className="flex flex-col gap-12 md:gap-16">
|
||||||
{timeline.map((item, i) => (
|
{timeline.map((item, i) => (
|
||||||
<Item key={item.year} item={item} index={i} />
|
<Item key={item.id} item={item} index={i} />
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
<div className="mt-12">
|
||||||
|
<AddItemButton resource="timeline" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,42 +1,72 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Reveal } from '../Reveal';
|
import { Reveal } from '../Reveal';
|
||||||
import { SectionHeading } from '../SectionHeading';
|
import { SectionHeading } from '../SectionHeading';
|
||||||
import { IMAGES } from '../../data/site';
|
import { useSectionHeading, useSite } from '../../api';
|
||||||
import { useSite } from '../../api';
|
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
|
||||||
|
import { InlineEdit } from '../admin/InlineEdit';
|
||||||
|
import { useFieldSaver } from '../../admin/AdminContext';
|
||||||
|
|
||||||
export function WhyUs() {
|
export function WhyUs() {
|
||||||
const { why_us } = useSite();
|
const { why_us } = useSite();
|
||||||
|
const heading = useSectionHeading('why_us');
|
||||||
|
const save = useFieldSaver();
|
||||||
|
|
||||||
|
const saveItem = (id: number, changes: Record<string, unknown>) =>
|
||||||
|
save('why-us', id, changes, (d) => {
|
||||||
|
const it = d.why_us.find((x) => x.id === id);
|
||||||
|
if (it) Object.assign(it, changes);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="why-title">
|
<section className="bg-ink py-24 lg:py-32" aria-labelledby="why-title">
|
||||||
<div className="mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10">
|
<div className="mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10">
|
||||||
<div className="lg:col-span-5">
|
<div className="lg:col-span-5">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="چرا گروه ساختمانی آرامش"
|
title={heading.title}
|
||||||
lead="شش دلیلی که کارفرمایان و سرمایهگذاران بار دوم هم به ما برمیگردند." />
|
lead={heading.lead}
|
||||||
|
headingKey="why_us" />
|
||||||
<Reveal delay={0.12}>
|
|
||||||
<img
|
{heading.image && (
|
||||||
src={IMAGES[3]}
|
<Reveal delay={0.12}>
|
||||||
alt="نمای شبانه برج دوقلوی نیاوران"
|
<img
|
||||||
className="mt-10 h-80 w-full object-cover" />
|
src={heading.image}
|
||||||
|
alt={heading.title}
|
||||||
</Reveal>
|
className="mt-10 h-80 w-full object-cover" />
|
||||||
|
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-7">
|
<div className="lg:col-span-7">
|
||||||
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
|
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
|
||||||
{why_us.map((item, i) =>
|
{why_us.map((item, i) =>
|
||||||
<Reveal key={item.title} delay={0.05 * i}>
|
<Reveal key={item.id} delay={0.05 * i}>
|
||||||
|
<EditableItem resource="why_us" item={item}>
|
||||||
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
|
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
|
||||||
<span className="text-xs font-light text-copper">۰{(i + 1).toLocaleString('fa-IR')}</span>
|
<span className="text-xs font-light text-copper">۰{(i + 1).toLocaleString('fa-IR')}</span>
|
||||||
<h3 className="mt-4 text-lg font-bold text-ivory">{item.title}</h3>
|
<h3 className="mt-4 text-lg font-bold text-ivory">
|
||||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{item.desc}</p>
|
<InlineEdit
|
||||||
|
value={item.title}
|
||||||
|
onSave={(v) => saveItem(item.id, { title: v })}
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">
|
||||||
|
<InlineEdit
|
||||||
|
multiline
|
||||||
|
value={item.desc}
|
||||||
|
onSave={(v) => saveItem(item.id, { desc: v })}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<AddItemButton resource="why_us" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// آدرس بکاند FastAPI — در محیط تولید مقدار VITE_API_URL را در فایل .env تنظیم کنید.
|
// آدرس بکاند — پیشفرض خالی یعنی درخواستهای هممبدأ (/api از طریق Traefik).
|
||||||
export const API_URL: string =
|
// فقط اگر بکاند روی مبدأ دیگری سرو میشود VITE_API_URL را تنظیم کنید.
|
||||||
(import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8000'
|
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''
|
||||||
|
|||||||
+14
-117
@@ -1,60 +1,16 @@
|
|||||||
export const BRAND = {
|
// انواع مشترک دادهها — همه محتوای متنی سایت از بکاند (GET /api/site) بارگذاری میشود.
|
||||||
name: 'گروه ساختمانی آرامش',
|
|
||||||
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',
|
|
||||||
contactBg: 'https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png',
|
|
||||||
phone: '۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰',
|
|
||||||
phoneHref: 'tel:+982188742210',
|
|
||||||
email: 'info@arameshbuildinggroup.com',
|
|
||||||
address: 'تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم'
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IMAGES = [
|
|
||||||
'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'
|
|
||||||
];
|
|
||||||
|
|
||||||
export const NAV = [
|
|
||||||
{ label: 'صفحه اصلی', to: '/' },
|
|
||||||
{ label: 'درباره ما', to: '/about' },
|
|
||||||
{ label: 'خدمات', to: '/services' },
|
|
||||||
{ label: 'پروژهها', to: '/projects' },
|
|
||||||
{ label: 'سرمایهگذاری', to: '/investment' },
|
|
||||||
{ label: 'وبلاگ', to: '/blog' },
|
|
||||||
{ label: 'سوالات متداول', to: '/faq' },
|
|
||||||
{ label: 'تماس با ما', to: '/contact' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export type Service = {
|
export type Service = {
|
||||||
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape';
|
icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES: Service[] = [
|
|
||||||
{ title: 'طراحی معماری', desc: 'خلق فرمهای معاصر با نگاه ایرانی؛ از کانسپت اولیه تا نقشههای اجرایی و جزئیات نما.', icon: 'draft' },
|
|
||||||
{ title: 'ساخت برجهای مسکونی', desc: 'اجرای برجهای بلندمرتبه با استانداردهای سازهای بینالمللی و کیفیت تحویل بینقص.', icon: 'tower' },
|
|
||||||
{ title: 'مجتمعهای تجاری', desc: 'طراحی و ساخت مراکز تجاری با مطالعه دقیق جریان مخاطب و بازدهی بلندمدت سرمایه.', icon: 'mall' },
|
|
||||||
{ title: 'ویلاسازی', desc: 'ویلاهای اختصاصی همنشین با طبیعت؛ محرمانگی، نور طبیعی و مصالح ماندگار.', icon: 'villa' },
|
|
||||||
{ title: 'بازسازی', desc: 'بازآفرینی ساختمانهای موجود با ارتقای سازه، نما و تجربه زندگی در فضا.', icon: 'renew' },
|
|
||||||
{ title: 'مدیریت پروژه', desc: 'کنترل همزمان زمان، هزینه و کیفیت با گزارشدهی شفاف در تمام مراحل ساخت.', icon: 'manage' },
|
|
||||||
{ title: 'سرمایهگذاری ساختمانی', desc: 'ساختارهای مشارکت شفاف، ارزیابی ریسک و طراحی سبد ملکی متناسب با سرمایه شما.', icon: 'invest' },
|
|
||||||
{ title: 'محوطهسازی و لنداسکیپ', desc: 'طراحی حیاط، بام سبز و مسیرهای دسترسی؛ ادامه معماری در فضای باز پروژه.', icon: 'landscape' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const STATS = [
|
|
||||||
{ value: 21, suffix: '+', label: 'سال تجربه ساخت' },
|
|
||||||
{ value: 47, suffix: '', label: 'پروژه تحویلشده' },
|
|
||||||
{ value: 860, suffix: 'هزار', label: 'مترمربع زیربنا' },
|
|
||||||
{ value: 1200, suffix: '+', label: 'خانواده ساکن' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export type ProjectStatus = 'done' | 'progress';
|
export type ProjectStatus = 'done' | 'progress';
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
location: string;
|
location: string;
|
||||||
@@ -70,77 +26,18 @@ export type Project = {
|
|||||||
body: string[];
|
body: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STATUS_LABEL: Record<ProjectStatus, string> = {
|
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
|
||||||
done: 'انجام شده',
|
export type ArticleBodyDoc = { type: 'doc'; content: unknown[] };
|
||||||
progress: 'در حال انجام'
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PROJECTS: Project[] = [
|
export type ArticleStatus = 'draft' | 'published';
|
||||||
{ slug: 'aramesh-elahieh', title: 'برج آرامش الهیه', location: 'تهران، الهیه', year: '۱۴۰۲', status: 'done', image: IMAGES[0], gallery: [IMAGES[0], IMAGES[3], IMAGES[1]], area: '۲۴٬۰۰۰ متر مربع', floors: '۱۸ طبقه', units: '۵۶ واحد', client: 'سرمایهگذاری خصوصی', summary: 'یک برج مسکونی لوکس با نمای سنگ تراورتن و پوسته دوپوستهای که مصرف انرژی را تا ۳۵ درصد کاهش داده است.', body: ['برج آرامش الهیه روی زمینی با شیب طبیعی شمالی طراحی شد؛ همین شیب فرصت داد تا لابی، استخر و فضاهای مشترک در سه تراز مختلف بنشینند و همه واحدها دید بیواسطه به شهر داشته باشند.', 'پوسته بیرونی ترکیبی از سنگ تراورتن روشن و پنجرههای قدی با شیشه سهجداره است. سایهبانهای عمودی مسیرنگ، نور مستقیم تابستان را میشکنند و در زمستان اجازه ورود کامل نور را میدهند.', 'اجرای پروژه در سیودو ماه به پایان رسید و تمام واحدها پیش از تحویل کلید به فروش رسیدند.'] },
|
|
||||||
{ slug: 'parseh-mall', title: 'مجتمع تجاری پارسه', location: 'اصفهان، مرکز شهر', year: '۱۴۰۱', status: 'done', image: IMAGES[1], gallery: [IMAGES[1], IMAGES[2], IMAGES[4]], area: '۳۸٬۵۰۰ متر مربع', floors: '۷ طبقه', units: '۱۴۰ واحد تجاری', client: 'گروه سرمایهگذاری پارسه', summary: 'مرکز تجاری با آتریوم مرکزی و نورگیر سقفی که جریان مخاطب را بهصورت طبیعی در همه ترازها توزیع میکند.', body: ['مطالعه رفتار مخاطب پیش از طراحی نشان داد کوتاهترین مسیر عمودی، کلید موفقیت تجاری این مجموعه است؛ بنابراین آتریوم مرکزی با پلهبرقیهای قطری در قلب پلان قرار گرفت.', 'نورگیر سقفی با شیشه لو-ای، روشنایی روز را تا تراز منفی دو میرساند و مصرف روشنایی مصنوعی را بهطور محسوس کاهش میدهد.', 'مجموعه دو ماه پیش از موعد قراردادی به بهرهبرداری رسید.'] },
|
|
||||||
{ slug: 'daryakenar-villas', title: 'ویلاهای دریاکنار', location: 'مازندران، نوشهر', year: '۱۴۰۰', status: 'done', image: IMAGES[2], gallery: [IMAGES[2], IMAGES[4], IMAGES[0]], area: '۹٬۲۰۰ متر مربع', floors: '۲ طبقه', units: '۱۲ ویلا', client: 'مجموعه خصوصی دریاکنار', summary: 'دوازده ویلای اختصاصی با چیدمان شانهای که برای هر واحد، دید مستقیم به دریا و محرمانگی کامل فراهم میکند.', body: ['چیدمان شانهای ویلاها نتیجه دهها بررسی زاویه دید بود؛ هیچ ویلایی در دید ویلای دیگر نیست و همه به دریا باز میشوند.', 'مصالح، پاسخی به اقلیم مرطوب شمال است: چوب ترموود، بتن اکسپوز و پوششهای ضد نمک برای فلزات نما.', 'محوطه با گیاهان بومی طراحی شد تا نیاز آبیاری در طول سال حداقل باشد.'] },
|
|
||||||
{ slug: 'niavaran-twins', title: 'برج دوقلوی نیاوران', location: 'تهران، نیاوران', year: '۱۳۹۹', status: 'done', image: IMAGES[3], gallery: [IMAGES[3], IMAGES[0], IMAGES[2]], area: '۳۱٬۷۰۰ متر مربع', floors: '۲۰ طبقه', units: '۸۰ واحد', client: 'مشارکت با مالکان زمین', summary: 'دو برج همسان با پادست مشترک که فضاهای رفاهی و پارکینگها را در یک تراز واحد ادغام میکند.', body: ['دو برج روی یک پادست مشترک نشستهاند؛ این تصمیم اجازه داد فضاهای رفاهی، سالن اجتماعات و پارکینگها بین دو ساختمان مشترک شوند و سطح اشغال باغ کاهش یابد.', 'سازه با سیستم دیوار برشی و دال پسکشیده اجرا شد تا دهانههای داخلی آزاد و پلانها قابل شخصیسازی باشند.'] },
|
|
||||||
{ slug: 'kish-garden-tower', title: 'باغبرج کیش', location: 'کیش، مروارید', year: '۱۴۰۵ (پیشبینی)', status: 'progress', image: IMAGES[4], gallery: [IMAGES[4], IMAGES[1], IMAGES[3]], area: '۱۸٬۴۰۰ متر مربع', floors: '۱۴ طبقه', units: '۶۲ واحد اقامتی', client: 'صندوق پروژهمحور آرامش', summary: 'باغبرجی اقامتی با تراسهای سبز پیوسته که در حال ساخت است؛ پیشرفت فیزیکی پروژه ۴۸ درصد.', body: ['تراسهای سبز پیوسته، پوستهای زنده برای ساختمان میسازند که در اقلیم گرم جزیره، دمای سطح نما را بهطور محسوس پایین میآورد.', 'در حال حاضر سازه تا تراز طبقه نهم اجرا شده و نصب نما از ضلع جنوبی آغاز شده است.'] },
|
|
||||||
{ slug: 'aramesh-business-center', title: 'مرکز تجاری آرامش تهران', location: 'تهران، سعادتآباد', year: '۱۴۰۶ (پیشبینی)', status: 'progress', image: IMAGES[1], gallery: [IMAGES[1], IMAGES[3], IMAGES[4]], area: '۲۷٬۳۰۰ متر مربع', floors: '۱۲ طبقه', units: '۹۰ دفتر اداری', client: 'مشارکت با هلدینگ آرامش', summary: 'مجتمع اداری-تجاری با هسته مرکزی شفاف و لابی دوبلکس؛ در مرحله اجرای سفتکاری.', body: ['برنامهریزی فضایی این مجموعه بر اساس دفاتر انعطافپذیر انجام شد؛ هر تراز میتواند به دو، سه یا شش دفتر مستقل تقسیم شود.', 'اجرای تأسیسات مکانیکی بهصورت همزمان با سفتکاری پیش میرود تا زمان کل پروژه کوتاهتر شود.'] }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const BEFORE_AFTER = {
|
|
||||||
title: 'از کانسپت تا خلق واقعیت',
|
|
||||||
lead: 'دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل میشود.',
|
|
||||||
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',
|
|
||||||
beforeLabel: 'کانسپت اولیه',
|
|
||||||
afterLabel: 'پروژه تکمیلشده',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TIMELINE = [
|
|
||||||
{ year: '۱۳۸۳', title: 'آغاز مسیر', desc: 'تأسیس دفتر معماری آرامش با تیمی چهارنفره و نخستین پروژه بازسازی در تهران.', image: IMAGES[2] },
|
|
||||||
{ year: '۱۳۸۵', title: 'نخستین ساخت مستقل', desc: 'تحویل اولین ساختمان مسکونی هفتطبقه؛ آغاز همکاری با پیمانکاران سازهای بزرگ.', image: IMAGES[0] },
|
|
||||||
{ year: '۱۳۸۷', title: 'ورود به بازار تجاری', desc: 'طراحی و اجرای اولین مجتمع تجاری با رویکرد بازدهی سرمایه و جریان مخاطب.', image: IMAGES[1] },
|
|
||||||
{ year: '۱۳۸۹', title: 'استاندارد بینالمللی', desc: 'استقرار نظام کنترل کیفیت و دریافت گواهینامههای مدیریت پروژه.', image: IMAGES[4] },
|
|
||||||
{ year: '۱۳۹۵', title: 'بلندمرتبهسازی', desc: 'آغاز ساخت نخستین برج بیستطبقه شرکت در شمال تهران.', image: IMAGES[3] },
|
|
||||||
{ year: '۱۴۰۳', title: 'گروه ساختمانی آرامش امروز', desc: 'پرتفوی ۴۷ پروژه، دفاتر در سه شهر و صندوق سرمایهگذاری ساختمانی اختصاصی.', image: IMAGES[0] }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const WHY = [
|
|
||||||
{ title: 'کیفیت بدون مصالحه', desc: 'انتخاب مصالح و مجریان بر اساس ماندگاری چنددههای، نه سریعترین تحویل.' },
|
|
||||||
{ title: 'شفافیت مالی کامل', desc: 'گزارش ماهانه هزینهها و پیشرفت فیزیکی برای همه شرکای پروژه.' },
|
|
||||||
{ title: 'تحویل بهموقع', desc: '۹۴٪ پروژههای ما در بازه زمانی قراردادی یا زودتر تحویل داده شدهاند.' },
|
|
||||||
{ title: 'طراحی اختصاصی', desc: 'هیچ نقشهای دو بار اجرا نمیشود؛ هر پروژه هویت معماری خودش را دارد.' },
|
|
||||||
{ title: 'خدمات پس از تحویل', desc: 'پنج سال پشتیبانی فنی و نگهداری تأسیسات پس از تحویل کلید.' },
|
|
||||||
{ title: 'پایداری و انرژی', desc: 'طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد.' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TESTIMONIALS = [
|
|
||||||
{ name: 'مهندس سارا کیانی', role: 'سرمایهگذار پروژه پارسه', quote: 'گزارشهای ماهانه گروه ساختمانی آرامش آنقدر دقیق بود که هیچوقت نیاز نشد نگران سرمایهام باشم. پروژه دو ماه زودتر از موعد به بهرهبرداری رسید.' },
|
|
||||||
{ name: 'دکتر امیرحسین راد', role: 'ساکن برج الهیه', quote: 'کیفیت اجرا در جزئیاتی دیده میشود که معمولاً کسی به آنها فکر نمیکند؛ از عایق صوتی پنجرهها تا نور غیرمستقیم راهروها.' },
|
|
||||||
{ name: 'مریم دلاور', role: 'مدیر مجموعه ویلاهای دریاکنار', quote: 'همکاری با تیم طراحی، تجربهای واقعاً حرفهای بود. ایدههای ما را شنیدند و به زبان معماری ترجمه کردند.' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TEAM = [
|
|
||||||
{ name: 'کامران آرامش', role: 'بنیانگذار و مدیرعامل', bio: 'بیش از سه دهه تجربه در ساخت بلندمرتبه.' },
|
|
||||||
{ name: 'نگار شریفی', role: 'مدیر ارشد طراحی', bio: 'معمار ارشد، فارغالتحصیل دانشگاه تهران و میلان.' },
|
|
||||||
{ name: 'بهزاد مرادی', role: 'مدیر اجرایی پروژهها', bio: 'مسئول کنترل کیفیت و زمانبندی همه کارگاهها.' },
|
|
||||||
{ name: 'الهام نیکآیین', role: 'مدیر سرمایهگذاری', bio: 'طراح ساختارهای مشارکت و تحلیل بازدهی ملکی.' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export type Article = {
|
export type Article = {
|
||||||
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string; body: string[];
|
id: number;
|
||||||
|
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string;
|
||||||
|
/** سند TipTap — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
||||||
|
body: ArticleBodyDoc | string[];
|
||||||
|
/** پیشنویس فقط برای ادمین (از انتهای مدیریتی) میآید؛ بازدیدکننده نمیبیند */
|
||||||
|
status: ArticleStatus;
|
||||||
|
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
||||||
|
seo_keyphrase: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ARTICLES: Article[] = [
|
|
||||||
{ slug: 'natural-light', title: 'معماری آرام: چرا نور طبیعی گرانترین مصالح ساختمان است؟', date: '۲۴ مرداد ۱۴۰۳', category: 'معماری', image: IMAGES[1], read: '۶ دقیقه', excerpt: 'نور طبیعی نهتنها کیفیت زندگی در فضا را تعیین میکند، بلکه مستقیماً بر مصرف انرژی و ارزش بلندمدت ملک اثر میگذارد.', body: ['در بازار ساختوساز، ارزش یک واحد اغلب با متراژ سنجیده میشود؛ اما تجربه ما در بیست سال گذشته نشان داده کیفیت نور، تعیینکنندهتر از چند متر اضافه است.', 'جهتگیری ساختمان، عمق دهانه و ارتفاع کف تا سقف، سه متغیر اصلی نور طبیعی هستند. تصمیم درباره هر سه، پیش از ترسیم اولین پلان گرفته میشود و بعد از آن تقریباً برگشتپذیر نیست.', 'در برج آرامش الهیه، چرخش ۱۲ درجهای توده ساختمان نسبت به شمال، دو ساعت نور مفید بیشتر در روزهای زمستان به واحدها اضافه کرد؛ تصمیمی بیهزینه با اثری چنددههای.', 'برای انتخاب یک واحد، پیشنهاد ما ساده است: بازدید را در ساعتهای میانی صبح و اواخر بعدازظهر انجام دهید و کیفیت نور را در همان ساعتهایی بسنجید که واقعاً در خانه هستید.'] },
|
|
||||||
{ slug: 'real-estate-portfolio', title: 'راهنمای انتخاب سبد سرمایهگذاری ملکی در بازار امروز', date: '۱۰ مرداد ۱۴۰۳', category: 'سرمایهگذاری', image: IMAGES[3], read: '۹ دقیقه', excerpt: 'سه معیار کلیدی برای تشکیل سبدی متعادل از دارایی ملکی، بر اساس افق زمانی و سطح ریسک شما.', body: ['سبد ملکی متعادل، ترکیبی از داراییهای با نقدشوندگی متفاوت است؛ نه همه سرمایه در یک پروژه بلندمدت، نه همه در واحدهای آماده.', 'افق زمانی، نخستین معیار است. اگر برنامه شما کمتر از دو سال است، پیشخرید در پروژهای که تازه شروع شده انتخاب پرریسکی خواهد بود.', 'معیار دوم شفافیت گزارشدهی است. بدون گزارش ماهانه پیشرفت فیزیکی و مالی، ارزیابی وضعیت سرمایه شما عملاً غیرممکن است.', 'معیار سوم، تنوع کاربری است. ترکیب مسکونی و تجاری، نوسان بازدهی سبد را در دورههای رکود محسوس کاهش میدهد.'] },
|
|
||||||
{ slug: 'facade-quality', title: 'پنج نکته فنی که کیفیت نما را پس از ده سال مشخص میکند', date: '۲۸ تیر ۱۴۰۳', category: 'اجرا', image: IMAGES[4], read: '۵ دقیقه', excerpt: 'نمای ساختمان در سال اول زیبا به نظر میرسد؛ تفاوت کیفیت اجرا در سال دهم آشکار میشود.', body: ['اتصالات مکانیکی سنگ نما، مهمتر از خود سنگ است. اتصال چسبی در اقلیم سردوگرم ایران عمر کوتاهی دارد.', 'درزهای انقباضی باید در نقشه دیده شوند، نه در کارگاه تصمیمگیری شوند؛ نبودشان اولین دلیل ترکهای افقی نما است.', 'آببندی دور پنجره، پرتکرارترین نقطه شکست است. اجرای نوار آببند پیش از نصب قرنیز، این ریسک را حذف میکند.', 'زیرسازی گالوانیزه گرم، در برابر زیرسازی رنگشده، تفاوت قیمت اندکی دارد و تفاوت عمر بسیار زیادی.', 'در پایان، برنامه بازدید دورهای سالانه نما را جدی بگیرید؛ ترمیم یک درز، هزینهای در حد هیچ در برابر بازسازی کل نما است.'] },
|
|
||||||
{ slug: 'commercial-yield', title: 'چگونه بازدهی یک پروژه تجاری را پیش از ساخت تخمین بزنیم؟', date: '۱۵ تیر ۱۴۰۳', category: 'سرمایهگذاری', image: IMAGES[2], read: '۷ دقیقه', excerpt: 'مدل سادهای برای برآورد بازدهی، بر پایه جریان مخاطب، ترکیب واحدها و نرخ اجاره منطقه.', body: ['برآورد بازدهی تجاری با سه عدد آغاز میشود: جریان روزانه مخاطب، نرخ اجاره هر مترمربع در منطقه و نسبت سطح مفید به کل.', 'خطای رایج، نادیدهگرفتن ترازهای بالاتر است. بدون طراحی درست مسیر عمودی، ترازهای سه به بالا معمولاً نیمی از نرخ تراز همکف را هم بهدست نمیآورند.', 'در نهایت، سناریوی محافظهکارانه را هم بنویسید؛ اگر پروژه در سناریوی بدبینانه هم توجیه داشت، سرمایهگذاری منطقی است.'] },
|
|
||||||
{ slug: 'facade-materials', title: 'مصالح نما: مقایسه سنگ، کامپوزیت و ترموود در اقلیم ایران', date: '۲ تیر ۱۴۰۳', category: 'اجرا', image: IMAGES[0], read: '۸ دقیقه', excerpt: 'هر مصالح نما در اقلیم متفاوتی درست کار میکند؛ انتخاب اشتباه، هزینه نگهداری را چند برابر میکند.', body: ['سنگ در اقلیم خشک، بهترین ماندگاری را دارد؛ اما وزن آن نیازمند زیرسازی دقیق و محاسبه سازهای است.', 'کامپوزیت انتخاب سریع و سبکی است، مشروط بر انتخاب گرید ضد آتش و درنظرگرفتن انقباض حرارتی در طول ورق.', 'ترموود در اقلیم مرطوب شمال، بهترین پاسخ زیباییشناسانه است، ولی برنامه نگهداری دوسالانه دارد.'] },
|
|
||||||
{ slug: 'renovate-or-rebuild', title: 'بازسازی یا نوسازی؟ معیارهای تصمیمگیری برای مالکان', date: '۲۰ خرداد ۱۴۰۳', category: 'معماری', image: IMAGES[1], read: '۶ دقیقه', excerpt: 'اگر عمر سازه و ضوابط شهری اجازه دهند، بازسازی میتواند بازدهی بالاتری از تخریب کامل داشته باشد.', body: ['نخست وضعیت سازه ارزیابی میشود؛ اگر مقاومسازی هزینهای بیش از ۴۰ درصد ساخت نو دارد، نوسازی منطقیتر است.', 'دومین معیار، ضوابط شهری روز است. گاهی تخریب، سطح زیربنای مجاز کمتری به شما میدهد و این تصمیم را کاملاً تغییر میدهد.', 'در بازسازی، بیشترین ارزش افزوده از سه جا میآید: نما، ورودی و بازآرایی پلان داخلی.'] }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const FAQS = [
|
|
||||||
{ q: 'حداقل سرمایه برای مشارکت در پروژههای گروه ساختمانی آرامش چقدر است؟', a: 'ساختار مشارکت هر پروژه متفاوت است، اما معمولاً واحدهای سرمایهگذاری از سطح مشخصی آغاز میشوند و جزئیات دقیق در جلسه ارزیابی اولیه ارائه میشود.' },
|
|
||||||
{ q: 'مدت زمان معمول ساخت یک برج مسکونی چقدر است؟', a: 'بسته به تعداد طبقات و شرایط زمین، میانگین اجرای یک برج مسکونی بین ۲۸ تا ۴۲ ماه از زمان صدور پروانه تا تحویل کلید است.' },
|
|
||||||
{ q: 'آیا امکان تغییر نقشه داخلی واحد وجود دارد؟', a: 'بله. تا پیش از پایان مرحله سفتکاری، امکان شخصیسازی پلان داخلی با هماهنگی تیم طراحی و تأیید سازه فراهم است.' },
|
|
||||||
{ q: 'خدمات پس از تحویل شامل چه مواردی میشود؟', a: 'پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دورهای سالانه و تضمین اجرای نما و عایقکاری.' },
|
|
||||||
{ q: 'پروژهها در چه شهرهایی اجرا میشوند؟', a: 'در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژهای نیز در جزیره کیش در دست ساخت است.' }
|
|
||||||
];
|
|
||||||
@@ -40,3 +40,102 @@ body {
|
|||||||
scroll-behavior: auto;
|
scroll-behavior: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- بدنه مقاله — رندر سند TipTap و ویرایشگر WYSIWYG ---------- */
|
||||||
|
.article-body {
|
||||||
|
line-height: 2.4;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.article-body p {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
color: rgba(245, 243, 239, 0.7);
|
||||||
|
}
|
||||||
|
.article-body > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.article-body h2,
|
||||||
|
.article-body h3 {
|
||||||
|
color: #f5f3ef;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 3rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
.article-body h2 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
padding-right: 0.85rem;
|
||||||
|
border-right: 3px solid #d5874c;
|
||||||
|
}
|
||||||
|
.article-body h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #e8c9a8;
|
||||||
|
}
|
||||||
|
.article-body ul,
|
||||||
|
.article-body ol {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
.article-body ul {
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
.article-body ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
.article-body li {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
color: rgba(245, 243, 239, 0.7);
|
||||||
|
}
|
||||||
|
.article-body li p {
|
||||||
|
margin-top: 0;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.article-body blockquote {
|
||||||
|
margin-top: 2rem;
|
||||||
|
border-right: 3px solid #d5874c;
|
||||||
|
background: rgba(30, 26, 22, 0.5);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
font-style: normal;
|
||||||
|
color: rgba(245, 243, 239, 0.85);
|
||||||
|
}
|
||||||
|
.article-body blockquote p {
|
||||||
|
margin-top: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.article-body a {
|
||||||
|
color: #d5874c;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 4px;
|
||||||
|
}
|
||||||
|
.article-body img {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 2rem;
|
||||||
|
border: 1px solid rgba(245, 243, 239, 0.1);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.article-body strong {
|
||||||
|
color: #f5f3ef;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.article-body hr {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
border: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(245, 243, 239, 0.12);
|
||||||
|
}
|
||||||
|
.article-body code {
|
||||||
|
background: rgba(30, 26, 22, 0.7);
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.article-body pre {
|
||||||
|
background: rgba(30, 26, 22, 0.7);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
direction: ltr;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.article-body pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "magic-patterns-project",
|
|
||||||
"private": true,
|
|
||||||
"dependencies": {
|
|
||||||
"react": "18.3.1",
|
|
||||||
"react-dom": "18.3.1",
|
|
||||||
"react-router-dom": "6.30.2",
|
|
||||||
"lucide-react": "0.577.0",
|
|
||||||
"framer-motion": "11.18.2",
|
|
||||||
"@radix-ui/react-icons": "1.3.2",
|
|
||||||
"date-fns": "4.1.0",
|
|
||||||
"tailwind-merge": "2.6.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,25 +6,13 @@ import { Timeline } from '../components/home/Timeline'
|
|||||||
import { WhyUs } from '../components/home/WhyUs'
|
import { WhyUs } from '../components/home/WhyUs'
|
||||||
import { Team } from '../components/home/Team'
|
import { Team } from '../components/home/Team'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { IMAGES } from '../data/site'
|
|
||||||
|
|
||||||
export function About() {
|
export function About() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero
|
<PageHero pageKey="about" />
|
||||||
title="درباره ما"
|
|
||||||
lead="گروه ساختمانی آرامش مجموعهای از معماران، مهندسان سازه و مدیران پروژه است که از سال ۱۳۸۳ در حوزه ساختوساز لوکس فعالیت میکند."
|
<Story showLink={false} showLabel={false} showQuote={true} alignTop={true} />
|
||||||
image={IMAGES[3]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Story showLink={false} showLabel={false} alignTop={true}>
|
|
||||||
<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">
|
|
||||||
«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی میسازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»
|
|
||||||
</p>
|
|
||||||
<p className="mt-5 text-sm font-bold text-copper">کامران آرامش — مدیرعامل شرکت ساختمانی آرامش</p>
|
|
||||||
</div>
|
|
||||||
</Story>
|
|
||||||
|
|
||||||
<Stats />
|
<Stats />
|
||||||
<Timeline />
|
<Timeline />
|
||||||
@@ -33,4 +21,4 @@ export function About() {
|
|||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,62 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react';
|
import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react';
|
||||||
|
import { renderToHTMLString } from '@tiptap/static-renderer';
|
||||||
import { Reveal } from '../components/Reveal';
|
import { Reveal } from '../components/Reveal';
|
||||||
import { ButtonLink } from '../components/Button';
|
import { ButtonLink } from '../components/Button';
|
||||||
import { ArticleCard } from '../components/ArticleCard';
|
import { ArticleCard } from '../components/ArticleCard';
|
||||||
import { Newsletter } from '../components/home/Newsletter';
|
import { Newsletter } from '../components/home/Newsletter';
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
|
import { applySeo } from '../seo';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||||
|
import type { Article } from '../data/site';
|
||||||
|
|
||||||
|
/** بدنه مقاله را به HTML تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
||||||
|
function renderBody(article: Article): { html: string | null; legacy: string[] } {
|
||||||
|
const body = article.body as unknown;
|
||||||
|
if (Array.isArray(body)) return { html: null, legacy: body };
|
||||||
|
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
html: renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }),
|
||||||
|
legacy: [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { html: null, legacy: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { html: null, legacy: [] };
|
||||||
|
}
|
||||||
|
|
||||||
export function ArticleDetail() {
|
export function ArticleDetail() {
|
||||||
const { slug } = useParams<{slug: string;}>();
|
const { slug } = useParams<{slug: string;}>();
|
||||||
const { articles } = useSite();
|
const { articles, brand } = useSite();
|
||||||
const article = articles.find((a) => a.slug === slug);
|
const article = articles.find((a) => a.slug === slug);
|
||||||
|
|
||||||
|
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
||||||
|
useEffect(() => {
|
||||||
|
if (!article) return;
|
||||||
|
applySeo({
|
||||||
|
title: `${article.title} | ${brand}`,
|
||||||
|
description: article.excerpt,
|
||||||
|
path: `/blog/${article.slug}`,
|
||||||
|
image: article.image,
|
||||||
|
type: 'article',
|
||||||
|
jsonLd: {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: article.title,
|
||||||
|
description: article.excerpt,
|
||||||
|
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
||||||
|
inLanguage: 'fa',
|
||||||
|
author: { '@type': 'Organization', name: brand },
|
||||||
|
publisher: { '@type': 'Organization', name: brand },
|
||||||
|
mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [article?.id]);
|
||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
return (
|
return (
|
||||||
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
||||||
@@ -25,7 +70,8 @@ export function ArticleDetail() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const related = articles.filter((a) => a.slug !== article.slug).slice(0, 3);
|
const { html, legacy } = renderBody(article);
|
||||||
|
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -36,7 +82,7 @@ export function ArticleDetail() {
|
|||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
|
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
|
||||||
aria-hidden="true" />
|
aria-hidden="true" />
|
||||||
|
|
||||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||||
<Link to="/" className="transition-colors hover:text-copper">
|
<Link to="/" className="transition-colors hover:text-copper">
|
||||||
@@ -67,10 +113,18 @@ export function ArticleDetail() {
|
|||||||
{article.excerpt}
|
{article.excerpt}
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
{article.body.map((paragraph, i) =>
|
{html ? (
|
||||||
<Reveal key={paragraph} delay={0.03 * i}>
|
<Reveal delay={0.05}>
|
||||||
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
|
<div
|
||||||
|
className="article-body mt-4 text-base font-light text-ivory/70"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
) : (
|
||||||
|
legacy.map((paragraph, i) =>
|
||||||
|
<Reveal key={i} delay={0.03 * i}>
|
||||||
|
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -92,4 +146,4 @@ export function ArticleDetail() {
|
|||||||
<Newsletter />
|
<Newsletter />
|
||||||
</>);
|
</>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-10
@@ -5,24 +5,69 @@ import { PageHero } from '../components/PageHero'
|
|||||||
import { Reveal } from '../components/Reveal'
|
import { Reveal } from '../components/Reveal'
|
||||||
import { ArticleCard } from '../components/ArticleCard'
|
import { ArticleCard } from '../components/ArticleCard'
|
||||||
import { Newsletter } from '../components/home/Newsletter'
|
import { Newsletter } from '../components/home/Newsletter'
|
||||||
import { IMAGES } from '../data/site'
|
|
||||||
import { useSite } from '../api'
|
import { useSite } from '../api'
|
||||||
|
import { EditableItem } from '../admin/Editable'
|
||||||
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
function DraftBadge({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`}
|
||||||
|
>
|
||||||
|
پیشنویس
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Blog() {
|
export function Blog() {
|
||||||
const { articles } = useSite()
|
const { articles } = useSite()
|
||||||
|
|
||||||
|
// حالت خالی — وبلاگ بدون مقاله نباید کرش کند
|
||||||
|
if (articles.length === 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero pageKey="blog" />
|
||||||
|
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
|
||||||
|
<div className="mx-auto max-w-site px-6 text-center lg:px-10">
|
||||||
|
<h2 className="text-3xl font-bold text-ivory">هنوز مقالهای منتشر نشده است</h2>
|
||||||
|
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||||
|
بهزودی نخستین نوشتههای ما اینجا منتشر میشود.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/admin/write"
|
||||||
|
data-admin-link
|
||||||
|
className="mt-8 inline-flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
نوشتن مقاله جدید
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<Newsletter />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const [featured, ...rest] = articles
|
const [featured, ...rest] = articles
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero
|
<PageHero pageKey="blog" />
|
||||||
title="وبلاگ"
|
|
||||||
lead="یادداشتهای تیم معماری، اجرا و سرمایهگذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار میگذرد."
|
|
||||||
image={IMAGES[2]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
|
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
|
<div className="mb-10 flex justify-start">
|
||||||
|
<Link
|
||||||
|
to="/admin/write"
|
||||||
|
data-admin-link
|
||||||
|
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" />
|
||||||
|
نوشتن مقاله جدید
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
|
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
|
||||||
<Link
|
<Link
|
||||||
to={`/blog/${featured.slug}`}
|
to={`/blog/${featured.slug}`}
|
||||||
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
|
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
|
||||||
@@ -36,7 +81,12 @@ export function Blog() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center p-8 lg:p-12">
|
<div className="flex flex-col justify-center p-8 lg:p-12">
|
||||||
<p className="text-xs font-light text-copper">مقاله ویژه — {featured.category}</p>
|
<p className="text-xs font-light text-copper">
|
||||||
|
مقاله ویژه — {featured.category}
|
||||||
|
{featured.status === 'draft' && (
|
||||||
|
<DraftBadge className="mr-3 inline-block align-middle" />
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl">
|
<h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl">
|
||||||
{featured.title}
|
{featured.title}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -56,12 +106,20 @@ export function Blog() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||||
{rest.map((article, i) => (
|
{rest.map((article, i) => (
|
||||||
<Reveal key={article.slug} delay={0.05 * i} className="h-full">
|
<Reveal key={article.id} delay={0.05 * i} className="h-full">
|
||||||
<ArticleCard article={article} />
|
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
||||||
|
<div className="relative h-full">
|
||||||
|
{article.status === 'draft' && (
|
||||||
|
<DraftBadge className="absolute left-3 top-3 z-10" />
|
||||||
|
)}
|
||||||
|
<ArticleCard article={article} />
|
||||||
|
</div>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -71,4 +129,4 @@ export function Blog() {
|
|||||||
<Newsletter />
|
<Newsletter />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,21 @@ import React from 'react'
|
|||||||
import { PageHero } from '../components/PageHero'
|
import { PageHero } from '../components/PageHero'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { Reveal } from '../components/Reveal'
|
import { Reveal } from '../components/Reveal'
|
||||||
import { IMAGES } from '../data/site'
|
import { useSite } from '../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||||
const OFFICES = [
|
|
||||||
{ city: 'تهران', address: 'خیابان ولیعصر، برج آرامش، طبقه هجدهم', hours: 'شنبه تا چهارشنبه، ۹ تا ۱۸' },
|
|
||||||
{ city: 'اصفهان', address: 'خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم', hours: 'شنبه تا چهارشنبه، ۹ تا ۱۷' },
|
|
||||||
{ city: 'نوشهر', address: 'بلوار دریا، دفتر پروژه دریاکنار', hours: 'شنبه تا پنجشنبه، ۱۰ تا ۱۶' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function ContactPage() {
|
export function ContactPage() {
|
||||||
|
const { offices } = useSite()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero
|
<PageHero pageKey="contact" />
|
||||||
title="تماس با ما"
|
|
||||||
lead="برای مشاوره ساخت، سرمایهگذاری یا بازدید از پروژهها، تیم ما در سه شهر آماده پاسخگویی است."
|
|
||||||
image={IMAGES[1]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="bg-ink py-24" aria-label="دفاتر ما">
|
<section className="bg-ink py-24" aria-label="دفاتر ما">
|
||||||
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
|
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
|
||||||
{OFFICES.map((office, i) => (
|
{offices.map((office, i) => (
|
||||||
<Reveal key={office.city} delay={0.06 * i} className="h-full">
|
<Reveal key={office.id} delay={0.06 * i} className="h-full">
|
||||||
{/* اعمال فرمول برای نرم شدن بالا آمدن کارت، بدون هیچ تغییری روی متنها */}
|
<EditableItem resource="offices" item={office} className="h-full">
|
||||||
<article className="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
<article className="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||||
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
|
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
|
||||||
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
|
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
|
||||||
@@ -33,12 +26,14 @@ export function ContactPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="offices" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,14 @@ import React from 'react'
|
|||||||
import { PageHero } from '../components/PageHero'
|
import { PageHero } from '../components/PageHero'
|
||||||
import { Faq } from '../components/home/Faq'
|
import { Faq } from '../components/home/Faq'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { IMAGES } from '../data/site'
|
|
||||||
|
|
||||||
export function FaqPage() {
|
export function FaqPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero
|
<PageHero pageKey="faq" />
|
||||||
title="سوالات متداول"
|
|
||||||
lead="پیش از تماس، شاید پاسخ پرسش شما همینجا باشد. اگر نبود، خوشحال میشویم مستقیم صحبت کنیم."
|
|
||||||
image={IMAGES[1]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Faq hideHeading={true} />
|
<Faq hideHeading={true} />
|
||||||
|
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,53 +4,33 @@ import { Reveal } from '../components/Reveal'
|
|||||||
import { SectionHeading } from '../components/SectionHeading'
|
import { SectionHeading } from '../components/SectionHeading'
|
||||||
import { Stats } from '../components/home/Stats'
|
import { Stats } from '../components/home/Stats'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { IMAGES } from '../data/site'
|
import { useSectionHeading, useSite } from '../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||||
const MODELS = [
|
|
||||||
{
|
|
||||||
title: 'مشارکت در ساخت',
|
|
||||||
desc: 'مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک میشوند؛ اجرا، مجوزها و فروش بر عهده ماست.',
|
|
||||||
points: ['ارزیابی رایگان زمین', 'قرارداد شفاف با ضمانت اجرا', 'گزارش ماهانه پیشرفت'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'صندوق پروژهمحور',
|
|
||||||
desc: 'سرمایهگذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبهشده در پایان دوره.',
|
|
||||||
points: ['حداقل دوره ۲۴ ماه', 'گزارش ارزشگذاری فصلی', 'امکان انتقال واحد سرمایه'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'پیشخرید واحد',
|
|
||||||
desc: 'خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحلهای.',
|
|
||||||
points: ['پرداخت مرحلهای متناسب با پیشرفت', 'امکان شخصیسازی پلان', 'تضمین زمان تحویل'],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function Investment() {
|
export function Investment() {
|
||||||
|
const { investment_models } = useSite()
|
||||||
|
const heading = useSectionHeading('investment_models')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero
|
<PageHero pageKey="investment" />
|
||||||
title="سرمایهگذاری"
|
|
||||||
lead="سه مسیر روشن برای ورود به بازار ساختوساز لوکس، با ساختار مالی شفاف و گزارشدهی منظم."
|
|
||||||
image={IMAGES[4]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="models-title">
|
<section className="bg-ink py-24 lg:py-32" aria-labelledby="models-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<SectionHeading
|
<SectionHeading title={heading.title} lead={heading.lead} />
|
||||||
title="مدلهای همکاری"
|
|
||||||
lead="بسته به میزان سرمایه، افق زمانی و سطح ریسکی که میپذیرید، یکی از این سه مسیر مناسب شماست."
|
|
||||||
/>
|
|
||||||
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
||||||
{MODELS.map((model, i) => (
|
{investment_models.map((model, i) => (
|
||||||
<Reveal key={model.title} delay={0.06 * i} className="h-full">
|
<Reveal key={model.id} delay={0.06 * i} className="h-full">
|
||||||
|
<EditableItem resource="investment_models" item={model} className="h-full">
|
||||||
<article className="group flex h-full flex-col border border-ivory/10 bg-char p-10 transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
<article className="group flex h-full flex-col border border-ivory/10 bg-char p-10 transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||||
<p className="text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p>
|
<p className="text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p>
|
||||||
|
|
||||||
<h3 className="mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
<h3 className="mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||||
{model.title}
|
{model.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="mt-5 text-sm font-light leading-9 text-ivory/65">{model.desc}</p>
|
<p className="mt-5 text-sm font-light leading-9 text-ivory/65">{model.desc}</p>
|
||||||
|
|
||||||
<div className="mt-auto pt-10">
|
<div className="mt-auto pt-10">
|
||||||
<ul className="space-y-4 border-t border-ivory/10 pt-8">
|
<ul className="space-y-4 border-t border-ivory/10 pt-8">
|
||||||
{model.points.map((point) => (
|
{model.points.map((point) => (
|
||||||
@@ -62,8 +42,10 @@ export function Investment() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="investment_models" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -72,4 +54,4 @@ export function Investment() {
|
|||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,28 @@ import { ButtonLink } from '../components/Button'
|
|||||||
import { ProjectCard } from '../components/ProjectCard'
|
import { ProjectCard } from '../components/ProjectCard'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { Lightbox } from '../components/Lightbox'
|
import { Lightbox } from '../components/Lightbox'
|
||||||
import { STATUS_LABEL } from '../data/site'
|
import { useEffect } from 'react';
|
||||||
import { useSite } from '../api'
|
import { useSite, useStatusLabel } from '../api';
|
||||||
|
import { applySeo } from '../seo';
|
||||||
|
|
||||||
export function ProjectDetail() {
|
export function ProjectDetail() {
|
||||||
const { slug } = useParams<{ slug: string }>()
|
const { slug } = useParams<{ slug: string }>()
|
||||||
const { projects } = useSite()
|
const { projects, brand } = useSite()
|
||||||
const project = projects.find((p) => p.slug === slug)
|
const project = projects.find((p) => p.slug === slug)
|
||||||
|
const statusLabel = useStatusLabel(project?.status ?? '')
|
||||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// عنوان تب، توضیح متا و OG از خود پروژه
|
||||||
|
useEffect(() => {
|
||||||
|
if (!project) return;
|
||||||
|
applySeo({
|
||||||
|
title: `${project.title} | ${brand}`,
|
||||||
|
description: project.summary,
|
||||||
|
path: `/projects/${project.slug}`,
|
||||||
|
image: project.image,
|
||||||
|
});
|
||||||
|
}, [project?.id]);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
||||||
@@ -74,7 +87,7 @@ export function ProjectDetail() {
|
|||||||
<Reveal className="order-1">
|
<Reveal className="order-1">
|
||||||
<span className="flex items-center gap-3 text-sm font-light text-copper">
|
<span className="flex items-center gap-3 text-sm font-light text-copper">
|
||||||
<span aria-hidden="true" className="h-px w-8 bg-copper" />
|
<span aria-hidden="true" className="h-px w-8 bg-copper" />
|
||||||
{STATUS_LABEL[project.status]}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
<h2 className="mt-4 text-3xl font-bold text-ivory">درباره پروژه</h2>
|
<h2 className="mt-4 text-3xl font-bold text-ivory">درباره پروژه</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -4,25 +4,36 @@ import { Reveal } from '../components/Reveal';
|
|||||||
import { ProjectCard } from '../components/ProjectCard';
|
import { ProjectCard } from '../components/ProjectCard';
|
||||||
import { Gallery } from '../components/home/Gallery';
|
import { Gallery } from '../components/home/Gallery';
|
||||||
import { ContactSection } from '../components/ContactSection';
|
import { ContactSection } from '../components/ContactSection';
|
||||||
import { IMAGES, type ProjectStatus } from '../data/site';
|
import type { ProjectStatus } from '../data/site';
|
||||||
import { useSite } from '../api';
|
import { useSite, useStatusLabel } from '../api';
|
||||||
|
import { useFieldSaver } from '../admin/AdminContext';
|
||||||
const FILTERS: {key: ProjectStatus;label: string;}[] = [
|
import { InlineEdit } from '../components/admin/InlineEdit';
|
||||||
{ key: 'done', label: 'انجام شده' },
|
import { AddItemButton, EditableItem } from '../admin/Editable';
|
||||||
{ key: 'progress', label: 'در حال انجام' }];
|
|
||||||
|
|
||||||
|
|
||||||
export function Projects() {
|
export function Projects() {
|
||||||
const { projects } = useSite();
|
const { projects, status_labels } = useSite();
|
||||||
|
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 doneLabel = useStatusLabel('done');
|
||||||
|
const progressLabel = useStatusLabel('progress');
|
||||||
|
const FILTERS: {key: ProjectStatus;label: string;}[] = [
|
||||||
|
{ key: 'done', label: doneLabel },
|
||||||
|
{ key: 'progress', label: progressLabel }];
|
||||||
const [status, setStatus] = useState<ProjectStatus>('done');
|
const [status, setStatus] = useState<ProjectStatus>('done');
|
||||||
const list = projects.filter((p) => p.status === status);
|
const list = projects.filter((p) => p.status === status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero
|
<PageHero pageKey="projects" />
|
||||||
title="پروژهها"
|
|
||||||
lead="مجموعهای از برجهای مسکونی، مراکز تجاری و ویلاهای اختصاصی؛ از پروژههای تحویلشده تا کارگاههای فعال امروز."
|
|
||||||
image={IMAGES[0]} />
|
|
||||||
|
|
||||||
|
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژهها">
|
<section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژهها">
|
||||||
@@ -48,7 +59,10 @@ export function Projects() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
<span className="relative z-10 transition-colors duration-300 ease-lux group-hover:text-char">
|
<span className="relative z-10 transition-colors duration-300 ease-lux group-hover:text-char">
|
||||||
{filter.label}
|
<InlineEdit
|
||||||
|
value={filter.label}
|
||||||
|
onSave={(v) => saveStatusLabel(filter.key, v)}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -56,10 +70,13 @@ export function Projects() {
|
|||||||
|
|
||||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||||
{list.map((project, i) =>
|
{list.map((project, i) =>
|
||||||
<Reveal key={project.slug} delay={0.05 * i} className="h-full">
|
<Reveal key={project.id} delay={0.05 * i} className="h-full">
|
||||||
<ProjectCard project={project} imageClass="h-80" />
|
<EditableItem resource="projects" item={project} className="h-full">
|
||||||
|
<ProjectCard project={project} imageClass="h-80" />
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
<AddItemButton resource="projects" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,39 +5,35 @@ import { Faq } from '../components/home/Faq'
|
|||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { Reveal } from '../components/Reveal'
|
import { Reveal } from '../components/Reveal'
|
||||||
import { SectionHeading } from '../components/SectionHeading'
|
import { SectionHeading } from '../components/SectionHeading'
|
||||||
import { IMAGES } from '../data/site'
|
import { useSectionHeading, useSite } from '../api'
|
||||||
|
import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||||
const PROCESS = [
|
|
||||||
{ step: '۰۱', title: 'مطالعه و امکانسنجی', desc: 'بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه.' },
|
|
||||||
{ step: '۰۲', title: 'طراحی و فاز اجرایی', desc: 'کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشههای اجرایی.' },
|
|
||||||
{ step: '۰۳', title: 'اجرا و کنترل کیفیت', desc: 'ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه.' },
|
|
||||||
{ step: '۰۴', title: 'تحویل و پشتیبانی', desc: 'تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی.' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function ServicesPage() {
|
export function ServicesPage() {
|
||||||
|
const { process_steps } = useSite()
|
||||||
|
const heading = useSectionHeading('process')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero
|
<PageHero pageKey="services" />
|
||||||
title="خدمات"
|
|
||||||
lead="هشت حوزه تخصصی که چرخه کامل یک پروژه ساختمانی را از ایده تا بهرهبرداری پوشش میدهند."
|
|
||||||
image={IMAGES[1]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Services />
|
<Services />
|
||||||
|
|
||||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="process-title">
|
<section className="bg-char py-24 lg:py-32" aria-labelledby="process-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<SectionHeading title="فرآیند کار ما" lead="چهار مرحله شفاف، از نخستین جلسه تا تحویل کلید." align="center" />
|
<SectionHeading title={heading.title} lead={heading.lead} align="center" />
|
||||||
<div className="mt-14 grid gap-px bg-ivory/10 md:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-14 grid gap-px bg-ivory/10 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{PROCESS.map((item, i) => (
|
{process_steps.map((item, i) => (
|
||||||
<Reveal key={item.step} delay={0.06 * i} className="h-full">
|
<Reveal key={item.id} delay={0.06 * i} className="h-full">
|
||||||
|
<EditableItem resource="process_steps" item={item} className="h-full">
|
||||||
<div className="flex h-full flex-col justify-center bg-char px-6 py-14 text-center">
|
<div className="flex h-full flex-col justify-center bg-char px-6 py-14 text-center">
|
||||||
<p className="text-4xl font-bold text-copper">{item.step}</p>
|
<p className="text-4xl font-bold text-copper">{item.step}</p>
|
||||||
<h3 className="mt-6 text-xl font-bold text-ivory">{item.title}</h3>
|
<h3 className="mt-6 text-xl font-bold text-ivory">{item.title}</h3>
|
||||||
<p className="mt-4 text-base font-light leading-9 text-ivory/60">{item.desc}</p>
|
<p className="mt-4 text-base font-light leading-9 text-ivory/60">{item.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
<AddItemButton resource="process_steps" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -46,4 +42,4 @@ export function ServicesPage() {
|
|||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// تگهای سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical و JSON-LD.
|
||||||
|
// محتوا از دیتابیس میآید؛ برای خزندههای بدون جاوااسکریپت، بکاند همان تگها را
|
||||||
|
// سمت سرور داخل index.html تزریق میکند (app/seo_pages.py).
|
||||||
|
|
||||||
|
export function setMetaDescription(content: string): void {
|
||||||
|
// بدون توضیح از دیتابیس، پیشفرض استاتیک index.html دستنخورده میماند
|
||||||
|
if (!content.trim()) return
|
||||||
|
let el = document.querySelector<HTMLMetaElement>('meta[name="description"]')
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('meta')
|
||||||
|
el.setAttribute('name', 'description')
|
||||||
|
document.head.appendChild(el)
|
||||||
|
}
|
||||||
|
el.setAttribute('content', content.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** کلید سئوی مسیر جاری — فقط صفحههای فهرست دقیق؛ صفحههای جزئیات خودشان عنوان میگذارند */
|
||||||
|
export function seoKeyForPath(pathname: string): string | null {
|
||||||
|
if (pathname === '/') return 'home'
|
||||||
|
const seg = pathname.split('/')[1] ?? ''
|
||||||
|
const known = ['about', 'services', 'projects', 'investment', 'blog', 'faq', 'contact']
|
||||||
|
// مسیرهای جزئی مثل /blog/{slug} شامل نمیشوند تا اثر خود صفحه جزئیات برسانند
|
||||||
|
return known.includes(seg) && pathname === `/${seg}` ? seg : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertMeta(attr: 'name' | 'property', key: string, content: string): void {
|
||||||
|
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`)
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('meta')
|
||||||
|
el.setAttribute(attr, key)
|
||||||
|
document.head.appendChild(el)
|
||||||
|
}
|
||||||
|
el.setAttribute('content', content)
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertCanonical(path: string): void {
|
||||||
|
let el = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('link')
|
||||||
|
el.setAttribute('rel', 'canonical')
|
||||||
|
document.head.appendChild(el)
|
||||||
|
}
|
||||||
|
el.setAttribute('href', new URL(path, window.location.origin).href)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setJsonLd(data: Record<string, unknown> | null): void {
|
||||||
|
const id = 'seo-jsonld'
|
||||||
|
let el = document.getElementById(id)
|
||||||
|
if (!data) {
|
||||||
|
el?.remove()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('script')
|
||||||
|
el.setAttribute('type', 'application/ld+json')
|
||||||
|
el.id = id
|
||||||
|
document.head.appendChild(el)
|
||||||
|
}
|
||||||
|
el.textContent = JSON.stringify(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SeoInput = {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
/** مسیر canonical — پیشفرض مسیر جاری مرورگر */
|
||||||
|
path?: string
|
||||||
|
/** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل میشود */
|
||||||
|
image?: string
|
||||||
|
type?: 'website' | 'article'
|
||||||
|
jsonLd?: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** تنظیم همه تگهای سئوی صفحه در یک فراخوانی */
|
||||||
|
export function applySeo(input: SeoInput): void {
|
||||||
|
const title = input.title.trim()
|
||||||
|
const description = (input.description ?? '').trim()
|
||||||
|
const path = input.path ?? window.location.pathname
|
||||||
|
const image = (input.image ?? '').trim()
|
||||||
|
|
||||||
|
document.title = title
|
||||||
|
setMetaDescription(description)
|
||||||
|
upsertCanonical(path)
|
||||||
|
upsertMeta('property', 'og:title', title)
|
||||||
|
upsertMeta('property', 'og:description', description)
|
||||||
|
upsertMeta('property', 'og:type', input.type ?? 'website')
|
||||||
|
upsertMeta('property', 'og:url', new URL(path, window.location.origin).href)
|
||||||
|
upsertMeta('name', 'twitter:card', 'summary_large_image')
|
||||||
|
if (image) {
|
||||||
|
upsertMeta('property', 'og:image', new URL(image, window.location.origin).href)
|
||||||
|
} else {
|
||||||
|
document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.remove()
|
||||||
|
}
|
||||||
|
setJsonLd(input.jsonLd ?? null)
|
||||||
|
}
|
||||||
+14
-1
@@ -4,4 +4,17 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
})
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/media': {
|
||||||
|
target: 'http://localhost:9000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/media/, '/aramesh-media'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# پشتیبانگیری روزانه — دیتابیس PostgreSQL (همه محتوای سایت) و تصاویر MinIO.
|
||||||
|
#
|
||||||
|
# اجرا: bash scripts/backup.sh
|
||||||
|
# زمانبندی: cron (لینوکس) یا Task Scheduler (ویندوز)، مثلا هر شب ساعت ۳:
|
||||||
|
# 0 3 * * * cd /path/to/aramesh-backend && bash scripts/backup.sh >> backups/backup.log 2>&1
|
||||||
|
#
|
||||||
|
# نگهداری: فایلهای قدیمیتر از RETENTION_DAYS (پیشفرض ۱۴) حذف میشوند.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
RETENTION_DAYS="${RETENTION_DAYS:-14}"
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p backups
|
||||||
|
|
||||||
|
# متغیرهای .env (برای MINIO_ROOT_*) در دسترس قرار میگیرند
|
||||||
|
if [ -f .env ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[backup] $STAMP"
|
||||||
|
|
||||||
|
# ۱) دیتابیس — همه متنها، مقالهها و تنظیمات سایت
|
||||||
|
DB_FILE="backups/db-${STAMP}.sql.gz"
|
||||||
|
docker compose exec -T db pg_dump -U aramesh arameshplaza | gzip > "$DB_FILE"
|
||||||
|
echo "[backup] db: $DB_FILE ($(du -h "$DB_FILE" | cut -f1))"
|
||||||
|
|
||||||
|
# ۲) تصاویر MinIO — باکت aramesh-media
|
||||||
|
docker compose run --rm -v "$(pwd)/backups:/backups" --entrypoint sh mc -c "
|
||||||
|
mc alias set local http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null &&
|
||||||
|
mc mirror --overwrite local/aramesh-media /backups/images-${STAMP}
|
||||||
|
"
|
||||||
|
echo "[backup] images: backups/images-${STAMP}"
|
||||||
|
|
||||||
|
# ۳) پاکسازی نسخههای قدیمیتر
|
||||||
|
find backups -name 'db-*.sql.gz' -mtime "+${RETENTION_DAYS}" -delete
|
||||||
|
find backups -maxdepth 1 -type d -name 'images-*' -mtime "+${RETENTION_DAYS}" -exec rm -rf {} +
|
||||||
|
echo "[backup] پاکسازی نسخههای قدیمیتر از ${RETENTION_DAYS} روز انجام شد"
|
||||||
|
|
||||||
|
echo "[backup] ✔ تمام شد"
|
||||||
Reference in New Issue
Block a user