Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35a558db20 | ||
|
|
5a6cb51333 | ||
|
|
dc200099ba | ||
|
|
0a55d5d0fe | ||
|
|
97dc728a4c | ||
|
|
87f38f1c0b | ||
|
|
8dab39ee83 | ||
|
|
32b6655a1e | ||
|
|
395aceb6f2 | ||
|
|
e76dbbf63d | ||
|
|
e0ebae4817 |
@@ -1,23 +0,0 @@
|
||||
# کپی کنید: 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,9 +9,6 @@ env/
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# پشتیبانگیری محلی
|
||||
backups/
|
||||
|
||||
# Node / Vite
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -26,6 +23,4 @@ Thumbs.db
|
||||
# Database
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# تصاویر محلی — از پنل/کنسول MinIO مدیریت میشوند
|
||||
frontend/public/images/
|
||||
frontend/public/images/originals/
|
||||
|
||||
@@ -2,78 +2,35 @@
|
||||
|
||||
مونوریپوی سایت گروه ساختمانی آرامش شامل:
|
||||
|
||||
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متنها و تصاویر از API خوانده میشود)
|
||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT + MinIO
|
||||
|
||||
## معماری
|
||||
|
||||
```
|
||||
Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
|
||||
├── / → Caddy (فایلهای استاتیک SPA)
|
||||
├── /api → FastAPI (بکاند)
|
||||
└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی)
|
||||
```
|
||||
|
||||
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانتاند هاردکد نشده؛
|
||||
با یک درخواست `GET /api/site` بارگذاری و از پنل مدیریت ویرایش میشود
|
||||
- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره میشوند و با مسیر نسبی
|
||||
`/media/...` برگردانده میشوند — بکاند ذخیرهسازی بدون تغییر محتوای دیتابیس قابل تعویض است
|
||||
- مسیریابی همهچیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
|
||||
- **`frontend/`** — سایت React + Vite + Tailwind (همه محتوا از API خوانده میشود)
|
||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT
|
||||
|
||||
## ساختار
|
||||
|
||||
```
|
||||
├── frontend/ # سایت React
|
||||
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
|
||||
│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy
|
||||
│ └── Caddyfile # فقط سرو فایلهای استاتیک (مسیریابی روی Traefik است)
|
||||
│ ├── Dockerfile # بیلد Node + سرو با nginx
|
||||
│ └── nginx.conf # پروکسی /api به بکاند
|
||||
├── backend/ # بکاند FastAPI
|
||||
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
|
||||
│ ├── tests/ # تستهای API (pytest)
|
||||
│ ├── app/ # کد منبع
|
||||
│ ├── requirements.txt
|
||||
│ ├── Dockerfile
|
||||
│ └── .env.example
|
||||
├── scripts/backup.sh # پشتیبانگیری دیتابیس و تصاویر
|
||||
├── docker-compose.yml # اجرای کامل با یک دستور
|
||||
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
|
||||
└── docker-compose.yml # اجرای کامل با یک دستور
|
||||
```
|
||||
|
||||
## اجرا با Docker (سادهترین راه)
|
||||
|
||||
پیشنیاز: Docker Desktop. بیلد فرانتاند روی هاست انجام میشود (کانتینر فقط فایلهای
|
||||
`dist/` را سرو میکند)، پس اولین بار باید یکبار `npm run build` بزنید:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمیآید
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
docker compose up -d --build
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
سایت: http://localhost:5173 — ورود مدیریت: `/admin/login` (کاربر/رمز از `ADMIN_*` در `.env`)
|
||||
|
||||
- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی)
|
||||
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env`
|
||||
- دیتابیس PostgreSQL و آبجکتاستور MinIO هر دو در کانتینر جدا با volume دائمی
|
||||
- بکاند هنگام شروع، خودش جداول را میسازد و **فقط جدولهای خالی** را seed میکند —
|
||||
ارتقای نسخه دادههای فعلی را از بین نمیبرد
|
||||
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را میسازد، خواندن عمومی را
|
||||
فعال میکند و تصاویر داخل `seed-media/` را در باکت میریزد — پس یک کلون تازه هم
|
||||
همان تصاویر سایت را دارد
|
||||
|
||||
> نکته برای تازهواردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
|
||||
> فایلهای `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
|
||||
|
||||
## انتشار واقعی (TLS)
|
||||
|
||||
برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید:
|
||||
|
||||
```bash
|
||||
# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
- Traefik گواهی Let's Encrypt میگیرد و تمدید میکند؛ همه درخواستهای HTTP به HTTPS هدایت میشوند
|
||||
- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرسهای OG مطلق شوند
|
||||
- سایت: http://localhost:5173
|
||||
- مستندات API: http://localhost:8000/docs
|
||||
- دیتابیس PostgreSQL در کانتینر جدا (دادهها در volume ذخیره میشوند)
|
||||
- بکاند هنگام شروع، خودش جداول و دادههای اولیه سایت را seed میکند
|
||||
- nginx درخواستهای `/api` را خودش به بکاند پروکسی میکند — نیازی به تنظیم CORS نیست
|
||||
|
||||
## اجرای محلی (توسعه)
|
||||
|
||||
@@ -95,108 +52,34 @@ uvicorn app.main:app --reload
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند؛
|
||||
پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `frontend/src/config.ts` از
|
||||
`VITE_API_URL` خوانده میشود — پیشفرض خالی یعنی درخواستهای هممبدأ.
|
||||
آدرس بکاند در `frontend/src/config.ts` از `VITE_API_URL` خوانده میشود؛
|
||||
اگر تنظیم نشود پیشفرض `http://localhost:8000` است.
|
||||
|
||||
## امکانات بکاند
|
||||
|
||||
- **API عمومی**: برند، منو، هیرو، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم،
|
||||
تایملاین، آمار، دفاتر، مدلهای سرمایهگذاری و... — همه در دیتابیس
|
||||
- **API عمومی**: برند، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم، تایملاین، آمار و...
|
||||
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
|
||||
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`)
|
||||
و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
||||
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
|
||||
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد میشود
|
||||
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
|
||||
ذخیره و URL نسبی `/media/...` برمیگردد
|
||||
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسالشده عوض میشوند
|
||||
- **ترتیببندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
|
||||
- همه محتوا در یک درخواست: `GET /api/site` (مقالههای پیشنویس در آن نمیآیند)
|
||||
- **تستها**: `cd backend && pip install -r requirements-dev.txt && pytest tests`
|
||||
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`) و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
||||
- همه محتوا در یک درخواست: `GET /api/site`
|
||||
|
||||
> در محیط واقعی حتماً `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`
|
||||
> در محیط واقعی حتماً `SECRET_KEY` و `ADMIN_PASSWORD` را تغییر دهید.
|
||||
|
||||
## نکات امنیتی
|
||||
|
||||
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml`
|
||||
با سینتکس `${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید
|
||||
- رمز عبور ادمین با bcrypt هش میشود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
|
||||
- ورود با محدودیت تلاش محافظت میشود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
|
||||
- فایل `.env` هرگز commit نمیشود (در `.gitignore` است)
|
||||
- رمز عبور ادمین با bcrypt هش میشود
|
||||
- توکنهای JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
|
||||
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
|
||||
- مقالههای پیشنویس در هیچ انتهای عمومی نمیآیند و در HTML تزریقشده سئو هم افشا نمیشوند
|
||||
|
||||
## تغییرات اخیر
|
||||
|
||||
### بهبود سرعت (کامیت e0ebae4)
|
||||
- **حذف کامل وابستگیهای خارجی**: همه عکسهای سایت بهصورت محلی در `frontend/public/images/` قرار گرفتند (بدون وابستگی به سرورهای خارجی مثل postimg و housedesign)
|
||||
- **حذف فونتهای خارجی** (Google Fonts و fontcdn که رندر صفحه را قفل میکردند) — استفاده از فونت سیستمی سریع
|
||||
- **favicon واقعی** اضافه شد (قبلاً خراب بود)
|
||||
- **کش ۳۰ روزه** برای تصاویر و فایلهای استاتیک در nginx
|
||||
- نتیجه: صفحهها در چند میلیثانیه لود میشوند و سایت هیچ درخواست خارجی ندارد
|
||||
|
||||
> اگر نسخه قبلی سایت را اجرا کردهاید، برای دریافت نسخه جدید یک بار `docker compose down -v` و سپس `docker compose up --build -d` را اجرا کنید.
|
||||
|
||||
@@ -15,15 +15,3 @@ CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
|
||||
# مشخصات ادمین پیشفرض که هنگام seed ساخته میشود
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=aramesh123
|
||||
|
||||
# Object Storage (MinIO) برای آپلود تصاویر پنل مدیریت
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY=aramesh
|
||||
S3_SECRET_KEY=change-me-minio-secret
|
||||
S3_BUCKET=aramesh-media
|
||||
|
||||
# پیشوند عمومی مسیر تصاویر — پروکسی Reverse باید آن را به باکت برساند
|
||||
MEDIA_PUBLIC_BASE=/media
|
||||
|
||||
# حداکثر حجم آپلود تصویر (مگابایت)
|
||||
MEDIA_MAX_UPLOAD_MB=10
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
# بکاند گروه ساختمانی آرامش (Aramesh Building Group API)
|
||||
|
||||
بکاند سایت گروه ساختمانی آرامش — ساختهشده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT** + **MinIO** (ذخیره تصاویر) + **Traefik** (مسیریابی).
|
||||
بکاند سایت گروه ساختمانی آرامش — ساختهشده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT**.
|
||||
|
||||
## امکانات
|
||||
|
||||
- **API عمومی محتوا**: برند، منو، هیرو، داستان، تیتر بخشها، هیرو صفحات، خدمات، پروژهها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایملاین، آمار، چرا آرامش، قبل/بعد، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، فرم تماس، خبرنامه و فوتر — **تمام متنهای سایت در دیتابیس است**
|
||||
- **API عمومی محتوا**: برند، خدمات، پروژهها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایملاین، آمار، چرا آرامش، و بخش قبل/بعد
|
||||
- **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`)
|
||||
- **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`)
|
||||
- **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیامهای تماس و مشترکین
|
||||
- **آپلود تصویر**: `POST /api/admin/upload` — فایل در MinIO ذخیره و URL نسبی `/media/...` برمیگردد
|
||||
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار میگیرد (فقط جدولهای خالی پر میشوند — ارتقا بدون از دست رفتن داده)
|
||||
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار میگیرد
|
||||
- مستندات تعاملی Swagger در مسیر `/docs`
|
||||
|
||||
## راهاندازی
|
||||
@@ -86,13 +85,6 @@ Authorization: Bearer <access_token>
|
||||
|
||||
> نام کاربری و رمز عبور پیشفرض از تنظیمات `.env` خوانده میشود — حتماً در محیط تولید تغییرشان دهید.
|
||||
|
||||
تغییر رمز (با توکن):
|
||||
|
||||
```
|
||||
POST /api/auth/change-password
|
||||
{ "current_password": "...", "new_password": "..." }
|
||||
```
|
||||
|
||||
## فهرست انتهاپوینتها
|
||||
|
||||
### عمومی (بدون نیاز به توکن)
|
||||
@@ -104,8 +96,8 @@ POST /api/auth/change-password
|
||||
| GET | `/api/services` | خدمات |
|
||||
| GET | `/api/projects` | همه پروژهها |
|
||||
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
|
||||
| GET | `/api/articles` | مقالههای منتشرشده (پیشنویس نمیآید) |
|
||||
| GET | `/api/articles/{slug}` | جزئیات یک مقاله منتشرشده |
|
||||
| GET | `/api/articles` | همه مقالات |
|
||||
| GET | `/api/articles/{slug}` | جزئیات یک مقاله |
|
||||
| GET | `/api/testimonials` | نظرات مشتریان |
|
||||
| GET | `/api/faqs` | سوالات متداول |
|
||||
| GET | `/api/team` | اعضای تیم |
|
||||
@@ -116,11 +108,7 @@ POST /api/auth/change-password
|
||||
| GET | `/api/site` | **همه محتوا در یک درخواست** |
|
||||
| POST | `/api/contact` | ثبت پیام تماس |
|
||||
| POST | `/api/newsletter` | عضویت در خبرنامه |
|
||||
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن — بعد از ۵ تلاش ناموفق در ۱۵ دقیقه، 429 |
|
||||
| GET | `/robots.txt` | قوانین خزندهها |
|
||||
| GET | `/sitemap.xml` | نقشه سایت — مقاله/پروژههای منتشرشده |
|
||||
| GET | `/blog/{slug}` | HTML صفحه مقاله با متای سئوی تزریقشده |
|
||||
| GET | `/projects/{slug}` | HTML صفحه پروژه با متای سئوی تزریقشده |
|
||||
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن |
|
||||
|
||||
### مدیریت (نیاز به توکن Bearer)
|
||||
|
||||
@@ -130,35 +118,12 @@ POST /api/auth/change-password
|
||||
| متد | مسیر | توضیح |
|
||||
|---|---|---|
|
||||
| GET/POST | `/api/admin/{resource}` | لیست / ساخت |
|
||||
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش کامل / حذف |
|
||||
| PATCH | `/api/admin/{resource}/{id}` | **ویرایش جزئی** — فقط فیلدهای ارسالشده بهروزرسانی میشوند |
|
||||
| POST | `/api/admin/{resource}/reorder` | ذخیره ترتیب: `{"ids": [3, 1, 2, ...]}` |
|
||||
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش / حذف |
|
||||
| GET | `/api/admin/messages` | پیامهای تماس (با `?unread_only=true` فقط خواندهنشدهها) |
|
||||
| PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خواندهشدن |
|
||||
| DELETE | `/api/admin/messages/{id}` | حذف پیام |
|
||||
| GET | `/api/admin/subscribers` | مشترکین خبرنامه |
|
||||
| DELETE | `/api/admin/subscribers/{id}` | حذف مشترک |
|
||||
| POST | `/api/admin/upload` | آپلود تصویر (multipart، حداکثر ۱۰MB — jpg/png/webp/gif/avif) → `{"url": "/media/..."}` |
|
||||
|
||||
### بدنه مقاله — سند TipTap
|
||||
|
||||
`body` مقاله یک سند ساختاریافته TipTap (ProseMirror JSON) است: `{"type": "doc", "content": [...]}`.
|
||||
- فقط ساختار اعتبارسنجی میشود (`type: "doc"` + `content` لیست)؛ امنیت رندر بر عهده
|
||||
رندر استاتیک TipTap در فرانتاند است که فقط تگهای شناختهشده تولید میکند
|
||||
- ردیفهای قدیمی (لیست پاراگراف) با مهاجرت idempotent `migrate_article_bodies`
|
||||
در startup/seed خودکار به سند تبدیل میشوند
|
||||
- seed هم بدنههای لیستی را قبل از درج تبدیل میکند
|
||||
|
||||
| POST/PATCH | `/api/admin/seo-meta` | عنوان و توضیح متای صفحات — `page_key`: home / about / services / projects / investment / blog / faq / contact |
|
||||
|
||||
منابعی که `reorder` دارند (ستون `position`): `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `investment-models`، `process-steps`، `offices`، `gallery`، `nav`. ستون با مهاجرت سبک idempotent هنگام startup/seed به دیتابیسهای موجود اضافه میشود.
|
||||
|
||||
### تصاویر و MinIO
|
||||
|
||||
فایلهای آپلودشده در باکت `aramesh-media` روی MinIO ذخیره میشوند و با مسیر نسبی `/media/<سال>/<ماه>/<فایل>` برگردانده میشوند. Traefik مسیر `/media` را به MinIO میبرد، پس URLها مستقل از بکاند ذخیرهسازی هستند — بعداً برای مهاجرت به R2/S3 فقط تنظیمات را عوض کنید، نه محتوای دیتابیس را.
|
||||
|
||||
- کنسول MinIO: `http://localhost:9001` (کاربر/رمز از `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` در compose)
|
||||
- تصاویر عمومی و قابل نمایشاند؛ آپلود فقط با توکن ادمین انجام میشود.
|
||||
|
||||
## اتصال به فرانتاند
|
||||
|
||||
|
||||
@@ -1,103 +1,40 @@
|
||||
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import boto3
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from sqlalchemy import func
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models, schemas
|
||||
from ..auth import create_access_token, get_current_admin, hash_password, verify_password
|
||||
from ..config import settings
|
||||
from ..auth import create_access_token, get_current_admin, verify_password
|
||||
from ..database import get_db
|
||||
from ..rate_limit import clear as clear_rate_limit
|
||||
from ..rate_limit import ensure_allowed, record_failure
|
||||
from ..schemas import (
|
||||
ArticleIn,
|
||||
ArticleOut,
|
||||
ArticlePatch,
|
||||
BeforeAfterIn,
|
||||
BeforeAfterOut,
|
||||
BeforeAfterPatch,
|
||||
BrandIn,
|
||||
BrandOut,
|
||||
BrandPatch,
|
||||
ChangePasswordIn,
|
||||
ContactSectionIn,
|
||||
ContactSectionOut,
|
||||
ContactSectionPatch,
|
||||
ContactMessageOut,
|
||||
FaqIn,
|
||||
FaqOut,
|
||||
FaqPatch,
|
||||
FooterIn,
|
||||
FooterOut,
|
||||
FooterPatch,
|
||||
GalleryImageIn,
|
||||
GalleryImageOut,
|
||||
GalleryImagePatch,
|
||||
HeroIn,
|
||||
HeroOut,
|
||||
HeroPatch,
|
||||
InvestmentModelIn,
|
||||
InvestmentModelOut,
|
||||
InvestmentModelPatch,
|
||||
LoginIn,
|
||||
MessageReadIn,
|
||||
NavItemIn,
|
||||
NavItemOut,
|
||||
NavItemPatch,
|
||||
NewsletterSectionIn,
|
||||
NewsletterOut,
|
||||
NewsletterSectionPatch,
|
||||
OfficeIn,
|
||||
OfficeOut,
|
||||
OfficePatch,
|
||||
PageHeroIn,
|
||||
PageHeroOut,
|
||||
PageHeroPatch,
|
||||
ProcessStepIn,
|
||||
ProcessStepOut,
|
||||
ProcessStepPatch,
|
||||
ProjectIn,
|
||||
ProjectOut,
|
||||
ProjectPatch,
|
||||
ReorderIn,
|
||||
SeoMetaIn,
|
||||
SeoMetaOut,
|
||||
SeoMetaPatch,
|
||||
SectionHeadingIn,
|
||||
SectionHeadingOut,
|
||||
SectionHeadingPatch,
|
||||
ServiceIn,
|
||||
ServiceOut,
|
||||
ServicePatch,
|
||||
StatIn,
|
||||
StatOut,
|
||||
StatPatch,
|
||||
StatusLabelIn,
|
||||
StatusLabelOut,
|
||||
StatusLabelPatch,
|
||||
StoryIn,
|
||||
StoryOut,
|
||||
StoryPatch,
|
||||
SubscriberOut,
|
||||
TeamMemberIn,
|
||||
TeamMemberOut,
|
||||
TeamMemberPatch,
|
||||
TestimonialIn,
|
||||
TestimonialOut,
|
||||
TestimonialPatch,
|
||||
TimelineItemIn,
|
||||
TimelineItemOut,
|
||||
TimelineItemPatch,
|
||||
TokenOut,
|
||||
WhyUsItemIn,
|
||||
WhyUsItemOut,
|
||||
WhyUsItemPatch,
|
||||
)
|
||||
|
||||
# روتر عمومی برای ورود — بدون نیاز به توکن
|
||||
@@ -108,52 +45,26 @@ router = APIRouter(dependencies=[Depends(get_current_admin)])
|
||||
|
||||
|
||||
@auth_router.post("/auth/login", response_model=TokenOut)
|
||||
def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
|
||||
ensure_allowed(request)
|
||||
def login(payload: LoginIn, db: Session = Depends(get_db)):
|
||||
user = db.query(models.AdminUser).filter(models.AdminUser.username == payload.username).first()
|
||||
if user is None or not verify_password(payload.password, user.password_hash):
|
||||
record_failure(request)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="نام کاربری یا رمز عبور اشتباه است",
|
||||
)
|
||||
clear_rate_limit(request)
|
||||
return TokenOut(access_token=create_access_token(user.username))
|
||||
|
||||
|
||||
@auth_router.post("/auth/change-password")
|
||||
def change_password(
|
||||
payload: ChangePasswordIn,
|
||||
admin: models.AdminUser = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""تغییر رمز ادمین واردشده — رمز فعلی باید درست باشد."""
|
||||
if not verify_password(payload.current_password, admin.password_hash):
|
||||
raise HTTPException(status_code=400, detail="رمز عبور فعلی اشتباه است")
|
||||
admin.password_hash = hash_password(payload.new_password)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
|
||||
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
||||
حذف و ترتیببندی) روی روتر مدیریت."""
|
||||
def register_crud(path: str, model, out_schema, create_schema) -> None:
|
||||
"""ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت."""
|
||||
|
||||
@router.get(f"/{path}", response_model=list[out_schema])
|
||||
def list_items(db: Session = Depends(get_db)):
|
||||
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()
|
||||
return db.query(model).order_by(model.id).all()
|
||||
|
||||
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
||||
def create_item(payload: create_schema, db: Session = Depends(get_db)):
|
||||
item = model(**payload.model_dump())
|
||||
if hasattr(model, "position"):
|
||||
# آیتم جدید در انتهای لیست قرار میگیرد
|
||||
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
|
||||
db.add(item)
|
||||
try:
|
||||
db.commit()
|
||||
@@ -185,38 +96,6 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
if patch_schema is not None:
|
||||
|
||||
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
|
||||
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)):
|
||||
"""ویرایش جزئی — فقط فیلدهای ارسالشده بهروزرسانی میشوند (برای ویرایش زنده)."""
|
||||
item = db.get(model, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(item, key, value)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
@router.post(f"/{path}/reorder", response_model=list[out_schema])
|
||||
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)):
|
||||
"""ذخیره ترتیب جدید آیتمها بر اساس لیست idهای ارسالی."""
|
||||
if not hasattr(model, "position"):
|
||||
raise HTTPException(status_code=400, detail="این منبع از ترتیببندی پشتیبانی نمیکند")
|
||||
items = db.query(model).filter(model.id.in_(payload.ids)).all()
|
||||
if len(items) != len(payload.ids):
|
||||
raise HTTPException(status_code=404, detail="برخی رکوردها یافت نشدند")
|
||||
by_id = {item.id: item for item in items}
|
||||
for pos, item_id in enumerate(payload.ids):
|
||||
by_id[item_id].position = pos
|
||||
db.commit()
|
||||
return db.query(model).order_by(model.position, model.id).all()
|
||||
|
||||
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
||||
def delete_item(item_id: int, db: Session = Depends(get_db)):
|
||||
item = db.get(model, item_id)
|
||||
@@ -227,87 +106,17 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
|
||||
|
||||
# CRUD محتوا
|
||||
register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch)
|
||||
register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch)
|
||||
register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch)
|
||||
register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch)
|
||||
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch)
|
||||
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
|
||||
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
|
||||
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
|
||||
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch)
|
||||
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
|
||||
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
|
||||
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
|
||||
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn, TimelineItemPatch)
|
||||
register_crud("stats", models.Stat, StatOut, StatIn, StatPatch)
|
||||
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn, WhyUsItemPatch)
|
||||
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn, BeforeAfterPatch)
|
||||
register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn, InvestmentModelPatch)
|
||||
register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn, ProcessStepPatch)
|
||||
register_crud("offices", models.Office, OfficeOut, OfficeIn, OfficePatch)
|
||||
register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn, GalleryImagePatch)
|
||||
register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn, StatusLabelPatch)
|
||||
register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch)
|
||||
register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch)
|
||||
register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
|
||||
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
|
||||
|
||||
|
||||
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمیگرداند تا بکاند ذخیرهسازی قابل تعویض باشد
|
||||
ALLOWED_IMAGE_TYPES = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"image/avif": "avif",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_media(
|
||||
file: UploadFile = File(...),
|
||||
_admin: models.AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
content_type = (file.content_type or "").lower()
|
||||
ext = ALLOWED_IMAGE_TYPES.get(content_type)
|
||||
if ext is None:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="فرمت فایل پشتیبانی نمیشود (مجاز: jpg، png، webp، gif، avif)",
|
||||
)
|
||||
|
||||
max_bytes = settings.media_max_upload_mb * 1024 * 1024
|
||||
data = await file.read()
|
||||
if len(data) == 0:
|
||||
raise HTTPException(status_code=400, detail="فایل خالی است")
|
||||
if len(data) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
|
||||
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
region_name="us-east-1",
|
||||
)
|
||||
try:
|
||||
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
||||
|
||||
return {
|
||||
"url": f"{settings.media_public_base}/{key}",
|
||||
"key": key,
|
||||
"size": len(data),
|
||||
"content_type": content_type,
|
||||
}
|
||||
register_crud("brand", models.Brand, BrandOut, BrandIn)
|
||||
register_crud("services", models.Service, ServiceOut, ServiceIn)
|
||||
register_crud("projects", models.Project, ProjectOut, ProjectIn)
|
||||
register_crud("articles", models.Article, ArticleOut, ArticleIn)
|
||||
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn)
|
||||
register_crud("faqs", models.Faq, FaqOut, FaqIn)
|
||||
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn)
|
||||
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn)
|
||||
register_crud("stats", models.Stat, StatOut, StatIn)
|
||||
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn)
|
||||
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn)
|
||||
|
||||
|
||||
# پیامهای تماس
|
||||
|
||||
@@ -37,12 +37,12 @@ def get_brand(db: Session = Depends(get_db)):
|
||||
|
||||
@router.get("/services", response_model=list[ServiceOut])
|
||||
def get_services(db: Session = Depends(get_db)):
|
||||
return db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
|
||||
return db.query(models.Service).all()
|
||||
|
||||
|
||||
@router.get("/projects", response_model=list[ProjectOut])
|
||||
def get_projects(db: Session = Depends(get_db)):
|
||||
return db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
||||
return db.query(models.Project).order_by(models.Project.id).all()
|
||||
|
||||
|
||||
@router.get("/projects/{slug}", response_model=ProjectOut)
|
||||
@@ -55,21 +55,12 @@ def get_project(slug: str, db: Session = Depends(get_db)):
|
||||
|
||||
@router.get("/articles", response_model=list[ArticleOut])
|
||||
def get_articles(db: Session = Depends(get_db)):
|
||||
return (
|
||||
db.query(models.Article)
|
||||
.filter(models.Article.status == "published")
|
||||
.order_by(models.Article.position, models.Article.id)
|
||||
.all()
|
||||
)
|
||||
return db.query(models.Article).order_by(models.Article.id).all()
|
||||
|
||||
|
||||
@router.get("/articles/{slug}", response_model=ArticleOut)
|
||||
def get_article(slug: str, db: Session = Depends(get_db)):
|
||||
article = (
|
||||
db.query(models.Article)
|
||||
.filter(models.Article.slug == slug, models.Article.status == "published")
|
||||
.first()
|
||||
)
|
||||
article = db.query(models.Article).filter(models.Article.slug == slug).first()
|
||||
if article is None:
|
||||
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||
return article
|
||||
@@ -77,32 +68,32 @@ def get_article(slug: str, db: Session = Depends(get_db)):
|
||||
|
||||
@router.get("/testimonials", response_model=list[TestimonialOut])
|
||||
def get_testimonials(db: Session = Depends(get_db)):
|
||||
return db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all()
|
||||
return db.query(models.Testimonial).all()
|
||||
|
||||
|
||||
@router.get("/faqs", response_model=list[FaqOut])
|
||||
def get_faqs(db: Session = Depends(get_db)):
|
||||
return db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all()
|
||||
return db.query(models.Faq).all()
|
||||
|
||||
|
||||
@router.get("/team", response_model=list[TeamMemberOut])
|
||||
def get_team(db: Session = Depends(get_db)):
|
||||
return db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all()
|
||||
return db.query(models.TeamMember).all()
|
||||
|
||||
|
||||
@router.get("/timeline", response_model=list[TimelineItemOut])
|
||||
def get_timeline(db: Session = Depends(get_db)):
|
||||
return db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all()
|
||||
return db.query(models.TimelineItem).order_by(models.TimelineItem.id).all()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=list[StatOut])
|
||||
def get_stats(db: Session = Depends(get_db)):
|
||||
return db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all()
|
||||
return db.query(models.Stat).all()
|
||||
|
||||
|
||||
@router.get("/why-us", response_model=list[WhyUsItemOut])
|
||||
def get_why_us(db: Session = Depends(get_db)):
|
||||
return db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all()
|
||||
return db.query(models.WhyUsItem).all()
|
||||
|
||||
|
||||
@router.get("/before-after", response_model=BeforeAfterOut)
|
||||
@@ -118,32 +109,14 @@ def get_site(db: Session = Depends(get_db)):
|
||||
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانتاند."""
|
||||
return {
|
||||
"brand": db.query(models.Brand).first(),
|
||||
"nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(),
|
||||
"hero": db.query(models.Hero).first(),
|
||||
"story": db.query(models.StorySection).first(),
|
||||
"section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(),
|
||||
"page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(),
|
||||
"services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(),
|
||||
"projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(),
|
||||
# فقط مقالههای منتشرشده — پیشنویسها از انتهای مدیریتی خوانده میشوند
|
||||
"articles": db.query(models.Article)
|
||||
.filter(models.Article.status == "published")
|
||||
.order_by(models.Article.position, models.Article.id)
|
||||
.all(),
|
||||
"testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(),
|
||||
"faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(),
|
||||
"team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(),
|
||||
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all(),
|
||||
"stats": db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all(),
|
||||
"why_us": db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all(),
|
||||
"services": db.query(models.Service).all(),
|
||||
"projects": db.query(models.Project).order_by(models.Project.id).all(),
|
||||
"articles": db.query(models.Article).order_by(models.Article.id).all(),
|
||||
"testimonials": db.query(models.Testimonial).all(),
|
||||
"faqs": db.query(models.Faq).all(),
|
||||
"team": db.query(models.TeamMember).all(),
|
||||
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(),
|
||||
"stats": db.query(models.Stat).all(),
|
||||
"why_us": db.query(models.WhyUsItem).all(),
|
||||
"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,19 +18,5 @@ class Settings(BaseSettings):
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "aramesh123"
|
||||
|
||||
# Object Storage (MinIO / هر S3 سازگار) برای آپلود تصاویر
|
||||
s3_endpoint: str = "http://localhost:9000"
|
||||
s3_access_key: str = "aramesh"
|
||||
s3_secret_key: str = "aramesh-minio-secret"
|
||||
s3_bucket: str = "aramesh-media"
|
||||
# پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی میکند
|
||||
media_public_base: str = "/media"
|
||||
media_max_upload_mb: int = 10
|
||||
|
||||
# نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
|
||||
site_url: str = ""
|
||||
# مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
|
||||
frontend_dist: str = "../frontend/dist"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -5,11 +5,8 @@ from . import models # noqa: F401 — ثبت مدلها پیش از ساخت
|
||||
from .api import admin, forms, public
|
||||
from .config import settings
|
||||
from .database import Base, engine
|
||||
from .migrations import run_all as run_migrations
|
||||
from .seo_pages import router as seo_router
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
app = FastAPI(
|
||||
title="Aramesh Building Group API",
|
||||
@@ -34,8 +31,6 @@ app.include_router(public.router, prefix="/api", tags=["عمومی"])
|
||||
app.include_router(forms.router, prefix="/api", tags=["فرمها"])
|
||||
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"])
|
||||
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
|
||||
app.include_router(seo_router, tags=["سئو"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"""مهاجرتهای سبک دیتابیس — 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,8 +35,6 @@ class Brand(Base):
|
||||
class Service(Base):
|
||||
__tablename__ = "services"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -46,8 +44,6 @@ class Service(Base):
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
@@ -67,8 +63,6 @@ class Project(Base):
|
||||
class Article(Base):
|
||||
__tablename__ = "articles"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
@@ -78,17 +72,11 @@ class Article(Base):
|
||||
read: Mapped[str] = mapped_column(String(32), default="")
|
||||
excerpt: Mapped[str] = mapped_column(Text, default="")
|
||||
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):
|
||||
__tablename__ = "testimonials"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
role: Mapped[str] = mapped_column(String(128), default="")
|
||||
@@ -98,8 +86,6 @@ class Testimonial(Base):
|
||||
class Faq(Base):
|
||||
__tablename__ = "faqs"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
question: Mapped[str] = mapped_column(Text)
|
||||
answer: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -108,8 +94,6 @@ class Faq(Base):
|
||||
class TeamMember(Base):
|
||||
__tablename__ = "team"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
role: Mapped[str] = mapped_column(String(128), default="")
|
||||
@@ -119,8 +103,6 @@ class TeamMember(Base):
|
||||
class TimelineItem(Base):
|
||||
__tablename__ = "timeline"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
year: Mapped[str] = mapped_column(String(32))
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
@@ -131,8 +113,6 @@ class TimelineItem(Base):
|
||||
class Stat(Base):
|
||||
__tablename__ = "stats"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
value: Mapped[int] = mapped_column(Integer)
|
||||
suffix: Mapped[str] = mapped_column(String(16), default="")
|
||||
@@ -142,8 +122,6 @@ class Stat(Base):
|
||||
class WhyUsItem(Base):
|
||||
__tablename__ = "why_us"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -161,200 +139,6 @@ class BeforeAfter(Base):
|
||||
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):
|
||||
__tablename__ = "contact_messages"
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login.
|
||||
|
||||
پیشفرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک میکند.
|
||||
شمارنده در حافظه همین پروسه است — با ریاستارت بکاند پاک میشود و همین کافی است.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
MAX_FAILED_ATTEMPTS = 5
|
||||
WINDOW_SECONDS = 15 * 60
|
||||
|
||||
_failed: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _prune(key: str, now: float) -> list[float]:
|
||||
recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS]
|
||||
_failed[key] = recent
|
||||
return recent
|
||||
|
||||
|
||||
def ensure_allowed(request: Request) -> None:
|
||||
"""اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد میشود."""
|
||||
key = client_ip(request)
|
||||
recent = _prune(key, time.monotonic())
|
||||
if len(recent) >= MAX_FAILED_ATTEMPTS:
|
||||
retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0])))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="تلاشهای ناموفق زیاد است — بعداً دوباره امتحان کنید",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
|
||||
def record_failure(request: Request) -> None:
|
||||
_failed.setdefault(client_ip(request), []).append(time.monotonic())
|
||||
|
||||
|
||||
def clear(request: Request) -> None:
|
||||
"""با ورود موفق، تاریخچه تلاشهای ناموفق IP پاک میشود."""
|
||||
_failed.pop(client_ip(request), None)
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
# ---------- پایه ----------
|
||||
|
||||
@@ -58,9 +58,7 @@ class ArticleOut(ORMModel):
|
||||
image: str
|
||||
read: str
|
||||
excerpt: str
|
||||
body: dict
|
||||
status: str
|
||||
seo_keyphrase: str
|
||||
body: list[str]
|
||||
|
||||
|
||||
class TestimonialOut(ORMModel):
|
||||
@@ -114,144 +112,6 @@ class BeforeAfterOut(ORMModel):
|
||||
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):
|
||||
id: int
|
||||
name: str
|
||||
@@ -294,11 +154,6 @@ class TokenOut(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class BrandIn(BaseModel):
|
||||
name: str = ""
|
||||
logo: str = ""
|
||||
@@ -332,18 +187,6 @@ class ProjectIn(BaseModel):
|
||||
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):
|
||||
slug: str = Field(min_length=1, max_length=128)
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
@@ -352,10 +195,7 @@ class ArticleIn(BaseModel):
|
||||
image: str = ""
|
||||
read: str = ""
|
||||
excerpt: 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)
|
||||
body: list[str] = []
|
||||
|
||||
|
||||
class TestimonialIn(BaseModel):
|
||||
@@ -402,187 +242,5 @@ class BeforeAfterIn(BaseModel):
|
||||
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):
|
||||
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]
|
||||
|
||||
@@ -1,50 +1,16 @@
|
||||
"""اسکریپت seed دیتابیس.
|
||||
|
||||
اجرا:
|
||||
python -m app.seed # فقط جدولهای خالی پر میشوند
|
||||
python -m app.seed # فقط اگر دیتابیس خالی باشد
|
||||
python -m app.seed --force # حذف کامل دادهها و ساخت مجدد
|
||||
|
||||
پر کردن هر جدول مستقل است: روی یک دیتابیس موجود، فقط جدولهای جدید یا خالی
|
||||
seed میشوند و دادههای قبلی دستنخورده میمانند.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from typing import Any
|
||||
|
||||
from . import models, seed_data
|
||||
from .auth import hash_password
|
||||
from .config import settings
|
||||
from .database import Base, SessionLocal, engine
|
||||
from .migrations import paragraphs_to_doc, run_all as run_migrations
|
||||
|
||||
# (مدل، دادههای اولیه، برچسب گزارش)
|
||||
CONTENT: list[tuple[Any, list[dict], str]] = [
|
||||
(models.Brand, [seed_data.BRAND], "برند"),
|
||||
(models.Hero, [seed_data.HERO], "هیرو صفحه اصلی"),
|
||||
(models.StorySection, [seed_data.STORY], "داستان ما"),
|
||||
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, "تیتر بخشها"),
|
||||
(models.PageHeroContent, seed_data.PAGE_HEROES, "هیرو صفحات"),
|
||||
(models.NavItem, seed_data.NAV_ITEMS, "منوی ناوبری"),
|
||||
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, "مدلهای سرمایهگذاری"),
|
||||
(models.ProcessStep, seed_data.PROCESS_STEPS, "مراحل فرآیند کار"),
|
||||
(models.Office, seed_data.OFFICES, "دفاتر"),
|
||||
(models.GalleryImage, seed_data.GALLERY_IMAGES, "گالری تصاویر"),
|
||||
(models.StatusLabel, seed_data.STATUS_LABELS, "برچسب وضعیت پروژه"),
|
||||
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], "بخش تماس"),
|
||||
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], "خبرنامه"),
|
||||
(models.FooterContent, [seed_data.FOOTER], "فوتر"),
|
||||
(models.Service, seed_data.SERVICES, "خدمات"),
|
||||
(models.Stat, seed_data.STATS, "آمار"),
|
||||
(models.Project, seed_data.PROJECTS, "پروژهها"),
|
||||
(models.Article, seed_data.ARTICLES, "مقالات"),
|
||||
(models.Testimonial, seed_data.TESTIMONIALS, "نظرات"),
|
||||
(models.Faq, seed_data.FAQS, "سوالات متداول"),
|
||||
(models.TeamMember, seed_data.TEAM, "تیم"),
|
||||
(models.TimelineItem, seed_data.TIMELINE, "تایملاین"),
|
||||
(models.WhyUsItem, seed_data.WHY, "چرا آرامش"),
|
||||
(models.BeforeAfter, [seed_data.BEFORE_AFTER], "قبل/بعد"),
|
||||
(models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"),
|
||||
]
|
||||
|
||||
|
||||
def seed(force: bool = False) -> None:
|
||||
@@ -55,19 +21,32 @@ def seed(force: bool = False) -> None:
|
||||
print("جداول قبلی حذف شدند.")
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
for model, rows, label in CONTENT:
|
||||
if db.query(model).count() > 0:
|
||||
continue
|
||||
for idx, row in enumerate(rows):
|
||||
payload = dict(row)
|
||||
if hasattr(model, "position") and "position" not in payload:
|
||||
payload["position"] = idx
|
||||
# بدنه مقالات در seed بهصورت لیست پاراگراف است — به سند تبدیل میشود
|
||||
if model is models.Article and isinstance(payload.get("body"), list):
|
||||
payload["body"] = paragraphs_to_doc(payload["body"])
|
||||
db.add(model(**payload))
|
||||
if not force and db.query(models.Service).count() > 0:
|
||||
print("دیتابیس قبلاً seed شده است. برای بازنشانی کامل: python -m app.seed --force")
|
||||
return
|
||||
|
||||
db.add(models.Brand(**seed_data.BRAND))
|
||||
|
||||
for item in seed_data.SERVICES:
|
||||
db.add(models.Service(**item))
|
||||
for item in seed_data.STATS:
|
||||
db.add(models.Stat(**item))
|
||||
for item in seed_data.PROJECTS:
|
||||
db.add(models.Project(**item))
|
||||
for item in seed_data.ARTICLES:
|
||||
db.add(models.Article(**item))
|
||||
for item in seed_data.TESTIMONIALS:
|
||||
db.add(models.Testimonial(**item))
|
||||
for item in seed_data.FAQS:
|
||||
db.add(models.Faq(**item))
|
||||
for item in seed_data.TEAM:
|
||||
db.add(models.TeamMember(**item))
|
||||
for item in seed_data.TIMELINE:
|
||||
db.add(models.TimelineItem(**item))
|
||||
for item in seed_data.WHY:
|
||||
db.add(models.WhyUsItem(**item))
|
||||
db.add(models.BeforeAfter(**seed_data.BEFORE_AFTER))
|
||||
|
||||
if db.query(models.AdminUser).count() == 0:
|
||||
db.add(
|
||||
@@ -80,8 +59,19 @@ def seed(force: bool = False) -> None:
|
||||
|
||||
db.commit()
|
||||
|
||||
for model, _, label in CONTENT:
|
||||
print(f" {label}: {db.query(model).count()} رکورد")
|
||||
counts = {
|
||||
"خدمات": db.query(models.Service).count(),
|
||||
"پروژهها": db.query(models.Project).count(),
|
||||
"مقالات": db.query(models.Article).count(),
|
||||
"نظرات": db.query(models.Testimonial).count(),
|
||||
"سوالات متداول": db.query(models.Faq).count(),
|
||||
"تیم": db.query(models.TeamMember).count(),
|
||||
"تایملاین": db.query(models.TimelineItem).count(),
|
||||
"آمار": db.query(models.Stat).count(),
|
||||
"چرا آرامش": db.query(models.WhyUsItem).count(),
|
||||
}
|
||||
for label, count in counts.items():
|
||||
print(f" {label}: {count} رکورد")
|
||||
print("✔ seed با موفقیت انجام شد.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
BRAND = {
|
||||
"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",
|
||||
"contact_bg": "https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png",
|
||||
"logo": "/images/logo.png",
|
||||
"hero": "/images/hero.png",
|
||||
"contact_bg": "/images/contact.png",
|
||||
"phone": "۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰",
|
||||
"phone_href": "tel:+982188742210",
|
||||
"email": "info@arameshbuildinggroup.com",
|
||||
@@ -12,11 +12,11 @@ BRAND = {
|
||||
}
|
||||
|
||||
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",
|
||||
"/images/img1.png",
|
||||
"/images/img2.png",
|
||||
"/images/contact.png",
|
||||
"/images/img4.png",
|
||||
"/images/img5.png",
|
||||
]
|
||||
|
||||
SERVICES = [
|
||||
@@ -154,8 +154,8 @@ PROJECTS = [
|
||||
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",
|
||||
"before": "/images/before.webp",
|
||||
"after": "/images/after.webp",
|
||||
"before_label": "کانسپت اولیه",
|
||||
"after_label": "پروژه تکمیلشده",
|
||||
}
|
||||
@@ -289,170 +289,3 @@ FAQS = [
|
||||
{"question": "خدمات پس از تحویل شامل چه مواردی میشود؟", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دورهای سالانه و تضمین اجرای نما و عایقکاری."},
|
||||
{"question": "پروژهها در چه شهرهایی اجرا میشوند؟", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژهای نیز در جزیره کیش در دست ساخت است."},
|
||||
]
|
||||
|
||||
# ---------- محتوای قبلاً هاردکدشده در فرانتاند (الان در دیتابیس) ----------
|
||||
|
||||
NAV_ITEMS = [
|
||||
{"label": "صفحه اصلی", "href": "/"},
|
||||
{"label": "درباره ما", "href": "/about"},
|
||||
{"label": "خدمات", "href": "/services"},
|
||||
{"label": "پروژهها", "href": "/projects"},
|
||||
{"label": "سرمایهگذاری", "href": "/investment"},
|
||||
{"label": "وبلاگ", "href": "/blog"},
|
||||
{"label": "سوالات متداول", "href": "/faq"},
|
||||
{"label": "تماس با ما", "href": "/contact"},
|
||||
]
|
||||
|
||||
HERO = {
|
||||
"kicker": "از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال",
|
||||
"title": "ساختمانی که آرامش را معماری میکند",
|
||||
"title_highlight": "معماری",
|
||||
"subtitle": "طراحی، ساخت و سرمایهگذاری در پروژههای مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده میشود و در سالها دوام میآورد.",
|
||||
"badge_value": "۴۷",
|
||||
"badge_label": "پروژه تحویلشده در بیستویک سال فعالیت حرفهای",
|
||||
"cta_primary_label": "مشاهده پروژهها",
|
||||
"cta_primary_href": "/projects",
|
||||
"cta_secondary_label": "مشاوره سرمایهگذاری",
|
||||
"cta_secondary_href": "/contact",
|
||||
"scroll_label": "اسکرول کنید",
|
||||
}
|
||||
|
||||
STORY = {
|
||||
"title": "درباره ی ما",
|
||||
"paragraphs": [
|
||||
"گروه ساختمانی آرامش با یک دفتر کوچک معماری آغاز شد و امروز یکی از مجموعههای شناختهشده ساختوساز لوکس در ایران است. در تمام این سالها یک اصل تغییر نکرده است: ساختمان باید برای زندگی ساخته شود، نه برای فروش سریع.",
|
||||
"هر پروژه با مطالعه زمین، نور، اقلیم و الگوی زندگی ساکنان آغاز میشود؛ سپس تیم طراحی، سازه و اجرا در یک فرآیند واحد آن را تا تحویل کلید پیش میبرند.",
|
||||
],
|
||||
"quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی میسازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»",
|
||||
"quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
|
||||
"stat1_value": "۹۴٪",
|
||||
"stat1_label": "تحویل بهموقع پروژهها",
|
||||
"stat2_value": "۵ سال",
|
||||
"stat2_label": "پشتیبانی پس از تحویل",
|
||||
"link_label": "داستان کامل ما",
|
||||
"link_href": "/about",
|
||||
"bento": [
|
||||
{"src": IMAGES[0], "alt": "نمای برج مسکونی آرامش در تهران"},
|
||||
{"src": IMAGES[1], "alt": "آتریوم مرکزی مجتمع تجاری پارسه"},
|
||||
{"src": IMAGES[2], "alt": "ویلاهای ساختهشده در سواحل شمال"},
|
||||
{"src": IMAGES[3], "alt": "نمای شبانه برج دوقلوی نیاوران"},
|
||||
],
|
||||
}
|
||||
|
||||
SECTION_HEADINGS = [
|
||||
{"key": "services", "title": "خدمات", "lead": "از نخستین خط روی کاغذ تا تحویل کلید، مدیریت سرمایه و طراحی محوطه؛ همه مراحل در یک مجموعه و زیر یک نظام کنترل کیفیت."},
|
||||
{"key": "featured_projects", "title": "پروژههای شاخص", "lead": "کارهایی که بیشترین چیز را درباره استاندارد ما میگویند؛ چه آنهایی که تحویل شدهاند و چه آنهایی که همین امروز در حال ساختاند.", "cta_label": "همه پروژهها", "cta_href": "/projects"},
|
||||
{"key": "gallery", "title": "گالری تصاویر", "lead": "قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."},
|
||||
{"key": "timeline", "title": "مسیر ما", "lead": "از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژههای ما را ببینید."},
|
||||
{"key": "why_us", "title": "چرا گروه ساختمانی آرامش", "lead": "شش دلیلی که کارفرمایان و سرمایهگذاران بار دوم هم به ما برمیگردند.", "image": IMAGES[3]},
|
||||
{"key": "testimonials", "title": "نظرات مشتریان"},
|
||||
{"key": "team", "title": "تیم ما", "lead": "کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."},
|
||||
{"key": "articles", "label": "وبلاگ", "title": "آخرین مقالات", "lead": "یادداشتهای تیم معماری، اجرا و سرمایهگذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار میگذرد.", "cta_label": "همه مقالات", "cta_href": "/blog"},
|
||||
{"key": "faq", "title": "سوالات متداول", "lead": "پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."},
|
||||
{"key": "investment_models", "title": "مدلهای همکاری", "lead": "بسته به میزان سرمایه، افق زمانی و سطح ریسکی که میپذیرید، یکی از این سه مسیر مناسب شماست."},
|
||||
{"key": "process", "title": "فرآیند کار ما", "lead": "چهار مرحله شفاف، از نخستین جلسه تا تحویل کلید."},
|
||||
]
|
||||
|
||||
PAGE_HEROES = [
|
||||
{"key": "about", "title": "درباره ما", "lead": "گروه ساختمانی آرامش مجموعهای از معماران، مهندسان سازه و مدیران پروژه است که از سال ۱۳۸۳ در حوزه ساختوساز لوکس فعالیت میکند.", "image": IMAGES[3]},
|
||||
{"key": "services", "title": "خدمات", "lead": "هشت حوزه تخصصی که چرخه کامل یک پروژه ساختمانی را از ایده تا بهرهبرداری پوشش میدهند.", "image": IMAGES[1]},
|
||||
{"key": "projects", "title": "پروژهها", "lead": "مجموعهای از برجهای مسکونی، مراکز تجاری و ویلاهای اختصاصی؛ از پروژههای تحویلشده تا کارگاههای فعال امروز.", "image": IMAGES[0]},
|
||||
{"key": "investment", "title": "سرمایهگذاری", "lead": "سه مسیر روشن برای ورود به بازار ساختوساز لوکس، با ساختار مالی شفاف و گزارشدهی منظم.", "image": IMAGES[4]},
|
||||
{"key": "blog", "title": "وبلاگ", "lead": "یادداشتهای تیم معماری، اجرا و سرمایهگذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار میگذرد.", "image": IMAGES[2]},
|
||||
{"key": "faq", "title": "سوالات متداول", "lead": "پیش از تماس، شاید پاسخ پرسش شما همینجا باشد. اگر نبود، خوشحال میشویم مستقیم صحبت کنیم.", "image": IMAGES[1]},
|
||||
{"key": "contact", "title": "تماس با ما", "lead": "برای مشاوره ساخت، سرمایهگذاری یا بازدید از پروژهها، تیم ما در سه شهر آماده پاسخگویی است.", "image": IMAGES[1]},
|
||||
]
|
||||
|
||||
INVESTMENT_MODELS = [
|
||||
{
|
||||
"title": "مشارکت در ساخت",
|
||||
"desc": "مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک میشوند؛ اجرا، مجوزها و فروش بر عهده ماست.",
|
||||
"points": ["ارزیابی رایگان زمین", "قرارداد شفاف با ضمانت اجرا", "گزارش ماهانه پیشرفت"],
|
||||
},
|
||||
{
|
||||
"title": "صندوق پروژهمحور",
|
||||
"desc": "سرمایهگذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبهشده در پایان دوره.",
|
||||
"points": ["حداقل دوره ۲۴ ماه", "گزارش ارزشگذاری فصلی", "امکان انتقال واحد سرمایه"],
|
||||
},
|
||||
{
|
||||
"title": "پیشخرید واحد",
|
||||
"desc": "خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحلهای.",
|
||||
"points": ["پرداخت مرحلهای متناسب با پیشرفت", "امکان شخصیسازی پلان", "تضمین زمان تحویل"],
|
||||
},
|
||||
]
|
||||
|
||||
PROCESS_STEPS = [
|
||||
{"step": "۰۱", "title": "مطالعه و امکانسنجی", "desc": "بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه."},
|
||||
{"step": "۰۲", "title": "طراحی و فاز اجرایی", "desc": "کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشههای اجرایی."},
|
||||
{"step": "۰۳", "title": "اجرا و کنترل کیفیت", "desc": "ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه."},
|
||||
{"step": "۰۴", "title": "تحویل و پشتیبانی", "desc": "تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی."},
|
||||
]
|
||||
|
||||
OFFICES = [
|
||||
{"city": "تهران", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸"},
|
||||
{"city": "اصفهان", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷"},
|
||||
{"city": "نوشهر", "address": "بلوار دریا، دفتر پروژه دریاکنار", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶"},
|
||||
]
|
||||
|
||||
GALLERY_IMAGES = [
|
||||
{"src": IMAGES[0], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۱"},
|
||||
{"src": IMAGES[1], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۲"},
|
||||
{"src": IMAGES[2], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۳"},
|
||||
{"src": IMAGES[3], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۴"},
|
||||
{"src": IMAGES[4], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۵"},
|
||||
{"src": IMAGES[1], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۶"},
|
||||
]
|
||||
|
||||
STATUS_LABELS = [
|
||||
{"key": "done", "label": "انجام شده"},
|
||||
{"key": "progress", "label": "در حال انجام"},
|
||||
]
|
||||
|
||||
CONTACT_SECTION = {
|
||||
"label": "تماس با ما",
|
||||
"title": "پروژهتان را با ما در میان بگذارید",
|
||||
"lead": "شمارهتان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس میگیرد.",
|
||||
"address_label": "دفتر مرکزی",
|
||||
"phone_label": "تلفن",
|
||||
"email_label": "ایمیل",
|
||||
"name_label": "نام",
|
||||
"name_placeholder": "نام و نام خانوادگی",
|
||||
"phone_field_label": "شماره تلفن",
|
||||
"phone_placeholder": "09xx xxx xxxx",
|
||||
"note_label": "توضیحات",
|
||||
"note_optional_label": "(اختیاری)",
|
||||
"note_placeholder": "درباره پروژه یا درخواست خود بنویسید…",
|
||||
"submit_label": "ارسال درخواست مشاوره",
|
||||
"sending_label": "در حال ارسال…",
|
||||
"success_title": "درخواست شما ثبت شد",
|
||||
"success_text": "از اعتماد شما سپاسگزاریم. کارشناسان ما بهزودی با شما تماس میگیرند.",
|
||||
"success_button_label": "ثبت درخواست جدید",
|
||||
}
|
||||
|
||||
NEWSLETTER = {
|
||||
"title": "خبرنامه گروه ساختمانی آرامش",
|
||||
"lead": "هر ماه یک ایمیل: پروژههای جدید، فرصتهای سرمایهگذاری و یادداشتهای تیم معماری.",
|
||||
"placeholder": "نشانی ایمیل شما",
|
||||
"button_label": "عضویت",
|
||||
"sending_label": "در حال ارسال…",
|
||||
"success_text": "عضویت شما ثبت شد.",
|
||||
}
|
||||
|
||||
FOOTER = {
|
||||
"about_text": "گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمانهایی میسازد که قرار است چند نسل در آنها زندگی کنند؛ معماری آرام، اجرای دقیق و سرمایهگذاری شفاف.",
|
||||
"pages_heading": "صفحات",
|
||||
"services_heading": "خدمات",
|
||||
"contact_heading": "ارتباط با ما",
|
||||
"copyright": "© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.",
|
||||
"tagline": "طراحی و ساخت با دقتِ معماری",
|
||||
"socials": [
|
||||
{"label": "اینستاگرام", "href": "#", "icon": "instagram"},
|
||||
{"label": "لینکدین", "href": "#", "icon": "linkedin"},
|
||||
{"label": "تلگرام", "href": "#", "icon": "telegram"},
|
||||
],
|
||||
}
|
||||
|
||||
SEO_META = [
|
||||
{"page_key": key, "title": "", "description": ""}
|
||||
for key in ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
|
||||
]
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"""سئوی سمت سرور — 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",
|
||||
},
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
# وابستگیهای توسعه و تست — pip install -r requirements-dev.txt
|
||||
-r requirements.txt
|
||||
pytest==8.3.4
|
||||
httpx==0.27.2
|
||||
@@ -1,13 +1,9 @@
|
||||
# نسخهها دقیق پین شدهاند تا ارتقای خودکار یک وابستگی، استقرار را نشکند.
|
||||
# ارتقا: نسخه را دستی بالا ببرید و روی محیط آمادهسازی تست کنید.
|
||||
fastapi==0.115.8
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
psycopg[binary]==3.2.3
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.6.1
|
||||
PyJWT==2.10.1
|
||||
bcrypt==4.2.1
|
||||
email-validator==2.2.0
|
||||
boto3==1.35.0
|
||||
python-multipart==0.0.18
|
||||
fastapi>=0.115.8
|
||||
uvicorn[standard]>=0.34.0
|
||||
sqlalchemy>=2.0.36
|
||||
psycopg[binary]>=3.2.3
|
||||
pydantic>=2.10.4
|
||||
pydantic-settings>=2.6.1
|
||||
PyJWT>=2.10.1
|
||||
bcrypt>=4.2.1
|
||||
email-validator>=2.2.0
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""پیکربندی تستها — دیتابیس 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)
|
||||
@@ -1,182 +0,0 @@
|
||||
"""تستهای 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
|
||||
@@ -1,57 +0,0 @@
|
||||
# لایه تولید — 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:
|
||||
@@ -1,27 +1,10 @@
|
||||
# مقادیر محرمانه از فایل .env ریشه خوانده میشوند — از .env.example کپی کنید:
|
||||
# cp .env.example .env
|
||||
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README).
|
||||
|
||||
services:
|
||||
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند) و /media (MinIO)
|
||||
traefik:
|
||||
image: traefik:v3
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-5173}:80"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: aramesh
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||
POSTGRES_PASSWORD: aramesh
|
||||
POSTGRES_DB: arameshplaza
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
@@ -31,132 +14,31 @@ services:
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
ports:
|
||||
- "9001:9001" # کنسول MinIO — در محیط واقعی با فایروال ببندید
|
||||
- "19000:9000" # دسترسی مستقیم/لوکال به MinIO (اختیاری — تصاویر از مسیر /media سرو میشوند)
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.media.rule=PathPrefix(`/media`)
|
||||
- traefik.http.routers.media.entrypoints=web
|
||||
- traefik.http.routers.media.priority=100
|
||||
- traefik.http.routers.media.middlewares=media-rewrite
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
||||
- traefik.http.services.media.loadbalancer.server.port=9000
|
||||
|
||||
# یکبار اجرا میشود: ساخت باکت، بازگذاشتن خواندن عمومی و پیشبارگذاری تصویرهای seed.
|
||||
# تصویرهای داخل seed-media/ (که با پیشوند /media در دیتابیس ثبتاند) روی یک حجم تازه
|
||||
# وارد باکت میشوند تا هر نسخه تازهکلون/بیلد، همان عکسها را داشته باشد؛
|
||||
# روی حجم موجود، mc cp فایلهای تکراری را رد میکند و دادهتازه را نمیخورد.
|
||||
minio-setup:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
MINIO_BUCKET: aramesh-media
|
||||
volumes:
|
||||
- ./seed-media:/seed-media:ro
|
||||
restart: "no"
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||
mc mb --ignore-existing local/$$MINIO_BUCKET &&
|
||||
mc anonymous set download local/$$MINIO_BUCKET &&
|
||||
echo 'minio-setup: bucket ready' &&
|
||||
cd /seed-media && mc mirror --overwrite . local/$$MINIO_BUCKET/ &&
|
||||
echo 'minio-setup: seed media mirrored'
|
||||
"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost:5173","http://127.0.0.1:5173"]}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD را در .env تنظیم کنید}
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-aramesh}
|
||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
S3_BUCKET: aramesh-media
|
||||
MEDIA_PUBLIC_BASE: /media
|
||||
# نشانی عمومی سایت — برای sitemap و آدرس مطلق OG (مثل https://aramesh.example)
|
||||
SITE_URL: ${SITE_URL:-}
|
||||
# بیلد فرانتاند برای تزریق متای سئو در صفحات /blog و /projects
|
||||
FRONTEND_DIST: /srv/frontend
|
||||
volumes:
|
||||
- ./frontend/dist:/srv/frontend:ro
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:aramesh@db:5432/arameshplaza
|
||||
SECRET_KEY: change-me-to-a-long-random-string
|
||||
CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]'
|
||||
ADMIN_USERNAME: admin
|
||||
ADMIN_PASSWORD: aramesh123
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.api.rule=PathPrefix(`/api`)
|
||||
- traefik.http.routers.api.entrypoints=web
|
||||
- traefik.http.routers.api.priority=100
|
||||
- traefik.http.services.api.loadbalancer.server.port=8000
|
||||
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
|
||||
- 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
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
# فقط سرو فایلهای استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
|
||||
frontend:
|
||||
build: ./frontend
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_API_URL: ""
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
- minio
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.web.rule=PathPrefix(`/`)
|
||||
- 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 تنظیم کنید}
|
||||
ports:
|
||||
- "5173:80"
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
minio_data:
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
:80 {
|
||||
root * /srv
|
||||
encode gzip
|
||||
|
||||
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمیگردند
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
|
||||
# کش بلندمدت برای فایلهای هشدار بیلد
|
||||
@assets path /assets/*
|
||||
header @assets Cache-Control "public, immutable, max-age=31536000"
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
# ---------- فرانتاند فقط سرو میکند ----------
|
||||
# بیلد روی هاست انجام میشود (npm install && npm run build) و خروجی dist/
|
||||
# داخل ایمیج کپی میشود — شبکه کانتینر بیلد برای npm قابلاعتماد نیست.
|
||||
# ---------- مرحله ساخت ----------
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
FROM caddy:2-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY dist /srv
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# آدرس API در زمان بیلد؛ پیشفرض خالی یعنی درخواستهای نسبی (/api) که nginx آنها را به بکاند پروکسی میکند
|
||||
ARG VITE_API_URL=""
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---------- مرحله سرو ----------
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -1,53 +1,8 @@
|
||||
# Magic Patterns - Vite Template
|
||||
|
||||
# Aramesh Construction Group — Frontend
|
||||
|
||||
A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
|
||||
|
||||
## Typography
|
||||
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
|
||||
|
||||
## CMS, SEO & Editor Features
|
||||
|
||||
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities:
|
||||
|
||||
* **Advanced SEO Management:** Dynamic `meta_description` fields (150-160 characters) and fully managed Image `Alt` texts (with auto-fallback to titles/filenames) ensure high search engine visibility.
|
||||
* **Tags & Categorization:** Integrated `react-select` for modern category selection and creatable dynamic tags.
|
||||
* **Article Metadata UI:** A beautifully designed metadata component displaying the author's avatar, name, and publication date right below the H1 title.
|
||||
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
|
||||
* **Custom Tiptap WYSIWYG Editor:**
|
||||
* Specific block-level toggling for Headings (H1: 18px, H2: 16px, H3: 14px) and Paragraphs (14px).
|
||||
* A custom feature to wrap English text blocks in LTR styling (`<span dir="ltr" class="english-text">` at 12px) for perfect bi-directional text alignment.
|
||||
* **Full Jalali (Shamsi) Calendar Integration:**
|
||||
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
|
||||
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
```
|
||||
|
||||
In development mode, Vite proxies `/api` and `/media` requests to Traefik (port 5173). Therefore, the Docker stack must be running.
|
||||
The backend API URL is read from the `VITE_API_URL` environment variable in `src/config.ts`; an empty default implies same-origin requests.
|
||||
|
||||
## Build & Production
|
||||
|
||||
The build process runs on the host machine. The Docker container only serves the generated static files in the `dist/` directory using Caddy:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
|
||||
```
|
||||
|
||||
> **Note:** The backend reads the `dist/` directory (mounted at `/srv/frontend` in Docker Compose) to inject SEO metadata into the `/blog` and `/projects` pages. After making any frontend changes, running `npm run build` and restarting the backend is sufficient.
|
||||
|
||||
## Key Modules & Architecture
|
||||
|
||||
* `src/api.tsx` — Handles site data loading and draft article merging for the admin view.
|
||||
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
|
||||
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
|
||||
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
|
||||
This code was generated by [Magic Patterns](https://magicpatterns.com) for this design: [Source Design](https://www.magicpatterns.com/c/ka4i8ikygtakmdobhws6nu)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Run `npm install`
|
||||
2. Run `npm run dev`
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>گروه ساختمانی آرامش</title>
|
||||
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژههای ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایهگذاری ساختوساز." />
|
||||
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"Organization","name":"گروه ساختمانی آرامش","inLanguage":"fa"}
|
||||
</script>
|
||||
<title>Aramesh Building Group Luxury Website</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# مسیریابی SPA — همه مسیرها به index.html برمیگردند
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# پروکسی درخواستهای API به سرویس بکاند
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# تصاویر: بدون کش طولانی — تا تغییرات سریع دیده شوند
|
||||
location ~* ^/images/ {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# کش استاتیک
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|ico|woff2?)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,11 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@tiptap/extension-image": "^3.31.3",
|
||||
"@tiptap/extension-link": "^3.31.3",
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
"slugify": "^1.6.9",
|
||||
"sweetalert2": "^11.26.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -931,31 +922,6 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -1461,465 +1427,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz",
|
||||
"integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz",
|
||||
"integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz",
|
||||
"integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz",
|
||||
"integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz",
|
||||
"integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz",
|
||||
"integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz",
|
||||
"integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz",
|
||||
"integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz",
|
||||
"integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz",
|
||||
"integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz",
|
||||
"integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz",
|
||||
"integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz",
|
||||
"integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz",
|
||||
"integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-image": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.31.3.tgz",
|
||||
"integrity": "sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz",
|
||||
"integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz",
|
||||
"integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz",
|
||||
"integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz",
|
||||
"integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz",
|
||||
"integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz",
|
||||
"integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz",
|
||||
"integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz",
|
||||
"integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
|
||||
"integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz",
|
||||
"integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz",
|
||||
"integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz",
|
||||
"integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.4.1",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-dropcursor": "^1.8.2",
|
||||
"prosemirror-gapcursor": "^1.4.1",
|
||||
"prosemirror-history": "^1.5.0",
|
||||
"prosemirror-inputrules": "^1.5.1",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.11",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.5",
|
||||
"prosemirror-transform": "^1.12.0",
|
||||
"prosemirror-view": "^1.42.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz",
|
||||
"integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-equals": "^5.3.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.31.3",
|
||||
"@tiptap/extension-floating-menu": "^3.31.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz",
|
||||
"integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-blockquote": "3.31.3",
|
||||
"@tiptap/extension-bold": "3.31.3",
|
||||
"@tiptap/extension-bullet-list": "3.31.3",
|
||||
"@tiptap/extension-code": "3.31.3",
|
||||
"@tiptap/extension-code-block": "3.31.3",
|
||||
"@tiptap/extension-document": "3.31.3",
|
||||
"@tiptap/extension-dropcursor": "3.31.3",
|
||||
"@tiptap/extension-gapcursor": "3.31.3",
|
||||
"@tiptap/extension-hard-break": "3.31.3",
|
||||
"@tiptap/extension-heading": "3.31.3",
|
||||
"@tiptap/extension-horizontal-rule": "3.31.3",
|
||||
"@tiptap/extension-italic": "3.31.3",
|
||||
"@tiptap/extension-link": "3.31.3",
|
||||
"@tiptap/extension-list": "3.31.3",
|
||||
"@tiptap/extension-list-item": "3.31.3",
|
||||
"@tiptap/extension-list-keymap": "3.31.3",
|
||||
"@tiptap/extension-ordered-list": "3.31.3",
|
||||
"@tiptap/extension-paragraph": "3.31.3",
|
||||
"@tiptap/extension-strike": "3.31.3",
|
||||
"@tiptap/extension-text": "3.31.3",
|
||||
"@tiptap/extension-underline": "3.31.3",
|
||||
"@tiptap/extensions": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/static-renderer": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/static-renderer/-/static-renderer-3.31.3.tgz",
|
||||
"integrity": "sha512-TzbN/aSTQBhGZL94aaEnfbwZw/gIiQd8/q3TucTYuzdbsrsZNfBMg7CCA9W9dOg4vlKxCTLRVas21ivsrUm7og==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1999,12 +1506,14 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -2015,20 +1524,12 @@
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
|
||||
@@ -2036,12 +1537,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
||||
@@ -2772,16 +2267,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.407",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
||||
@@ -3093,15 +2578,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz",
|
||||
"integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -3692,12 +3168,6 @@
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -3752,12 +3222,6 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -3885,6 +3349,7 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -3928,12 +3393,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -4262,156 +3721,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
|
||||
"integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
|
||||
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz",
|
||||
"integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.11",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz",
|
||||
"integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz",
|
||||
"integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.42.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz",
|
||||
"integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.8",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -4455,12 +3764,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-date-object": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmmirror.com/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
@@ -4474,36 +3777,12 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-element-popper": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/react-element-popper/-/react-element-popper-2.1.7.tgz",
|
||||
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-multi-date-picker": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
|
||||
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-date-object": "^2.1.8",
|
||||
"react-element-popper": "^2.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -4546,43 +3825,6 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-select": {
|
||||
"version": "5.10.2",
|
||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
|
||||
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.0",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.8.1",
|
||||
"@floating-ui/dom": "^1.0.1",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"prop-types": "^15.6.0",
|
||||
"react-transition-group": "^4.3.0",
|
||||
"use-isomorphic-layout-effect": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -4710,12 +3952,6 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -4795,15 +4031,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/slugify": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
|
||||
"integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
@@ -5166,29 +4393,6 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-isomorphic-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -5256,12 +4460,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -11,20 +11,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@tiptap/extension-image": "^3.31.3",
|
||||
"@tiptap/extension-link": "^3.31.3",
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
"slugify": "^1.6.9",
|
||||
"sweetalert2": "^11.26.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 583 KiB |
|
After Width: | Height: | Size: 481 KiB |
|
After Width: | Height: | Size: 505 KiB |
|
After Width: | Height: | Size: 500 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 228 KiB |
@@ -16,11 +16,6 @@ import { ArticleDetail } from './pages/ArticleDetail';
|
||||
import { FaqPage } from './pages/FaqPage';
|
||||
import { ContactPage } from './pages/ContactPage';
|
||||
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() {
|
||||
const { pathname } = useLocation();
|
||||
@@ -40,11 +35,8 @@ function AnimatedRoutes() {
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
||||
|
||||
|
||||
<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="/about" element={<About />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
@@ -62,38 +54,6 @@ 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() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
|
||||
@@ -117,7 +77,10 @@ function AppShell() {
|
||||
) : data ? (
|
||||
<BrowserRouter>
|
||||
<ScrollToTop />
|
||||
<AppChrome />
|
||||
<Header />
|
||||
<AnimatedRoutes />
|
||||
<Footer />
|
||||
<FloatingContact />
|
||||
</BrowserRouter>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -132,9 +95,7 @@ export function App() {
|
||||
|
||||
return (
|
||||
<SiteProvider>
|
||||
<AdminProvider>
|
||||
<AppShell />
|
||||
</AdminProvider>
|
||||
<AppShell />
|
||||
</SiteProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
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)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
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],
|
||||
)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
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 { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import { ARTICLE_CATEGORIES } from './entities'
|
||||
import type { Article, ArticleBodyDoc } from '../data/site'
|
||||
|
||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||||
|
||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته */
|
||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc }
|
||||
|
||||
/** دادههای قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
|
||||
if (typeof value === 'string') return value.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
return []
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
idle: 'ذخیره شد',
|
||||
saving: 'در حال ذخیره…',
|
||||
error: 'خطا در ذخیره',
|
||||
}
|
||||
|
||||
/**
|
||||
* صفحه تمامعرض نوشتن/ویرایش مقاله — محیط نوشتن متمرکز بدون چرخه سایت.
|
||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||
*/
|
||||
export function ArticleWritePage() {
|
||||
const { isAuthenticated, saveStatus, runSave } = 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().toISOString().split('T')[0],
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
tags: [],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
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,
|
||||
tags: normalizeTags(article.tags),
|
||||
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 }),
|
||||
})
|
||||
|
||||
const metaLength = (draft.meta_description ?? '').trim().length
|
||||
|
||||
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}
|
||||
alt={draft.image_alt ?? ''}
|
||||
altFallback={draft.title}
|
||||
onSave={async (url) => {
|
||||
queueSave({ image: url })
|
||||
return true
|
||||
}}
|
||||
onSaveAlt={async (alt) => {
|
||||
queueSave({ image_alt: alt })
|
||||
return true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* متن */}
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
doc={draft.body as unknown as Record<string, unknown>}
|
||||
onChange={(json) => {
|
||||
// محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن
|
||||
const textContent = JSON.stringify(json)
|
||||
const wordCount = textContent.split(/\s+/).length
|
||||
const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه'
|
||||
queueSave({ body: json, read: calculatedReadTime })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* توضیحات متای سئو */}
|
||||
<section className="mt-8 border border-ivory/15 bg-char p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-bold text-copper">توضیحات سئو (Meta Description)</h2>
|
||||
<span
|
||||
className={`text-[11px] font-light ${
|
||||
metaLength >= 150 && metaLength <= 160 ? 'text-emerald-400' : 'text-ivory/40'
|
||||
}`}
|
||||
>
|
||||
{metaLength.toLocaleString('fa-IR')} از ۱۶۰ نویسه
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-light text-ivory/50">
|
||||
این همان متنی است که در صفحه نتایج گوگل زیر لینک مقاله به کاربران نمایش داده میشود (۱۵۰ تا ۱۶۰ نویسه).
|
||||
</p>
|
||||
<textarea
|
||||
id="meta-description"
|
||||
rows={3}
|
||||
value={draft.meta_description ?? ''}
|
||||
onChange={(e) => queueSave({ meta_description: e.target.value })}
|
||||
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
|
||||
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* چکیده و متادیتا */}
|
||||
<SeoChecklist
|
||||
input={{
|
||||
title: draft.title,
|
||||
slug: draft.slug,
|
||||
excerpt: draft.meta_description || draft.excerpt,
|
||||
body: draft.body,
|
||||
keyphrase: draft.seo_keyphrase,
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
|
||||
<h2 className="text-sm font-bold text-copper">جزئیات انتشار، دستهبندی و هشتگها</h2>
|
||||
<div className="mt-5 grid gap-5">
|
||||
<div>
|
||||
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمیشود)</label>
|
||||
<input
|
||||
id="seo-keyphrase"
|
||||
type="text"
|
||||
value={draft.seo_keyphrase}
|
||||
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
{...textField('excerpt')}
|
||||
placeholder="خلاصه کوتاه مقاله برای کارتهای وبلاگ…"
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="article-author" className="text-xs font-light text-ivory/50">نویسنده</label>
|
||||
<input
|
||||
id="article-author"
|
||||
type="text"
|
||||
{...textField('author')}
|
||||
placeholder="نام و نام خانوادگی نویسنده"
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">دستهبندی مقاله (Category)</label>
|
||||
<div className="mt-2">
|
||||
<LuxeSelect
|
||||
value={draft.category}
|
||||
options={ARTICLE_CATEGORIES}
|
||||
onChange={(category) => queueSave({ category })}
|
||||
isClearable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">هشتگها / برچسبها</label>
|
||||
<div className="mt-2">
|
||||
<LuxeTagsSelect
|
||||
value={normalizeTags(draft.tags)}
|
||||
onChange={(tags) => queueSave({ tags })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="article-date" className="text-xs font-light text-ivory/50">تاریخ انتشار (شمسی)</label>
|
||||
<div className="mt-2">
|
||||
<JalaliDatePicker
|
||||
id="article-date"
|
||||
value={draft.date ?? ''}
|
||||
onChange={(isoDate) => queueSave({ date: isoDate })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
{...textField('read')}
|
||||
className="mt-2 w-full cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-sm font-light text-ivory/50"
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
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)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
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 { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
|
||||
type EntityDrawerProps = {
|
||||
config: EntityConfig
|
||||
/** رکورد یگانه یا آیتم مجموعه — باید id داشته باشد */
|
||||
item: Record<string, unknown> & { id: number }
|
||||
onClose: () => void
|
||||
/** فقط برای مجموعهها: حذف آیتم */
|
||||
deletable?: boolean
|
||||
}
|
||||
|
||||
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
|
||||
const save = useFieldSaver()
|
||||
const { runSave } = 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) => {
|
||||
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
||||
const altKey =
|
||||
field.type === 'image'
|
||||
? field.name === 'src' && 'alt' in item
|
||||
? 'alt'
|
||||
: `${field.name}_alt`
|
||||
: ''
|
||||
return (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
altValue={altKey ? String(item[altKey] ?? '') : undefined}
|
||||
altFallback={String(item[config.titleKey] ?? '')}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
? (fn) => {
|
||||
flushRef.current = fn
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deletable && (
|
||||
<footer className="border-t border-ivory/10 px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-2 border border-red-400/40 px-4 py-2 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
حذف این رکورد
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</motion.aside>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
altValue,
|
||||
altFallback,
|
||||
onSave,
|
||||
onSaveAlt,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
/** مقدار Alt تصویر — فقط برای فیلدهای image */
|
||||
altValue?: string
|
||||
altFallback?: string
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
onSaveAlt?: (alt: string) => 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 ?? '')}
|
||||
alt={altValue}
|
||||
altFallback={altFallback}
|
||||
onSave={(url) => onSave(url)}
|
||||
onSaveAlt={onSaveAlt}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
<LuxeSelect
|
||||
value={String(value ?? '')}
|
||||
options={field.options ?? []}
|
||||
onChange={(next) => {
|
||||
setText(next)
|
||||
void onSave(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'date') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
<JalaliDatePicker
|
||||
value={String(value ?? '')}
|
||||
onChange={(isoDate) => {
|
||||
setText(isoDate)
|
||||
void onSave(isoDate)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'wysiwyg') {
|
||||
return <WysiwygField field={field} value={value} onSave={onSave} registerFlush={registerFlush} />
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
return field.ui === 'tags' ? (
|
||||
<TagsField field={field} value={value} onSave={onSave} />
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/** فیلد هشتگها — react-select قابل ساخت (Creatable) با ذخیره فوری */
|
||||
function TagsField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
}) {
|
||||
const [tags, setTags] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||
|
||||
const update = (next: string[]) => {
|
||||
setTags(next)
|
||||
void onSave(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
<LuxeTagsSelect value={tags} onChange={update} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
||||
function ListField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// لایه تماس ادمین — همه درخواستها با توکن 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 }),
|
||||
}),
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import type { SiteData } from '../api'
|
||||
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
|
||||
|
||||
export type FieldConfig = {
|
||||
name: string
|
||||
label: string
|
||||
type: FieldType
|
||||
hint?: string
|
||||
/** فقط برای type === 'select' — گزینههای ثابت react-select */
|
||||
options?: string[]
|
||||
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
||||
ui?: 'lines' | 'tags'
|
||||
}
|
||||
|
||||
export type EntityConfig = {
|
||||
resource: string
|
||||
dataKey: string
|
||||
title: string
|
||||
kind: 'collection' | 'singleton'
|
||||
titleKey: string
|
||||
fields: FieldConfig[]
|
||||
defaults: Record<string, unknown>
|
||||
}
|
||||
|
||||
const F = (
|
||||
name: string,
|
||||
label: string,
|
||||
type: FieldType = 'text',
|
||||
hint?: string,
|
||||
extra?: Partial<FieldConfig>,
|
||||
): FieldConfig => ({
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
hint,
|
||||
...extra,
|
||||
})
|
||||
|
||||
/** دستهبندیهای استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده میشوند */
|
||||
export const ARTICLE_CATEGORIES = [
|
||||
'معماری و ساخت',
|
||||
'سرمایهگذاری ملکی',
|
||||
'طراحی داخلی',
|
||||
'اخبار و اطلاعیهها',
|
||||
'یادداشت مدیریت',
|
||||
]
|
||||
|
||||
export const ENTITIES: Record<string, EntityConfig> = {
|
||||
services: {
|
||||
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape')],
|
||||
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', 'چکیده (روی کارت مقاله)'),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود'),
|
||||
F('image', 'تصویر شاخص', 'image'),
|
||||
F('category', 'دستهبندی (Category)', 'select', undefined, { options: ARTICLE_CATEGORIES }),
|
||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags' }),
|
||||
F('author', 'نویسنده'),
|
||||
F('date', 'تاریخ انتشار (شمسی)', 'date'),
|
||||
F('read', 'زمان مطالعه (دقیقه)'),
|
||||
F('slug', 'نشانی (slug)'),
|
||||
],
|
||||
defaults: {
|
||||
title: 'مقاله جدید',
|
||||
slug: `article-${Date.now()}`,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
tags: ['مدیریت پروژه', 'ساختوساز'],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
seo_keyphrase: '',
|
||||
body: { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||
},
|
||||
},
|
||||
testimonials: {
|
||||
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام'), F('role', 'سمت'), F('quote', 'متن نظر', 'textarea')],
|
||||
defaults: { name: 'نظر جدید', role: '', quote: '' },
|
||||
},
|
||||
faqs: {
|
||||
resource: 'faqs', dataKey: 'faqs', title: 'سوالات متداول', kind: 'collection',
|
||||
titleKey: 'question',
|
||||
fields: [F('question', 'سوال'), F('answer', 'پاسخ', 'textarea')],
|
||||
defaults: { question: 'سوال جدید', answer: '' },
|
||||
},
|
||||
team: {
|
||||
resource: 'team', dataKey: 'team', title: 'تیم', kind: 'collection',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام'), F('role', 'سمت'), F('bio', 'معرفی', 'textarea')],
|
||||
defaults: { name: 'عضو جدید', role: '', bio: '' },
|
||||
},
|
||||
timeline: {
|
||||
resource: 'timeline', dataKey: 'timeline', title: 'تایملاین', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image')],
|
||||
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
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Article, Project, Service } from './data/site'
|
||||
import { API_URL } from './config'
|
||||
import { getToken } from './admin/adminClient'
|
||||
|
||||
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
||||
export type BrandInfo = {
|
||||
@@ -33,109 +32,8 @@ export type BeforeAfterInfo = {
|
||||
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 {
|
||||
brand: BrandInfo
|
||||
nav: NavItem[]
|
||||
hero: HeroContent
|
||||
story: StoryContent
|
||||
section_headings: SectionHeadingContent[]
|
||||
page_heroes: PageHeroContent[]
|
||||
services: Service[]
|
||||
projects: Project[]
|
||||
articles: Article[]
|
||||
@@ -146,31 +44,15 @@ export interface SiteData {
|
||||
stats: StatItem[]
|
||||
why_us: WhyUsItem[]
|
||||
before_after: BeforeAfterInfo
|
||||
investment_models: InvestmentModelItem[]
|
||||
process_steps: ProcessStepItem[]
|
||||
offices: OfficeItem[]
|
||||
gallery: GalleryImageItem[]
|
||||
status_labels: StatusLabelItem[]
|
||||
contact_section: ContactSectionContent
|
||||
newsletter: NewsletterContent
|
||||
footer: FooterContent
|
||||
seo_meta: { id: number; page_key: string; title: string; description: string }[]
|
||||
}
|
||||
|
||||
interface SiteContextValue {
|
||||
data: SiteData | null
|
||||
error: boolean
|
||||
reload: () => void
|
||||
/** بهروزرسانی خوشبینانه درخت داده — پس از موفقیت PATCH استفاده میشود */
|
||||
updateSite: (mutate: (data: SiteData) => void) => void
|
||||
}
|
||||
|
||||
const SiteContext = createContext<SiteContextValue>({
|
||||
data: null,
|
||||
error: false,
|
||||
reload: () => {},
|
||||
updateSite: () => {},
|
||||
})
|
||||
const SiteContext = createContext<SiteContextValue>({ data: null, error: false, reload: () => {} })
|
||||
|
||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const [data, setData] = useState<SiteData | null>(null)
|
||||
@@ -184,27 +66,9 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<SiteData>
|
||||
})
|
||||
.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 {
|
||||
/* بدون پیشنویسها ادامه بده */
|
||||
}
|
||||
}
|
||||
.then((json) => {
|
||||
if (!cancelled) {
|
||||
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
||||
if (json.brand && json.before_after) setData(json)
|
||||
else setError(true)
|
||||
}
|
||||
})
|
||||
@@ -224,20 +88,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
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>
|
||||
)
|
||||
return <SiteContext.Provider value={{ data, error, reload }}>{children}</SiteContext.Provider>
|
||||
}
|
||||
|
||||
export function useSiteData(): SiteContextValue {
|
||||
@@ -252,34 +103,3 @@ export function useSite(): SiteData {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,49 +2,44 @@ import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { formatJalali } from '../utils/date'
|
||||
|
||||
export function ArticleCard({ article }: { article: Article }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/blog/${article.slug}`}
|
||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
className="group flex h-full flex-col border border-ivory/10 bg-char transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`خواندن مقاله ${article.title}`}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="overflow-hidden">
|
||||
<img
|
||||
src={article.image}
|
||||
alt={`تصویر مقاله ${article.title}`}
|
||||
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
className="h-52 w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105 transform-gpu"
|
||||
/>
|
||||
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||
<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 className="flex flex-1 flex-col p-7">
|
||||
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
<p className="text-xs font-light text-copper">{article.category}</p>
|
||||
|
||||
<h3 className="mt-4 text-lg font-bold leading-9 text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
|
||||
<p className="mt-4 mb-8 text-sm font-light leading-[2.2rem] text-ivory/55">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-3">
|
||||
<span>{formatJalali(article.date)}</span>
|
||||
<div className="mt-auto flex items-center justify-between border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-4">
|
||||
<span>{article.date}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
{article.read}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
|
||||
<span className="flex items-center gap-2 text-copper">
|
||||
ادامه مطلب
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 ease-out group-hover:-translate-x-1.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { CalendarIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
|
||||
|
||||
type ArticleMetaProps = {
|
||||
article: Article
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* سربرگ نویسنده/تاریخ مقاله — زیر تیتر اصلی نمایش داده میشود:
|
||||
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
|
||||
*/
|
||||
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
|
||||
const rawDate = (article.date ?? '').trim()
|
||||
const author = (article.author ?? '').trim() || 'گروه ساختمانی آرامش'
|
||||
const initial = author.charAt(0)
|
||||
|
||||
// تاریخ ذخیرهشده (میلادی) به شمسی تبدیل میشود؛ در نبود تاریخ، امروز با ساعت نمایش داده میشود
|
||||
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
|
||||
const dateLabel = formatJalali(rawDate, todayLabel)
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
|
||||
{/* آواتار و نام نویسنده */}
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-lg font-bold text-copper shadow-[0_0_20px_rgba(213,135,76,0.25),inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
{initial}
|
||||
</span>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-bold text-ivory">{author}</span>
|
||||
<span className="text-[11px] font-light text-ivory/45">نویسنده</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="hidden h-9 w-px bg-ivory/10 sm:block" aria-hidden="true" />
|
||||
|
||||
{/* تاریخ جلالی */}
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
<CalendarIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||
{dateLabel}
|
||||
</span>
|
||||
|
||||
{/* زمان مطالعه */}
|
||||
{article.read && (
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||
{article.read}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,33 +9,26 @@ type BaseProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// استایلهای فوقلوکس شیشهای (Glassmorphic) با رنگهای اختصاصی پروژه
|
||||
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
|
||||
const STYLES: Record<Variant, { base: string; fill: string; text: string }> = {
|
||||
copper: {
|
||||
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
|
||||
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
|
||||
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
|
||||
text: 'group-hover:text-char font-semibold',
|
||||
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
|
||||
base: 'bg-copper text-char',
|
||||
fill: 'bg-char',
|
||||
text: 'group-hover:text-ivory',
|
||||
},
|
||||
outline: {
|
||||
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبهها
|
||||
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
|
||||
fill: 'bg-white/10',
|
||||
text: 'group-hover:text-copper',
|
||||
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
|
||||
base: 'border border-ivory/30 text-ivory',
|
||||
fill: 'bg-copper',
|
||||
text: 'group-hover:text-char',
|
||||
},
|
||||
dark: {
|
||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||
base: 'bg-char text-ivory',
|
||||
fill: 'bg-copper',
|
||||
text: 'group-hover:text-char',
|
||||
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
|
||||
},
|
||||
'dark-ivory': {
|
||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||
base: 'bg-char text-ivory',
|
||||
fill: 'bg-ivory',
|
||||
text: 'group-hover:text-char',
|
||||
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,18 +36,12 @@ function Inner({ children, variant = 'copper' }: { children: React.ReactNode; va
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<>
|
||||
{/* خط انعکاس نوری شیشه در بالای دکمه */}
|
||||
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
|
||||
|
||||
{/* انیمیشن پر شدن موجی و نرم */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
|
||||
className={`absolute inset-0 translate-y-full transition-transform duration-[600ms] ease-lux group-hover:translate-y-0 ${style.fill}`}
|
||||
/>
|
||||
|
||||
{/* متن و آیکون دکمه */}
|
||||
<span
|
||||
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
|
||||
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-[600ms] ease-lux ${style.text}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
@@ -62,9 +49,8 @@ function Inner({ children, variant = 'copper' }: { children: React.ReactNode; va
|
||||
)
|
||||
}
|
||||
|
||||
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشهای
|
||||
const SHELL =
|
||||
'group relative inline-flex items-center justify-center overflow-hidden 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'
|
||||
'group relative inline-flex items-center justify-center overflow-hidden px-8 py-4 text-sm font-bold focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink'
|
||||
|
||||
export function ButtonLink({
|
||||
to,
|
||||
@@ -72,9 +58,8 @@ export function ButtonLink({
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
}: BaseProps & { to: string }) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
|
||||
<Link to={to} className={`${SHELL} ${STYLES[variant].base} ${className}`}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</Link>
|
||||
)
|
||||
@@ -87,9 +72,8 @@ export function ButtonAnchor({
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<a href={href} className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</a>
|
||||
)
|
||||
@@ -101,9 +85,8 @@ export function Button({
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<button className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
import React, { useState } from 'react'
|
||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
|
||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from './Reveal'
|
||||
import { Button } from './Button'
|
||||
import { useSite } from '../api'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { API_URL } from '../config'
|
||||
|
||||
export function ContactSection() {
|
||||
const { brand, contact_section: content } = useSite()
|
||||
const save = useFieldSaver()
|
||||
const { brand } = useSite()
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||
const [sent, setSent] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
const saveContent = (field: string, value: string) =>
|
||||
save('contact-section', content.id, { [field]: value }, (d) => {
|
||||
;(d.contact_section as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const saveBrand = (field: string, value: string) =>
|
||||
save('brand', brand.id, { [field]: value }, (d) => {
|
||||
;(d.brand as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const showErrorAlert = (title: string, text: string) => {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
@@ -37,11 +23,11 @@ export function ContactSection() {
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
popup: 'font-pinar border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl bg-char/95',
|
||||
container: 'backdrop-blur-[8px] bg-ink/60',
|
||||
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
|
||||
title: 'font-pinar font-bold text-lg text-ivory pt-2',
|
||||
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
|
||||
confirmButton: 'mt-6 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)]',
|
||||
confirmButton: 'mt-6 bg-copper text-char font-bold px-10 py-3 transition-transform duration-300 ease-lux hover:scale-105',
|
||||
},
|
||||
showClass: {
|
||||
popup: 'swal2-show',
|
||||
@@ -50,7 +36,7 @@ export function ContactSection() {
|
||||
hideClass: {
|
||||
popup: 'swal2-hide',
|
||||
backdrop: 'swal2-backdrop-hide',
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,73 +90,53 @@ export function ContactSection() {
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
|
||||
|
||||
<div className="relative mx-auto max-w-site px-6 lg:px-10">
|
||||
<div className="mx-auto grid max-w-6xl items-stretch gap-8 lg:grid-cols-12">
|
||||
|
||||
{/* باکس اطلاعات تماس */}
|
||||
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
|
||||
<div className="mx-auto grid max-w-6xl items-stretch gap-6 lg:grid-cols-12">
|
||||
|
||||
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[8px] md:p-10 lg:col-span-5">
|
||||
<Reveal>
|
||||
<p className="text-sm font-light tracking-wide text-copper">
|
||||
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
|
||||
</p>
|
||||
<h2 id="contact-title" className="mt-4 text-section font-bold text-ivory">
|
||||
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
|
||||
<p className="text-sm font-light text-copper">تماس با ما</p>
|
||||
<h2 id="contact-title" className="mt-5 text-section font-bold text-ivory">
|
||||
پروژهتان را با ما در میان بگذارید
|
||||
</h2>
|
||||
<p className="mt-5 text-base font-light leading-9 text-ivory/75">
|
||||
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
|
||||
<p className="mt-6 text-base font-light leading-9 text-ivory/75">
|
||||
شمارهتان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس میگیرد.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<ul className="mt-10 space-y-6">
|
||||
<li className="flex items-start gap-4">
|
||||
<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>
|
||||
<MapPinIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ivory">
|
||||
<InlineEdit value={content.address_label} onSave={(v) => saveContent('address_label', v)} />
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">
|
||||
<InlineEdit value={brand.address} onSave={(v) => saveBrand('address', v)} />
|
||||
</p>
|
||||
<p className="text-sm font-bold text-ivory">دفتر مرکزی</p>
|
||||
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">{brand.address}</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-4">
|
||||
<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>
|
||||
<PhoneIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ivory">
|
||||
<InlineEdit value={content.phone_label} onSave={(v) => saveContent('phone_label', v)} />
|
||||
</p>
|
||||
<p className="text-sm font-bold text-ivory">تلفن</p>
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
dir="ltr"
|
||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
|
||||
{brand.phone}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-4">
|
||||
<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>
|
||||
<MailIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ivory">
|
||||
<InlineEdit value={content.email_label} onSave={(v) => saveContent('email_label', v)} />
|
||||
</p>
|
||||
<p className="text-sm font-bold text-ivory">ایمیل</p>
|
||||
<a
|
||||
href={`mailto:${brand.email}`}
|
||||
dir="ltr"
|
||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
<InlineEdit value={brand.email} onSave={(v) => saveBrand('email', v)} />
|
||||
{brand.email}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
@@ -178,19 +144,13 @@ export function ContactSection() {
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{/* باکس فرم ارسال درخواست */}
|
||||
<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" />
|
||||
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[9px] md:p-10 lg:col-span-7">
|
||||
{sent ? (
|
||||
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-copper shadow-[0_0_30px_rgba(197,143,92,0.4)]">
|
||||
<CheckCircle2Icon className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-bold text-ivory">
|
||||
<InlineEdit value={content.success_title} onSave={(v) => saveContent('success_title', v)} />
|
||||
</h3>
|
||||
<CheckCircle2Icon className="h-12 w-12 text-copper" />
|
||||
<h3 className="mt-6 text-xl font-bold text-ivory">درخواست شما ثبت شد</h3>
|
||||
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
|
||||
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
||||
از اعتماد شما سپاسگزاریم. کارشناسان ما بهزودی با شما تماس میگیرند.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -201,28 +161,28 @@ export function ContactSection() {
|
||||
setForm({ name: '', phone: '', note: '' })
|
||||
}}
|
||||
>
|
||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||
ثبت درخواست جدید
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
|
||||
<label htmlFor="contact-name" className="block text-sm font-bold text-ivory/90">
|
||||
نام
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={content.name_placeholder}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="contact-phone" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.phone_field_label} onSave={(v) => saveContent('phone_field_label', v)} /> <span className="text-copper">*</span>
|
||||
<label htmlFor="contact-phone" className="block text-sm font-bold text-ivory/90">
|
||||
شماره تلفن <span className="text-copper">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="contact-phone"
|
||||
@@ -231,40 +191,28 @@ export function ContactSection() {
|
||||
required
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder={content.phone_placeholder}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-right text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
placeholder="09xx xxx xxxx"
|
||||
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-right text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{content.note_optional_label}</span>
|
||||
<label htmlFor="contact-note" className="block text-sm font-bold text-ivory/90">
|
||||
توضیحات <span className="font-light text-ivory/50">(اختیاری)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-note"
|
||||
rows={5}
|
||||
rows={6}
|
||||
value={form.note}
|
||||
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||
placeholder={content.note_placeholder}
|
||||
className="mt-3 w-full resize-none rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light leading-8 text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-start">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="copper"
|
||||
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? (
|
||||
content.sending_label
|
||||
) : (
|
||||
<>
|
||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit" variant="copper" className="w-full md:w-auto md:px-12" disabled={sending}>
|
||||
{sending ? 'در حال ارسال…' : 'ارسال درخواست مشاوره'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,21 +4,14 @@ import { useSite } from '../api';
|
||||
|
||||
export function FloatingContact() {
|
||||
const { brand } = useSite();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
aria-label="تماس تلفنی با گروه ساختمانی آرامش"
|
||||
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
|
||||
>
|
||||
{/* پالس هاله نوری در پسزمینه مانند رادار */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-full border border-copper/60 animate-ping opacity-30"
|
||||
/>
|
||||
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-copper text-char shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
|
||||
|
||||
<span className="absolute inset-0 rounded-full border border-copper/50 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
|
||||
<PhoneIcon className="h-5 w-5" />
|
||||
</a>);
|
||||
|
||||
{/* آیکون تلفن با انیمیشن چرخش ظریف در زمان هاور */}
|
||||
<PhoneIcon className="relative z-10 h-6 w-6 transition-transform duration-300 ease-lux group-hover:rotate-12" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { NAV } from '../data/site';
|
||||
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() {
|
||||
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' });
|
||||
};
|
||||
|
||||
const { brand, services } = useSite();
|
||||
return (
|
||||
<footer className="relative border-t border-white/10 bg-char/95 backdrop-blur-2xl">
|
||||
<footer className="border-t border-ivory/10 bg-char">
|
||||
<div className="mx-auto max-w-site px-6 py-16 lg:px-10">
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
<div className="lg:col-span-4">
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
</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)} />
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/55">
|
||||
گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمانهایی میسازد که قرار است چند نسل در آنها زندگی کنند؛ معماری آرام،
|
||||
اجرای دقیق و سرمایهگذاری شفاف.
|
||||
</p>
|
||||
{/* آیکونهای شبکههای اجتماعی: گویهای شیشهای شناور */}
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
{footer.socials.map(({ label, href, icon }) => {
|
||||
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
||||
return (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
aria-label={label}
|
||||
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
|
||||
>
|
||||
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<div className="mt-6 flex gap-3">
|
||||
{[
|
||||
{ Icon: InstagramIcon, label: 'اینستاگرام' },
|
||||
{ Icon: LinkedinIcon, label: 'لینکدین' },
|
||||
{ Icon: SendIcon, label: 'تلگرام' }].
|
||||
map(({ Icon, label }) =>
|
||||
<a
|
||||
key={label}
|
||||
href="#"
|
||||
aria-label={label}
|
||||
className="flex h-10 w-10 items-center justify-center border border-ivory/15 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
||||
|
||||
<Icon className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
|
||||
</h3>
|
||||
<h3 className="text-sm font-bold text-copper">صفحات</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<Link
|
||||
to={item.href}
|
||||
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
{NAV.map((item) =>
|
||||
<li key={item.to}>
|
||||
<Link
|
||||
to={item.to}
|
||||
className="text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper">
|
||||
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="mt-4">
|
||||
<AddItemButton resource="nav" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
|
||||
</h3>
|
||||
<h3 className="text-sm font-bold text-copper">خدمات</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{services.map((service) => (
|
||||
<li key={service.id} className="text-sm font-light text-ivory/60">
|
||||
{services.map((service) =>
|
||||
<li key={service.title} className="text-sm font-light text-ivory/60">
|
||||
{service.title}
|
||||
</li>
|
||||
))}
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
|
||||
</h3>
|
||||
<h3 className="text-sm font-bold text-copper">ارتباط با ما</h3>
|
||||
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
|
||||
<li className="flex items-start gap-3">
|
||||
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
||||
@@ -125,28 +84,11 @@ export function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
|
||||
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||
<p>
|
||||
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>بازگشت به بالا</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 className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-ivory/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||
<p>© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.</p>
|
||||
<p>طراحی و ساخت با دقتِ معماری</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
</footer>);
|
||||
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
||||
import { NAV } from "../data/site";
|
||||
import { useSite } from "../api";
|
||||
import { useFieldSaver } from "../admin/AdminContext";
|
||||
import { InlineEdit } from "./admin/InlineEdit";
|
||||
import { EditableItem, SingletonPill } from "../admin/Editable";
|
||||
import { use } from "framer-motion/m";
|
||||
|
||||
export function Header() {
|
||||
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 { brand } = useSite();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { pathname } = useLocation();
|
||||
@@ -31,82 +23,75 @@ export function Header() {
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed inset-x-0 top-0 z-50 transition-all duration-300 ease-lux ${
|
||||
className={`fixed inset-x-0 top-0 z-50 transition-[background-color,border-color,backdrop-filter] duration-300 ease-lux ${
|
||||
scrolled
|
||||
? "border-b border-white/10 bg-ink/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.5)]"
|
||||
? "border-b border-ivory/10 bg-ink/90 backdrop-blur-md"
|
||||
: "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="relative flex items-center">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-3"
|
||||
aria-label={brand.name}
|
||||
>
|
||||
<img
|
||||
src={brand.logo}
|
||||
alt={brand.name}
|
||||
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
|
||||
scrolled ? "h-14" : "h-20"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-3"
|
||||
aria-label={brand.name}
|
||||
>
|
||||
<img
|
||||
src={brand.logo}
|
||||
alt={brand.name}
|
||||
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
|
||||
scrolled ? "h-14" : "h-20"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
|
||||
<ul className="flex items-center gap-7">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
|
||||
isActive ? "text-copper font-normal" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 left-0 right-0 h-0.5 w-full origin-center rounded-full bg-copper transition-transform duration-300 ease-lux ${
|
||||
isActive
|
||||
? "scale-x-100 shadow-[0_0_8px_rgba(197,143,92,0.8)]"
|
||||
: "scale-x-0 group-hover:scale-x-100"
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
</EditableItem>
|
||||
{NAV.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{item.label}
|
||||
<span
|
||||
className={`absolute -bottom-0.5 left-0 right-0 h-px w-full origin-center bg-copper transition-transform duration-300 ease-lux ${
|
||||
isActive
|
||||
? "scale-x-100"
|
||||
: "scale-x-0 group-hover:scale-x-100"
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* دکمه تماس کپسولی شیشهای لوکس */}
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-copper/20 text-copper transition-all duration-300 group-hover:bg-copper group-hover:text-char">
|
||||
<PhoneIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-lux group-hover:translate-y-0"
|
||||
/>
|
||||
<span className="relative z-10 flex items-center gap-2 transition-colors duration-300 ease-lux group-hover:text-char">
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
<span dir="ltr">{brand.phone}</span>
|
||||
</span>
|
||||
<span dir="ltr" className="tracking-wide">{brand.phone}</span>
|
||||
</a>
|
||||
|
||||
{/* دکمه منوی موبایل دایرهای شیشهای */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/5 text-ivory backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 xl:hidden"
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper xl:hidden"
|
||||
aria-label={open ? "بستن منو" : "باز کردن منو"}
|
||||
aria-expanded={open}
|
||||
>
|
||||
@@ -122,31 +107,26 @@ export function Header() {
|
||||
{open && (
|
||||
<nav
|
||||
aria-label="ناوبری موبایل"
|
||||
className="border-t border-white/10 bg-ink/95 backdrop-blur-2xl px-6 py-4 xl:hidden shadow-2xl"
|
||||
className="border-t border-ivory/10 bg-ink/98 px-6 py-4 xl:hidden"
|
||||
>
|
||||
<ul className="grid gap-1">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
</NavLink>
|
||||
</li>
|
||||
</EditableItem>
|
||||
{NAV.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,88 +2,52 @@ import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeftIcon } from 'lucide-react'
|
||||
import { usePageHero } from '../api'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { ImageField } from './admin/ImageField'
|
||||
|
||||
type PageHeroProps = {
|
||||
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایشپذیر میشوند */
|
||||
pageKey: string
|
||||
title: string
|
||||
lead: string
|
||||
image: string
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
export function PageHero({ title, lead, image }: PageHeroProps) {
|
||||
return (
|
||||
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
|
||||
{hero.image && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />}
|
||||
|
||||
<img src={image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
||||
|
||||
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
|
||||
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<span className="text-copper">{hero.title}</span>
|
||||
<span className="text-copper">{title}</span>
|
||||
</nav>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-5 max-w-[700px]"
|
||||
className="mt-5 max-w-[700px] text-hero font-bold text-ivory"
|
||||
>
|
||||
<InlineEdit
|
||||
as="span"
|
||||
value={hero.title}
|
||||
onSave={(v) => saveField('title', v)}
|
||||
className="block text-hero font-bold text-ivory"
|
||||
/>
|
||||
{title}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-5 max-w-2xl"
|
||||
className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/65"
|
||||
>
|
||||
<InlineEdit
|
||||
as="span"
|
||||
multiline
|
||||
value={hero.lead}
|
||||
onSave={(v) => saveField('lead', v)}
|
||||
className="block text-base font-light leading-9 text-ivory/65"
|
||||
/>
|
||||
{lead}
|
||||
</motion.p>
|
||||
<div className="mt-8 h-px w-28 bg-copper" />
|
||||
|
||||
{/* ویرایش تصویر پسزمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
|
||||
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
|
||||
<SingletonPill
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی این صفحه"
|
||||
select={(d) => d.seo_meta.find((s) => s.page_key === pageKey)}
|
||||
// اگر ردیف سئوی این صفحه هنوز ساخته نشده باشد، همینجا ساخته میشود
|
||||
createDefaults={() => ({ page_key: pageKey, title: '', description: '' })}
|
||||
/>
|
||||
<ImageField label="تصویر پسزمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useSiteData } from '../api'
|
||||
|
||||
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری دادهها از دیتابیس استفاده میشود
|
||||
const FALLBACK_LOGO = '/images/logo.png'
|
||||
const FALLBACK_NAME = 'گروه ساختمانی آرامش'
|
||||
import { BRAND } from '../data/site'
|
||||
|
||||
export function Preloader() {
|
||||
const { data } = useSiteData()
|
||||
const [done, setDone] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const logo = data?.brand.logo || FALLBACK_LOGO
|
||||
const name = data?.brand.name || FALLBACK_NAME
|
||||
|
||||
useEffect(() => {
|
||||
const start = Date.now()
|
||||
const total = 1800
|
||||
@@ -47,13 +39,13 @@ export function Preloader() {
|
||||
>
|
||||
<div className="relative h-36 w-36">
|
||||
<img
|
||||
src={logo}
|
||||
src={BRAND.logo}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-contain opacity-[0.12]"
|
||||
/>
|
||||
<div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(${100 - progress}% 0 0 0)` }}>
|
||||
<img src={logo} alt="" aria-hidden="true" className="h-full w-full object-contain" />
|
||||
<img src={BRAND.logo} alt="" aria-hidden="true" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-x-0 h-px bg-copper"
|
||||
@@ -72,10 +64,10 @@ export function Preloader() {
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mt-4 text-sm font-light tracking-widest text-copper font-pinar"
|
||||
>
|
||||
{name}
|
||||
{BRAND.name}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,48 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { useStatusLabel } from '../api'
|
||||
|
||||
export function ProjectCard({ project }: any) {
|
||||
const isOngoing = project.status === 'progress'
|
||||
const statusText = useStatusLabel(project.status)
|
||||
|
||||
|
||||
const statusText = isOngoing ? 'در حال انجام' : 'انجام شده'
|
||||
|
||||
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
||||
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
||||
|
||||
return (
|
||||
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
||||
<article className="group flex h-full flex-col overflow-hidden border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
||||
<Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
|
||||
|
||||
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشهای */}
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden">
|
||||
<img
|
||||
src={project.image}
|
||||
alt={project.title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
|
||||
{/* نشانگر وضعیت کپسولی شیشهای */}
|
||||
<div className={`absolute right-4 top-4 flex items-center gap-2 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)]' : ''}`} />
|
||||
<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}`} />
|
||||
{statusText}
|
||||
</div>
|
||||
|
||||
{/* گوی دکمه شیشهای شناور برای ورود به پروژه */}
|
||||
<div className="absolute bottom-4 left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
|
||||
<ArrowLeftIcon className="h-5 w-5 transition-transform duration-300 group-hover:-translate-x-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
<h3 className="font-pinar text-xl font-bold text-ivory">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||
<p className="mt-7 text-sm font-light text-ivory/60">
|
||||
{project.location}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="my-6 h-px w-full bg-white/10" />
|
||||
<div className="my-7 h-px w-full bg-ivory/10" />
|
||||
|
||||
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
|
||||
<span>سال: {project.year}</span>
|
||||
<span>{project.area} متر مربع</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
|
||||
@@ -1,58 +1,29 @@
|
||||
import React from 'react'
|
||||
import { Reveal } from './Reveal'
|
||||
import { useSectionHeading } from '../api'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
|
||||
type SectionHeadingProps = {
|
||||
/** در صورت ارسال، تیتر و توضیح از دیتابیس خوانده و بهصورت درجا ویرایشپذیر میشوند */
|
||||
headingKey?: string
|
||||
title?: string
|
||||
title: string
|
||||
lead?: string
|
||||
align?: 'right' | 'center'
|
||||
tone?: 'copper' | 'ivory'
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
headingKey,
|
||||
title,
|
||||
lead,
|
||||
align = 'right',
|
||||
tone = 'ivory',
|
||||
}: SectionHeadingProps) {
|
||||
const heading = useSectionHeading(headingKey ?? '')
|
||||
const save = useFieldSaver()
|
||||
const isDb = !!headingKey
|
||||
const currentTitle = isDb ? heading.title : (title ?? '')
|
||||
const currentLead = isDb ? heading.lead : (lead ?? '')
|
||||
const isCenter = align === 'center'
|
||||
|
||||
const saveField = (field: 'title' | 'lead', value: string) =>
|
||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) (h as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
|
||||
<InlineEdit
|
||||
as="h2"
|
||||
value={currentTitle}
|
||||
onSave={(v) => saveField('title', v)}
|
||||
className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}
|
||||
/>
|
||||
|
||||
{currentLead && (
|
||||
<InlineEdit
|
||||
as="p"
|
||||
multiline
|
||||
value={currentLead}
|
||||
onSave={(v) => saveField('lead', v)}
|
||||
className="mt-5 text-base font-light leading-9 text-ivory/60"
|
||||
/>
|
||||
)}
|
||||
|
||||
<h2 className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{lead && <p className="mt-5 text-base font-light leading-9 text-ivory/60">{lead}</p>}
|
||||
|
||||
<div className={`mt-6 h-px w-24 bg-copper/70 ${isCenter ? 'mx-auto' : ''}`} />
|
||||
</Reveal>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { MouseEvent } from 'react'
|
||||
import { ListIcon } from 'lucide-react'
|
||||
import { slugifyFa, uniqueSlug } from '../utils/slug'
|
||||
|
||||
export type TocItem = { id: string; text: string; level: 2 | 3 }
|
||||
|
||||
/**
|
||||
* استخراج فهرست مطالب از HTML ویرایشگر — تیترهای H2 و H3 شناسه لنگر میگیرند
|
||||
* (با slugify و پشتیبانی فارسی) و HTML برگشتی همان است که باید رندر شود.
|
||||
*/
|
||||
export function extractToc(html: string): { items: TocItem[]; html: string } {
|
||||
if (!html || typeof window === 'undefined') return { items: [], html }
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
const used = new Map<string, number>()
|
||||
const items: TocItem[] = []
|
||||
|
||||
doc.body.querySelectorAll('h2, h3').forEach((el) => {
|
||||
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
if (!text) return
|
||||
const id = uniqueSlug(slugifyFa(text), used)
|
||||
el.setAttribute('id', id)
|
||||
items.push({ id, text, level: el.tagName === 'H3' ? 3 : 2 })
|
||||
})
|
||||
|
||||
return { items, html: doc.body.innerHTML }
|
||||
}
|
||||
|
||||
type TableOfContentsProps = {
|
||||
items: TocItem[]
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** فهرست مطالب کلیکپذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */
|
||||
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
|
||||
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setActiveId(items[0]?.id ?? '')
|
||||
}, [items])
|
||||
|
||||
useEffect(() => {
|
||||
if (!items.length || typeof IntersectionObserver === 'undefined') return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||
const first = visible[0]?.target.id
|
||||
if (first) setActiveId(first)
|
||||
},
|
||||
{ rootMargin: '-15% 0px -70% 0px', threshold: [0, 1] },
|
||||
)
|
||||
items.forEach((item) => {
|
||||
const el = document.getElementById(item.id)
|
||||
if (el) observer.observe(el)
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}, [items])
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
const go = (event: MouseEvent<HTMLButtonElement>, id: string) => {
|
||||
event.preventDefault()
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
window.history.replaceState(null, '', `#${id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="فهرست مطالب"
|
||||
className={`rounded-2xl border border-white/10 bg-char/60 p-6 backdrop-blur-xl shadow-[0_20px_50px_rgba(0,0,0,0.35),inset_0_1px_1px_rgba(255,255,255,0.08)] ${className}`}
|
||||
>
|
||||
<p className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||
<ListIcon className="h-4 w-4" aria-hidden="true" />
|
||||
{title}
|
||||
</p>
|
||||
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
|
||||
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-r border-ivory/10 pr-3">
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => go(event, item.id)}
|
||||
aria-current={isActive ? 'location' : undefined}
|
||||
className={`block w-full text-right text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
|
||||
item.level === 3 ? 'pr-4 text-[13px]' : ''
|
||||
} ${
|
||||
isActive
|
||||
? 'font-normal text-copper'
|
||||
: 'text-ivory/60 hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
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 { Mark, mergeAttributes } from '@tiptap/core'
|
||||
import {
|
||||
BoldIcon,
|
||||
ItalicIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ImagePlusIcon,
|
||||
LanguagesIcon,
|
||||
Link2Icon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
PilcrowIcon,
|
||||
QuoteIcon,
|
||||
Redo2Icon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
Undo2Icon,
|
||||
Loader2Icon,
|
||||
} from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { ENGLISH_TEXT_MARK, EnglishText } from './EnglishText'
|
||||
|
||||
const SmallSizeMark = Mark.create({
|
||||
name: 'smallSize',
|
||||
parseHTML() { return [{ tag: 'span[data-small]' }] },
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
|
||||
}
|
||||
})
|
||||
|
||||
export const ARTICLE_EXTENSIONS = [
|
||||
StarterKit.configure({
|
||||
link: false,
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Image.configure({ allowBase64: false }),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
|
||||
}),
|
||||
SmallSizeMark,
|
||||
EnglishText,
|
||||
]
|
||||
|
||||
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" />
|
||||
|
||||
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)
|
||||
|
||||
// استفاده از ریگکس یونیکد اصلاحشده برای نام فایل و Alt
|
||||
const originalName = file.name.replace(/\.[^.]+$/, '')
|
||||
const cleanAlt = originalName.replace(/[^\w\u0600-\u06FF\s-]/g, '-').replace(/\s+/g, '-').toLowerCase()
|
||||
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
editor.chain().focus().setImage({ src: url, alt: cleanAlt }).run()
|
||||
})
|
||||
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-1 border-b border-ivory/10 px-2 py-1.5">
|
||||
|
||||
{/* قالببندی بلوک — پاراگراف و تیترها با سایز مشخص */}
|
||||
<ToolButton
|
||||
label="پاراگراف (۱۴ پیکسل)"
|
||||
active={editor.isActive('paragraph')}
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
>
|
||||
<PilcrowIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۱ (۱۸ پیکسل)"
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
>
|
||||
<Heading1Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۲ (۱۶ پیکسل)"
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
<Heading2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۳ (۱۴ پیکسل)"
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
<Heading3Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<select
|
||||
className="h-8 bg-ink border border-ivory/15 text-ivory/90 text-xs px-2 outline-none focus:border-copper cursor-pointer transition-colors"
|
||||
value={editor.isActive('smallSize') ? '12' : '14'}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '12') {
|
||||
editor.chain().focus().setMark('smallSize').run()
|
||||
} else {
|
||||
editor.chain().focus().unsetMark('smallSize').run()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="14">سایز ۱۴ (فارسی)</option>
|
||||
<option value="12">سایز ۱۲ (انگلیسی)</option>
|
||||
</select>
|
||||
|
||||
{/* متن انگلیسی — span با dir=ltr و سایز ۱۲ پیکسل */}
|
||||
<ToolButton
|
||||
label="متن انگلیسی (۱۲ پیکسل، چپبهراست)"
|
||||
active={editor.isActive(ENGLISH_TEXT_MARK)}
|
||||
onClick={() => editor.chain().focus().toggleMark(ENGLISH_TEXT_MARK).run()}
|
||||
>
|
||||
<LanguagesIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||
<BoldIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||
<ItalicIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
||||
<UnderlineIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="خطخورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||
<StrikethroughIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="لیست نقطهای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="لیست شمارهای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||
<ListOrderedIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="نقلقول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||
<QuoteIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="لینک" active={editor.isActive('link')} onClick={setLink}>
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
||||
<Undo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
||||
<Redo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
</div>
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
data-editor-file-input
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) void uploadAndInsert(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||
|
||||
/** نام مارک متن انگلیسی — ویرایشگر و رندر استاتیک مقاله از همین نام استفاده میکنند */
|
||||
export const ENGLISH_TEXT_MARK = 'englishText'
|
||||
|
||||
/**
|
||||
* مارک «متن انگلیسی» — متن انتخابشده را داخل
|
||||
* <span dir="ltr" class="english-text"> میپیچد تا ۱۲ پیکسل و چپبهراست
|
||||
* نمایش داده شود، بدون اینکه جهت متن فارسی اطرافش بههم بخورد.
|
||||
*/
|
||||
export const EnglishText = Mark.create({
|
||||
name: ENGLISH_TEXT_MARK,
|
||||
inclusive: false,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span.english-text' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, { dir: 'ltr', class: 'english-text' }),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useEffect, useId, 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>
|
||||
/** متن جایگزین (Alt) — با ارسال این prop ورودی Alt نمایش داده میشود */
|
||||
alt?: string
|
||||
/** ذخیره متن جایگزین — بدون آن، Alt فقط برای پیشنمایش و پیشنهاد استفاده میشود */
|
||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||
/** پیشنهاد Alt وقتی خالی است (مثل عنوان مقاله یا نام موجودیت) */
|
||||
altFallback?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش و صفحه نوشتن مقاله.
|
||||
* پس از آپلود موفق، URL نسبی /media/... ذخیره میشود.
|
||||
* اگر Alt خالی باشد، پیش از فراخوانی API از عنوان موجودیت یا نام فایل ساخته میشود.
|
||||
*/
|
||||
export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback = '' }: ImageFieldProps) {
|
||||
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const altInputId = useId()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [altText, setAltText] = useState(alt ?? '')
|
||||
const showAlt = alt !== undefined || !!onSaveAlt
|
||||
|
||||
// همگامسازی با مقدار ذخیرهشده (پس از بهروزرسانی خوشبینانه در درخت داده)
|
||||
useEffect(() => {
|
||||
setAltText(alt ?? '')
|
||||
}, [alt])
|
||||
|
||||
if (!isAuthenticated || !isEditMode) return null
|
||||
|
||||
/** متن جایگزین نهایی: ورودی کاربر → عنوان موجودیت → نام فایل */
|
||||
const resolveAlt = (fileName: string): string => {
|
||||
const typed = altText.trim()
|
||||
if (typed) return typed
|
||||
const fromEntity = altFallback.trim()
|
||||
if (fromEntity) return fromEntity
|
||||
return fileName.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || label
|
||||
}
|
||||
|
||||
const pick = async (file: File | undefined) => {
|
||||
if (!file) return
|
||||
const generatedAlt = resolveAlt(file.name)
|
||||
setUploading(true)
|
||||
try {
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
const ok = await onSave(url)
|
||||
if (!ok) throw new Error('ذخیره نشد')
|
||||
// Alt ساختهشده هم ذخیره میشود تا تصویر بدون متن جایگزین نماند
|
||||
if (showAlt && generatedAlt !== (alt ?? '').trim()) {
|
||||
setAltText(generatedAlt)
|
||||
if (onSaveAlt) await onSaveAlt(generatedAlt)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const commitAlt = () => {
|
||||
const next = altText.trim()
|
||||
if (!onSaveAlt || next === (alt ?? '').trim()) return
|
||||
void onSaveAlt(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
{value ? (
|
||||
<img
|
||||
src={value}
|
||||
alt={altText || 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>
|
||||
|
||||
{showAlt && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label htmlFor={altInputId} className="text-[11px] font-light text-ivory/50">
|
||||
متن جایگزین (Alt)
|
||||
</label>
|
||||
<span className="text-[10px] font-light text-ivory/30">برای سئو و صفحهخوانها</span>
|
||||
</div>
|
||||
<input
|
||||
id={altInputId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={altText}
|
||||
onChange={(e) => setAltText(e.target.value)}
|
||||
onBlur={commitAlt}
|
||||
placeholder={altFallback ? `پیشفرض: ${altFallback}` : 'توضیح کوتاه تصویر…'}
|
||||
className="mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import DatePicker from 'react-multi-date-picker'
|
||||
import type { DateObject } from 'react-multi-date-picker'
|
||||
import persian from 'react-date-object/calendars/persian'
|
||||
import persian_fa from 'react-date-object/locales/persian_fa'
|
||||
import { toISODate, toJalaliDateObject } from '../../utils/date'
|
||||
|
||||
type JalaliDatePickerProps = {
|
||||
/** مقدار ذخیرهشده — ISO میلادی (YYYY-MM-DD) */
|
||||
value: string
|
||||
/** تغییر مقدار — همیشه ISO میلادی برمیگرداند تا ذخیرهسازی یکدست بماند */
|
||||
onChange: (isoDate: string) => void
|
||||
id?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخابگر تاریخ شمسی (react-multi-date-picker) با تقویم و locale فارسی.
|
||||
* نمایش جلالی است اما مقدار ذخیرهشده ISO میلادی میماند.
|
||||
*/
|
||||
export function JalaliDatePicker({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
placeholder = 'انتخاب تاریخ',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: JalaliDatePickerProps) {
|
||||
return (
|
||||
<DatePicker
|
||||
id={id}
|
||||
value={toJalaliDateObject(value)}
|
||||
onChange={(selected) => {
|
||||
const picked = Array.isArray(selected) ? selected[0] : selected
|
||||
if (!picked) return
|
||||
onChange(toISODate(picked as DateObject))
|
||||
}}
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
format="YYYY/MM/DD"
|
||||
calendarPosition="bottom-right"
|
||||
className={`aramesh-datepicker ${className}`}
|
||||
containerClassName="w-full"
|
||||
inputClass="w-full cursor-pointer border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
mobileLabels={{ OK: 'تأیید', CANCEL: 'انصراف' }}
|
||||
portal
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import Select from 'react-select'
|
||||
import CreatableSelect from 'react-select/creatable'
|
||||
import type { StylesConfig } from 'react-select'
|
||||
|
||||
export type LuxeOption = { label: string; value: string }
|
||||
|
||||
const COPPER = '#d5874c'
|
||||
const IVORY = '#F5F3EF'
|
||||
const SURFACE = '#1e1a16'
|
||||
const BORDER = 'rgba(245,243,239,0.15)'
|
||||
|
||||
/** استایل تیره و لوکس react-select هماهنگ با پالت سایت (مسی روی زغالی) */
|
||||
function makeStyles<IsMulti extends boolean>(): StylesConfig<LuxeOption, IsMulti> {
|
||||
return {
|
||||
control: (base, state) => ({
|
||||
...base,
|
||||
minHeight: 42,
|
||||
backgroundColor: SURFACE,
|
||||
borderColor: state.isFocused ? COPPER : BORDER,
|
||||
borderRadius: 0,
|
||||
boxShadow: state.isFocused ? '0 0 0 1px rgba(213,135,76,0.35)' : 'none',
|
||||
transition: 'border-color 150ms ease, box-shadow 150ms ease',
|
||||
':hover': { borderColor: state.isFocused ? COPPER : 'rgba(213,135,76,0.5)' },
|
||||
}),
|
||||
valueContainer: (base) => ({ ...base, padding: '2px 12px' }),
|
||||
placeholder: (base) => ({ ...base, color: 'rgba(245,243,239,0.35)', fontSize: 13 }),
|
||||
singleValue: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
|
||||
input: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
|
||||
menu: (base) => ({
|
||||
...base,
|
||||
backgroundColor: SURFACE,
|
||||
border: `1px solid ${BORDER}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: '0 24px 50px -18px rgba(0,0,0,0.75)',
|
||||
overflow: 'hidden',
|
||||
zIndex: 60,
|
||||
}),
|
||||
menuList: (base) => ({ ...base, padding: 4 }),
|
||||
menuPortal: (base) => ({ ...base, zIndex: 200 }),
|
||||
option: (base, state) => ({
|
||||
...base,
|
||||
backgroundColor: state.isSelected
|
||||
? 'rgba(213,135,76,0.25)'
|
||||
: state.isFocused
|
||||
? 'rgba(213,135,76,0.12)'
|
||||
: 'transparent',
|
||||
color: state.isSelected ? COPPER : 'rgba(245,243,239,0.85)',
|
||||
fontSize: 13,
|
||||
fontWeight: state.isSelected ? 700 : 400,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 0,
|
||||
':active': { backgroundColor: 'rgba(213,135,76,0.3)' },
|
||||
}),
|
||||
noOptionsMessage: (base) => ({ ...base, color: 'rgba(245,243,239,0.45)', fontSize: 12 }),
|
||||
indicatorSeparator: (base) => ({ ...base, backgroundColor: BORDER }),
|
||||
dropdownIndicator: (base, state) => ({
|
||||
...base,
|
||||
color: state.isFocused ? COPPER : 'rgba(245,243,239,0.4)',
|
||||
':hover': { color: COPPER },
|
||||
}),
|
||||
clearIndicator: (base) => ({
|
||||
...base,
|
||||
color: 'rgba(245,243,239,0.4)',
|
||||
':hover': { color: COPPER },
|
||||
}),
|
||||
multiValue: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'rgba(213,135,76,0.15)',
|
||||
border: '1px solid rgba(213,135,76,0.4)',
|
||||
borderRadius: 999,
|
||||
paddingInline: 2,
|
||||
}),
|
||||
multiValueLabel: (base) => ({ ...base, color: COPPER, fontSize: 12 }),
|
||||
multiValueRemove: (base) => ({
|
||||
...base,
|
||||
color: COPPER,
|
||||
borderRadius: 999,
|
||||
':hover': { backgroundColor: COPPER, color: SURFACE },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const portalTarget = (): HTMLElement | undefined =>
|
||||
typeof document === 'undefined' ? undefined : document.body
|
||||
|
||||
type LuxeSelectProps = {
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
isClearable?: boolean
|
||||
isDisabled?: boolean
|
||||
inputId?: string
|
||||
}
|
||||
|
||||
/** react-select تکی با ظاهر تیره پروژه — گزینه فعلی حتی اگر در لیست نباشد حفظ میشود */
|
||||
export function LuxeSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder = 'انتخاب کنید…',
|
||||
isClearable = true,
|
||||
isDisabled = false,
|
||||
inputId,
|
||||
}: LuxeSelectProps) {
|
||||
const selectOptions = useMemo(() => [...new Set([...options, ...(value ? [value] : [])])].map(
|
||||
(item) => ({ label: item, value: item }),
|
||||
), [options, value])
|
||||
|
||||
return (
|
||||
<Select<LuxeOption, false>
|
||||
inputId={inputId}
|
||||
classNamePrefix="luxe"
|
||||
options={selectOptions}
|
||||
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||
onChange={(option) => onChange(option?.value ?? '')}
|
||||
placeholder={placeholder}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
styles={makeStyles<false>()}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type LuxeTagsSelectProps = {
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
/** هشتگها با react-select قابل ساخت — هر تایپ جدید با Enter به فهرست اضافه میشود */
|
||||
export function LuxeTagsSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'هشتگ بنویسید و Enter بزنید…',
|
||||
}: LuxeTagsSelectProps) {
|
||||
const options = useMemo(
|
||||
() => [...new Set(value.filter(Boolean))].map((item) => ({ label: item, value: item })),
|
||||
[value],
|
||||
)
|
||||
|
||||
return (
|
||||
<CreatableSelect<LuxeOption, true>
|
||||
classNamePrefix="luxe"
|
||||
isMulti
|
||||
options={options}
|
||||
value={options}
|
||||
onChange={(next) => onChange(next.map((option) => option.value))}
|
||||
placeholder={placeholder}
|
||||
formatCreateLabel={(input) => `افزودن «${input}»`}
|
||||
noOptionsMessage={() => 'هشتگی یافت نشد'}
|
||||
styles={makeStyles<true>()}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
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,65 +3,41 @@ import { ArrowLeftIcon } from 'lucide-react';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { ButtonLink } from '../Button';
|
||||
import { ArticleCard } from '../ArticleCard';
|
||||
import { useSectionHeading, useSite } from '../../api';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
import { useSite } from '../../api';
|
||||
|
||||
export function Articles() {
|
||||
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 (
|
||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<Reveal className="max-w-2xl text-right">
|
||||
<p className="text-sm font-light text-copper">
|
||||
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
|
||||
</p>
|
||||
<p className="text-sm font-light text-copper">وبلاگ</p>
|
||||
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
|
||||
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||
آخرین مقالات
|
||||
</h2>
|
||||
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
||||
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
||||
یادداشتهای تیم معماری، اجرا و سرمایهگذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار میگذرد.
|
||||
</p>
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
||||
{published.slice(0, 3).map((article, i) =>
|
||||
<Reveal key={article.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
||||
<ArticleCard article={article} />
|
||||
</EditableItem>
|
||||
{articles.slice(0, 3).map((article, i) =>
|
||||
<Reveal key={article.slug} delay={0.06 * i} className="h-full">
|
||||
<ArticleCard article={article} />
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
|
||||
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<AddItemButton resource="articles" />
|
||||
</div>
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to="/blog" variant="outline" className="font-light">
|
||||
همه مقالات
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { MoveHorizontalIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function BeforeAfter() {
|
||||
const { before_after } = useSite()
|
||||
@@ -55,10 +54,6 @@ export function BeforeAfter() {
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<SingletonPill resource="before_after" label="ویرایش تصاویر و برچسبهای قبل/بعد" />
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { PlusIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
type FaqProps = {
|
||||
hideHeading?: boolean;
|
||||
@@ -16,15 +15,14 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
||||
|
||||
|
||||
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
|
||||
|
||||
|
||||
{!hideHeading && (
|
||||
<div className="lg:col-span-4">
|
||||
<SectionHeading
|
||||
title="سوالات متداول"
|
||||
lead="پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."
|
||||
headingKey="faq"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -34,48 +32,43 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
{faqs.map((faq, i) => {
|
||||
const isOpen = open === i
|
||||
return (
|
||||
<Reveal key={faq.id} delay={0.04 * i}>
|
||||
<EditableItem resource="faqs" item={faq}>
|
||||
<li className="border-b border-ivory/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||
{faq.question}
|
||||
</span>
|
||||
<PlusIcon
|
||||
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
||||
isOpen ? 'rotate-45' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</li>
|
||||
</EditableItem>
|
||||
<Reveal key={faq.question} delay={0.04 * i}>
|
||||
<li className="border-b border-ivory/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||
{faq.question}
|
||||
</span>
|
||||
<PlusIcon
|
||||
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
||||
isOpen ? 'rotate-45' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</li>
|
||||
</Reveal>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="mt-8">
|
||||
<AddItemButton resource="faqs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,75 +5,58 @@ import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { ProjectCard } from '../ProjectCard'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
import { useSite } from '../../api'
|
||||
import type { ProjectStatus } from '../../data/site'
|
||||
|
||||
export function FeaturedProjects() {
|
||||
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: 'انجام شده' },
|
||||
{ key: 'progress', label: 'در حال انجام' },
|
||||
]
|
||||
|
||||
const FILTERS: { key: ProjectStatus; label: string }[] = [
|
||||
{ key: 'done', label: useStatusLabel('done') },
|
||||
{ key: 'progress', label: useStatusLabel('progress') },
|
||||
]
|
||||
export function FeaturedProjects() {
|
||||
const { projects } = useSite()
|
||||
const [status, setStatus] = useState<ProjectStatus>('done')
|
||||
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
|
||||
<SectionHeading
|
||||
title="پروژههای شاخص"
|
||||
lead="کارهایی که بیشترین چیز را درباره استاندارد ما میگویند؛ چه آنهایی که تحویل شدهاند و چه آنهایی که همین امروز در حال ساختاند."
|
||||
/>
|
||||
|
||||
{/* دکمههای فیلتر مینیمال و شیشهای (بدون کادر) */}
|
||||
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||
{FILTERS.map((filter, index) => {
|
||||
const isSelected = status === filter.key
|
||||
return (
|
||||
<React.Fragment key={filter.key}>
|
||||
{/* خط جداکننده بین دکمهها */}
|
||||
{index > 0 && (
|
||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => setStatus(filter.key)}
|
||||
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${
|
||||
isSelected
|
||||
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
|
||||
: 'bg-transparent text-ivory/60 hover:bg-white/5 hover:text-ivory'
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 transition-colors duration-300 ease-out">
|
||||
<InlineEdit
|
||||
value={filter.label}
|
||||
onSave={(v) => saveStatusLabel(filter.key, v)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||
{FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === filter.key}
|
||||
onClick={() => setStatus(filter.key)}
|
||||
className={`group relative overflow-hidden border px-7 py-3 text-sm transition-colors duration-300 ease-out ${
|
||||
status === filter.key
|
||||
? 'border-copper bg-copper font-bold text-char'
|
||||
: 'border-ivory/15 font-light text-ivory/65'
|
||||
}`}
|
||||
>
|
||||
{status !== filter.key && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-out group-hover:translate-y-0"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 transition-colors duration-300 ease-out group-hover:text-char">
|
||||
{filter.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 min-h-[420px]">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={status}
|
||||
key={status}
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
@@ -82,46 +65,27 @@ export function FeaturedProjects() {
|
||||
>
|
||||
{list.map((project, i) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
key={project.slug}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full"
|
||||
>
|
||||
<EditableItem resource="projects" item={project} className="h-full">
|
||||
<ProjectCard project={project} />
|
||||
</EditableItem>
|
||||
<ProjectCard project={project} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* دکمه مشاهده همه پروژهها */}
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink
|
||||
to={heading.cta_href}
|
||||
variant="outline"
|
||||
className="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>
|
||||
)}
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to="/projects" variant="outline" className="font-light">
|
||||
همه پروژهها
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -3,17 +3,17 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
import { IMAGES } from '../../data/site'
|
||||
|
||||
const GRID = [IMAGES[0], IMAGES[1], IMAGES[2], IMAGES[3], IMAGES[4], IMAGES[1]]
|
||||
|
||||
export function Gallery() {
|
||||
const { gallery } = useSite()
|
||||
const [active, setActive] = useState<number | null>(null)
|
||||
|
||||
const close = useCallback(() => setActive(null), [])
|
||||
const step = useCallback((dir: number) => {
|
||||
setActive((prev) => (prev === null ? prev : (prev + dir + gallery.length) % gallery.length))
|
||||
}, [gallery.length])
|
||||
setActive((prev) => (prev === null ? prev : (prev + dir + GRID.length) % GRID.length))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (active === null) return
|
||||
@@ -32,35 +32,31 @@ export function Gallery() {
|
||||
<SectionHeading
|
||||
title="گالری تصاویر"
|
||||
lead="قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."
|
||||
headingKey="gallery"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
{gallery.map((image, i) => (
|
||||
<Reveal key={image.id} delay={0.04 * i}>
|
||||
<EditableItem resource="gallery" item={image}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`بزرگنمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
||||
>
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
</EditableItem>
|
||||
{GRID.map((src, i) => (
|
||||
<Reveal key={`${src}-${i}`} delay={0.04 * i}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`بزرگنمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={`تصویری از پروژههای گروه ساختمانی آرامش، شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="gallery" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{active !== null && gallery[active] && (
|
||||
{active !== null && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -106,7 +102,7 @@ export function Gallery() {
|
||||
|
||||
<motion.img
|
||||
key={active}
|
||||
src={gallery[active].src}
|
||||
src={GRID[active]}
|
||||
alt=""
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
@@ -120,4 +116,4 @@ export function Gallery() {
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,26 +3,16 @@ import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function Hero() {
|
||||
const { brand, hero } = useSite()
|
||||
const save = useFieldSaver()
|
||||
const { brand } = useSite()
|
||||
const { scrollY } = useScroll()
|
||||
const imageY = useTransform(scrollY, [0, 700], [0, 110])
|
||||
const contentY = useTransform(scrollY, [0, 700], [0, -55])
|
||||
const overlay = useTransform(scrollY, [0, 600], [0.5, 0.78])
|
||||
|
||||
const saveHero = (field: string, value: unknown) =>
|
||||
save('hero', hero.id, { [field]: value }, (d) => {
|
||||
;(d.hero as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی گروه ساختمانی آرامش">
|
||||
{/* تصویر پسزمینه */}
|
||||
<motion.img
|
||||
src={brand.hero}
|
||||
alt="نمای شبانه برج مسکونی ساختهشده توسط گروه ساختمانی آرامش"
|
||||
@@ -39,113 +29,78 @@ export function Hero() {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* محتوای اصلی هیرو */}
|
||||
<motion.div
|
||||
style={{ y: contentY }}
|
||||
className="relative mx-auto flex h-full max-w-site flex-col justify-center px-6 lg:px-10"
|
||||
>
|
||||
<div className="w-full max-w-[700px] lg:max-w-[58%]">
|
||||
{/* کیکر بالای تیتر */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="text-sm font-light text-copper tracking-wide"
|
||||
className="text-sm font-light text-copper"
|
||||
>
|
||||
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} />
|
||||
از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال
|
||||
</motion.p>
|
||||
|
||||
{/* تیتر اصلی */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
|
||||
className="mt-6 text-hero font-bold text-ivory"
|
||||
>
|
||||
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" />
|
||||
ساختمانی که آرامش را
|
||||
<span className="text-copper"> معماری </span>
|
||||
میکند
|
||||
</motion.h1>
|
||||
|
||||
{/* زیرتیتر */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/75"
|
||||
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/70"
|
||||
>
|
||||
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
|
||||
طراحی، ساخت و سرمایهگذاری در پروژههای مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده میشود و در
|
||||
سالها دوام میآورد.
|
||||
</motion.p>
|
||||
|
||||
{/* داک دکمهها: استایل کپسولی، شیشهای و دایرهای */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-10 flex flex-wrap items-center gap-4"
|
||||
>
|
||||
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
||||
<ButtonLink
|
||||
to={hero.cta_primary_href}
|
||||
variant="copper"
|
||||
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 to="/projects" variant="copper">
|
||||
مشاهده پروژهها
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
|
||||
{/* دکمه ثانویه: شیشه دودی مات (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 to="/contact" variant="outline" className="font-light">
|
||||
مشاوره سرمایهگذاری
|
||||
</ButtonLink>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* کادر آمار شناور (گوشه پایین چپ): شیشهای با لبههای گرد مدرن */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="absolute bottom-28 left-6 hidden w-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"
|
||||
className="absolute bottom-28 left-6 hidden w-60 border border-ivory/15 bg-char/60 p-6 backdrop-blur-md lg:block lg:left-10"
|
||||
>
|
||||
<p className="text-3xl font-bold text-copper font-mono">
|
||||
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-copper">۴۷</p>
|
||||
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
|
||||
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
|
||||
پروژه تحویلشده در بیستویک سال فعالیت حرفهای
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||
<a
|
||||
href="#story"
|
||||
aria-label="رفتن به بخش بعدی"
|
||||
className="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"
|
||||
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"
|
||||
>
|
||||
<span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100">
|
||||
<InlineEdit value={hero.scroll_label} onSave={(v) => saveHero('scroll_label', v)} />
|
||||
</span>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-ivory/20 bg-char/40 backdrop-blur-md transition-all duration-300 group-hover:border-copper group-hover:bg-char/70 group-hover:shadow-lg group-hover:shadow-copper/20">
|
||||
<ChevronDownIcon className="h-4 w-4 animate-bounce text-ivory group-hover:text-copper" />
|
||||
</div>
|
||||
<span className="text-xs font-light">اسکرول کنید</span>
|
||||
<ChevronDownIcon className="h-4 w-4 animate-bounce" />
|
||||
</a>
|
||||
|
||||
{/* کنترلهای ادمین */}
|
||||
<div className="absolute left-6 top-24 z-20 flex flex-col gap-2 lg:left-10">
|
||||
<SingletonPill resource="hero" label="تنظیمات هیرو (واژه مسی، دکمهها)" />
|
||||
<SingletonPill
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی صفحه اصلی"
|
||||
select={(d) => d.seo_meta.find((s) => s.page_key === 'home')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,24 +3,13 @@ import { CheckIcon, SendIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from '../Reveal'
|
||||
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'
|
||||
|
||||
export function Newsletter() {
|
||||
const { newsletter } = useSite()
|
||||
const save = useFieldSaver()
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
const saveContent = (field: string, value: string) =>
|
||||
save('newsletter', newsletter.id, { [field]: value }, (d) => {
|
||||
;(d.newsletter as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const showErrorAlert = (title: string, text: string) => {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
@@ -32,11 +21,11 @@ export function Newsletter() {
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
popup: 'font-pinar border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl bg-char/95',
|
||||
container: 'backdrop-blur-[8px] bg-ink/60',
|
||||
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
|
||||
title: 'font-pinar font-bold text-lg text-ivory pt-2',
|
||||
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
|
||||
confirmButton: 'mt-6 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)]',
|
||||
confirmButton: 'mt-6 bg-copper text-char font-bold px-10 py-3 transition-transform duration-300 ease-lux hover:scale-105',
|
||||
},
|
||||
showClass: {
|
||||
popup: 'swal2-show',
|
||||
@@ -96,28 +85,22 @@ export function Newsletter() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
||||
<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">
|
||||
<section className="bg-copper py-16" aria-labelledby="newsletter-title">
|
||||
<div className="mx-auto grid max-w-site items-center gap-8 px-6 lg:grid-cols-2 lg:px-10">
|
||||
<Reveal>
|
||||
<h2 id="newsletter-title" className="text-2xl font-bold leading-relaxed text-char md:text-3xl">
|
||||
<InlineEdit value={newsletter.title} onSave={(v) => saveContent('title', v)} />
|
||||
خبرنامه گروه ساختمانی آرامش
|
||||
</h2>
|
||||
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/80">
|
||||
<InlineEdit multiline value={newsletter.lead} onSave={(v) => saveContent('lead', v)} />
|
||||
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/75">
|
||||
هر ماه یک ایمیل: پروژههای جدید، فرصتهای سرمایهگذاری و یادداشتهای تیم معماری.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.08}>
|
||||
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
|
||||
<form onSubmit={submit} className="flex flex-col gap-3 sm:flex-row" noValidate>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="newsletter-email" className="sr-only">
|
||||
{newsletter.placeholder}
|
||||
نشانی ایمیل
|
||||
</label>
|
||||
<input
|
||||
id="newsletter-email"
|
||||
@@ -128,30 +111,21 @@ export function Newsletter() {
|
||||
setEmail(e.target.value)
|
||||
if (status !== 'idle') setStatus('idle')
|
||||
}}
|
||||
placeholder={newsletter.placeholder}
|
||||
className="w-full rounded-full border border-black/15 bg-char/90 px-6 py-4 text-left text-sm font-light text-ivory placeholder:text-ivory/40 shadow-[inset_0_2px_4px_rgba(0,0,0,0.35)] backdrop-blur-xl transition-all duration-300 ease-lux focus:border-ivory focus:bg-char focus:shadow-[0_0_20px_rgba(255,255,255,0.25)] focus:outline-none"
|
||||
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"
|
||||
aria-invalid={status === 'error'}
|
||||
/>
|
||||
{status === 'done' && (
|
||||
<p className="mt-2.5 flex items-center gap-1.5 px-3 text-xs font-medium text-char" role="status">
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
{newsletter.success_text}
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-light text-char" role="status">
|
||||
<CheckIcon className="h-3.5 w-3.5" />
|
||||
عضویت شما ثبت شد.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="dark"
|
||||
disabled={sending}
|
||||
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
|
||||
>
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
<span>
|
||||
{sending ? newsletter.sending_label : (
|
||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<Button type="submit" variant="dark-ivory" disabled={sending}>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
{sending ? 'در حال ارسال…' : 'عضویت'}
|
||||
</Button>
|
||||
</form>
|
||||
</Reveal>
|
||||
|
||||
@@ -1,56 +1,42 @@
|
||||
import React from 'react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ServiceIcon } from '../ServiceIcon'
|
||||
import { useSectionHeading, useSite } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
import { useSite } from '../../api'
|
||||
|
||||
export function Services() {
|
||||
const { services } = useSite()
|
||||
const heading = useSectionHeading('services')
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveHeading = (field: 'title' | 'lead', value: string) =>
|
||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) h[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<Reveal className="max-w-2xl text-right">
|
||||
|
||||
|
||||
<h2 id="services-title" className="text-section font-bold text-ivory">
|
||||
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||
خدمات
|
||||
</h2>
|
||||
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
||||
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
||||
از نخستین خط روی کاغذ تا تحویل کلید، مدیریت سرمایه و طراحی محوطه؛ همه مراحل در یک مجموعه و زیر یک نظام
|
||||
کنترل کیفیت.
|
||||
</p>
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{services.map((service, i) => (
|
||||
<Reveal key={service.id} delay={0.04 * i} className="h-full">
|
||||
<EditableItem resource="services" item={service} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||
<ServiceIcon
|
||||
name={service.icon}
|
||||
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
|
||||
/>
|
||||
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
|
||||
</article>
|
||||
</EditableItem>
|
||||
<Reveal key={service.title} delay={0.04 * i} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||
<ServiceIcon
|
||||
name={service.icon}
|
||||
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
|
||||
/>
|
||||
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="services" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useSite } from '../../api';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
|
||||
function toFa(n: number) {
|
||||
return n.toLocaleString('fa-IR');
|
||||
@@ -32,39 +29,19 @@ function Counter({ value }: {value: number;}) {
|
||||
|
||||
export function Stats() {
|
||||
const { stats } = useSite();
|
||||
const save = useFieldSaver();
|
||||
|
||||
return (
|
||||
<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">
|
||||
{stats.map((stat) =>
|
||||
<EditableItem key={stat.id} resource="stats" item={stat}>
|
||||
<div className="text-center">
|
||||
<div key={stat.label} className="text-center">
|
||||
<p className="text-4xl font-bold text-copper md:text-5xl">
|
||||
<Counter value={stat.value} />
|
||||
<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;
|
||||
})}
|
||||
/>
|
||||
{stat.suffix && <span className="mr-1 text-2xl">{stat.suffix}</span>}
|
||||
</p>
|
||||
<p className="mt-3 text-sm font-light text-ivory/55">{stat.label}</p>
|
||||
</div>
|
||||
</EditableItem>
|
||||
)}
|
||||
<AddItemButton resource="stats" />
|
||||
</div>
|
||||
</section>);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -2,34 +2,23 @@ import React from 'react'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
import { IMAGES } from '../../data/site'
|
||||
|
||||
const BENTO = [
|
||||
{ src: IMAGES[0], alt: 'نمای برج مسکونی آرامش در تهران' },
|
||||
{ src: IMAGES[1], alt: 'آتریوم مرکزی مجتمع تجاری پارسه' },
|
||||
{ src: IMAGES[2], alt: 'ویلاهای ساختهشده در سواحل شمال' },
|
||||
{ src: IMAGES[3], alt: 'نمای شبانه برج دوقلوی نیاوران' },
|
||||
]
|
||||
|
||||
type StoryProps = {
|
||||
showLink?: boolean;
|
||||
showLabel?: boolean;
|
||||
showQuote?: boolean;
|
||||
alignTop?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export function Story({ showLink = true, showLabel = true, alignTop = false, children }: StoryProps) {
|
||||
return (
|
||||
<section id="story" className="bg-ink py-24 lg:py-32" aria-labelledby="story-title">
|
||||
<div className={`mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-2 lg:px-10 ${alignTop ? 'items-start' : 'items-center'}`}>
|
||||
@@ -37,58 +26,40 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
{showLabel && (
|
||||
<Reveal>
|
||||
<h2 id="story-title" className="text-section font-bold text-ivory">
|
||||
<InlineEdit value={story.title} onSave={(v) => saveStory('title', v)} />
|
||||
درباره ی ما
|
||||
</h2>
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.08}>
|
||||
{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 className="mt-8 text-base font-light leading-9 text-ivory/65">
|
||||
گروه ساختمانی آرامش با یک دفتر کوچک معماری آغاز شد و امروز یکی از مجموعههای شناختهشده ساختوساز لوکس در ایران
|
||||
است. در تمام این سالها یک اصل تغییر نکرده است: ساختمان باید برای زندگی ساخته شود، نه برای فروش سریع.
|
||||
</p>
|
||||
<p className="mt-5 text-base font-light leading-9 text-ivory/65">
|
||||
هر پروژه با مطالعه زمین، نور، اقلیم و الگوی زندگی ساکنان آغاز میشود؛ سپس تیم طراحی، سازه و اجرا در یک
|
||||
فرآیند واحد آن را تا تحویل کلید پیش میبرند.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.16}>
|
||||
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-copper">
|
||||
<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>
|
||||
<p className="text-2xl font-bold text-copper">۹۴٪</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">تحویل بهموقع پروژهها</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-copper">
|
||||
<InlineEdit value={story.stat2_value} onSave={(v) => saveStory('stat2_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">
|
||||
<InlineEdit value={story.stat2_label} onSave={(v) => saveStory('stat2_label', v)} />
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-copper">۵ سال</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">پشتیبانی پس از تحویل</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{showQuote && story.quote && (
|
||||
<Reveal delay={0.2}>
|
||||
<div className="mt-12 border-r-2 border-copper bg-char/40 p-6">
|
||||
<p className="text-sm font-light leading-8 text-ivory/80">
|
||||
<InlineEdit multiline value={story.quote} onSave={(v) => saveStory('quote', v)} />
|
||||
</p>
|
||||
<p className="mt-5 text-sm font-bold text-copper">
|
||||
<InlineEdit value={story.quote_author} onSave={(v) => saveStory('quote_author', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{showLink && (
|
||||
<Reveal delay={0.22}>
|
||||
<ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light">
|
||||
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
||||
<ButtonLink to="/about" variant="outline" className="mt-10 font-light">
|
||||
داستان کامل ما
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
</Reveal>
|
||||
@@ -101,9 +72,8 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative grid grid-cols-2 gap-4">
|
||||
<SingletonPill resource="story" label="تصاویر و نقلقول بخش درباره ما" className="absolute -top-10 right-0" />
|
||||
{story.bento.map((item, i) => (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{BENTO.map((item, i) => (
|
||||
<Reveal key={item.src + i} delay={0.06 * i}>
|
||||
<div className="overflow-hidden">
|
||||
<img
|
||||
@@ -118,4 +88,4 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import React from 'react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Team() {
|
||||
const { team } = useSite()
|
||||
@@ -12,30 +11,26 @@ export function Team() {
|
||||
<SectionHeading
|
||||
title="تیم ما"
|
||||
lead="کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."
|
||||
headingKey="team"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{team.map((member, i) => (
|
||||
<Reveal key={member.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="team" item={member} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
|
||||
{member.name.trim().charAt(0)}
|
||||
</div>
|
||||
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
|
||||
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
|
||||
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
|
||||
{member.bio}
|
||||
</p>
|
||||
</article>
|
||||
</EditableItem>
|
||||
<Reveal key={member.name} delay={0.06 * i} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
|
||||
{member.name.trim().charAt(0)}
|
||||
</div>
|
||||
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
|
||||
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
|
||||
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
|
||||
{member.bio}
|
||||
</p>
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="team" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,11 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
|
||||
import { SectionHeading } from '../SectionHeading';
|
||||
import { useSite } from '../../api';
|
||||
import { EditableItem } from '../../admin/Editable';
|
||||
|
||||
export function Testimonials() {
|
||||
const { testimonials } = useSite();
|
||||
const [index, setIndex] = useState(0);
|
||||
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))];
|
||||
if (!current) return null;
|
||||
const item = testimonials[index];
|
||||
|
||||
const go = (dir: number) => {
|
||||
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
|
||||
@@ -18,67 +16,61 @@ export function Testimonials() {
|
||||
return (
|
||||
<section className="border-y border-ivory/10 bg-char py-24 lg:py-32" aria-labelledby="testimonials-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading headingKey="testimonials" align="center" />
|
||||
<SectionHeading title="نظرات مشتریان" align="center" />
|
||||
|
||||
<div className="relative mx-auto mt-14 max-w-3xl text-center">
|
||||
<QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.blockquote
|
||||
key={current.id}
|
||||
key={item.name}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
||||
|
||||
<EditableItem resource="testimonials" item={current}>
|
||||
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{current.quote}»</p>
|
||||
<footer className="mt-8">
|
||||
<p className="text-base font-bold text-copper">{current.name}</p>
|
||||
<p className="mt-1 text-sm font-light text-ivory/45">{current.role}</p>
|
||||
</footer>
|
||||
</EditableItem>
|
||||
|
||||
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{item.quote}»</p>
|
||||
<footer className="mt-8">
|
||||
<p className="text-base font-bold text-copper">{item.name}</p>
|
||||
<p className="mt-1 text-sm font-light text-ivory/45">{item.role}</p>
|
||||
</footer>
|
||||
</motion.blockquote>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-12 flex items-center justify-center gap-5">
|
||||
<div className="mt-10 flex items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(1)}
|
||||
aria-label="نظر قبلی"
|
||||
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5 transition-transform duration-300 ease-lux group-hover:translate-x-0.5" />
|
||||
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" />
|
||||
</button>
|
||||
<div className="flex gap-2" role="tablist" aria-label="انتخاب نظر">
|
||||
{testimonials.map((t, i) =>
|
||||
<button
|
||||
key={t.name}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`نظر ${t.name}`}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-1.5 transition-[width,background-color] duration-200 ease-lux ${
|
||||
i === index ? 'w-8 bg-copper' : 'w-3 bg-ivory/25'}`
|
||||
} />
|
||||
|
||||
<div className="flex items-center gap-2" role="tablist" aria-label="انتخاب نظر">
|
||||
{testimonials.map((t, i) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`نظر ${t.name}`}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-2 rounded-full transition-all duration-300 ease-lux ${
|
||||
i === index
|
||||
? 'w-8 bg-copper shadow-[0_0_12px_rgba(197,143,92,0.6)]'
|
||||
: 'w-2.5 bg-ivory/20 hover:bg-ivory/40'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(-1)}
|
||||
aria-label="نظر بعدی"
|
||||
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5 transition-transform duration-300 ease-lux group-hover:-translate-x-0.5" />
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>);
|
||||
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import React, { useRef } from 'react'
|
||||
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
||||
import { useSite, type TimelineItem } from '../../api'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
const ease = [0.23, 1, 0.32, 1] as const
|
||||
|
||||
@@ -17,10 +16,10 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute right-[-5px] top-8 z-10 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
|
||||
className={`absolute right-[-5px] top-6 z-10 h-2.5 w-2.5 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
|
||||
active
|
||||
? 'bg-copper shadow-[0_0_15px_rgba(197,143,92,0.6),0_0_0_6px_rgba(213,135,76,0.15)]'
|
||||
: 'bg-ivory/20'
|
||||
? 'bg-copper shadow-[0_0_0_5px_rgba(213,135,76,0.22)]'
|
||||
: 'bg-ivory/25'
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -32,16 +31,11 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
||||
transition={{ duration: 0.55, ease }}
|
||||
>
|
||||
<EditableItem resource="timeline" item={item}>
|
||||
<div className="group relative overflow-hidden rounded-2xl border border-ivory/10 bg-ink/40 p-6 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-1.5 hover:border-copper/50 hover:bg-ink/60 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] md:p-8">
|
||||
{/* لایه نوری ظریف که در هاور روشن میشود */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
||||
|
||||
<span className="relative z-10 text-sm font-medium tracking-widest text-copper">{item.year}</span>
|
||||
<h3 className="relative z-10 mt-3 text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-white">{item.title}</h3>
|
||||
<p className="relative z-10 mt-3 text-sm font-light leading-relaxed text-ivory/60 transition-colors duration-300 group-hover:text-ivory/80">{item.desc}</p>
|
||||
</div>
|
||||
</EditableItem>
|
||||
<div className="border border-ivory/10 bg-ink p-6 md:p-7">
|
||||
<span className="text-sm font-light text-copper">{item.year}</span>
|
||||
<h3 className="mt-3 text-lg font-bold text-ivory">{item.title}</h3>
|
||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/60">{item.desc}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</li>
|
||||
)
|
||||
@@ -59,35 +53,31 @@ export function Timeline() {
|
||||
return (
|
||||
<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="mb-16">
|
||||
<SectionHeading
|
||||
title="مسیر ما"
|
||||
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژههای ما را ببینید."
|
||||
headingKey="timeline"
|
||||
align="center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={ref} className="relative mx-auto mt-20 max-w-4xl">
|
||||
<div ref={ref} className="relative mx-auto max-w-4xl">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-px bg-ivory/10 md:right-1/2"
|
||||
className="absolute inset-y-0 right-0 w-px bg-ivory/12 md:right-1/2"
|
||||
/>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-px origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2"
|
||||
className="absolute inset-y-0 right-0 w-px origin-top bg-copper md:right-1/2"
|
||||
style={{ scaleY }}
|
||||
/>
|
||||
|
||||
<ol className="flex flex-col gap-12 md:gap-16">
|
||||
{timeline.map((item, i) => (
|
||||
<Item key={item.id} item={item} index={i} />
|
||||
<Item key={item.year} item={item} index={i} />
|
||||
))}
|
||||
</ol>
|
||||
<div className="mt-12">
|
||||
<AddItemButton resource="timeline" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,72 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { SectionHeading } from '../SectionHeading';
|
||||
import { useSectionHeading, useSite } from '../../api';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { IMAGES } from '../../data/site';
|
||||
import { useSite } from '../../api';
|
||||
|
||||
export function WhyUs() {
|
||||
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 (
|
||||
<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="lg:col-span-5">
|
||||
<SectionHeading
|
||||
title={heading.title}
|
||||
lead={heading.lead}
|
||||
headingKey="why_us" />
|
||||
|
||||
{heading.image && (
|
||||
<Reveal delay={0.12}>
|
||||
<img
|
||||
src={heading.image}
|
||||
alt={heading.title}
|
||||
className="mt-10 h-80 w-full object-cover" />
|
||||
|
||||
</Reveal>
|
||||
)}
|
||||
title="چرا گروه ساختمانی آرامش"
|
||||
lead="شش دلیلی که کارفرمایان و سرمایهگذاران بار دوم هم به ما برمیگردند." />
|
||||
|
||||
<Reveal delay={0.12}>
|
||||
<img
|
||||
src={IMAGES[3]}
|
||||
alt="نمای شبانه برج دوقلوی نیاوران"
|
||||
className="mt-10 h-80 w-full object-cover" />
|
||||
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-7">
|
||||
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
|
||||
{why_us.map((item, i) =>
|
||||
<Reveal key={item.id} delay={0.05 * i}>
|
||||
<EditableItem resource="why_us" item={item}>
|
||||
<Reveal key={item.title} delay={0.05 * i}>
|
||||
<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>
|
||||
<h3 className="mt-4 text-lg font-bold text-ivory">
|
||||
<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>
|
||||
<h3 className="mt-4 text-lg font-bold text-ivory">{item.title}</h3>
|
||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{item.desc}</p>
|
||||
</div>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<AddItemButton resource="why_us" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
// آدرس بکاند — پیشفرض خالی یعنی درخواستهای هممبدأ (/api از طریق Traefik).
|
||||
// فقط اگر بکاند روی مبدأ دیگری سرو میشود VITE_API_URL را تنظیم کنید.
|
||||
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''
|
||||
// آدرس بکاند FastAPI — در محیط تولید مقدار VITE_API_URL را در فایل .env تنظیم کنید.
|
||||
export const API_URL: string =
|
||||
(import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8000'
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
// انواع مشترک دادهها — همه محتوای متنی سایت از بکاند (GET /api/site) بارگذاری میشود.
|
||||
export const BRAND = {
|
||||
name: 'گروه ساختمانی آرامش',
|
||||
logo: '/images/logo.png',
|
||||
hero: '/images/hero.png',
|
||||
contactBg: '/images/contact.png',
|
||||
phone: '۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰',
|
||||
phoneHref: 'tel:+982188742210',
|
||||
email: 'info@arameshbuildinggroup.com',
|
||||
address: 'تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم'
|
||||
};
|
||||
|
||||
export const IMAGES = [
|
||||
'/images/img1.png',
|
||||
'/images/img2.png',
|
||||
'/images/contact.png',
|
||||
'/images/img4.png',
|
||||
'/images/img5.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 = {
|
||||
id: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
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 Project = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
location: string;
|
||||
@@ -26,26 +70,77 @@ export type Project = {
|
||||
body: string[];
|
||||
};
|
||||
|
||||
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
|
||||
export type ArticleBodyDoc = { type: 'doc'; content: unknown[] };
|
||||
export const STATUS_LABEL: Record<ProjectStatus, string> = {
|
||||
done: 'انجام شده',
|
||||
progress: 'در حال انجام'
|
||||
};
|
||||
|
||||
export type ArticleStatus = 'draft' | 'published';
|
||||
export const PROJECTS: Project[] = [
|
||||
{ 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: '/images/before.webp',
|
||||
after: '/images/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 = {
|
||||
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;
|
||||
/** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */
|
||||
meta_description?: string;
|
||||
/** متن جایگزین تصویر شاخص (Alt) */
|
||||
image_alt?: string;
|
||||
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده میشود */
|
||||
author?: string;
|
||||
/** هشتگها/برچسبهای مقاله */
|
||||
tags?: string[];
|
||||
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string; body: 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: 'در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژهای نیز در جزیره کیش در دست ساخت است.' }
|
||||
];
|
||||
@@ -1,5 +1,3 @@
|
||||
/* @import url() FONT IMPORTS MUST ALWAYS BE AT THE VERY TOP OF THIS FILE, ABOVE THE TAILWIND IMPORTS — DO NOT DELETE THIS COMMENT */
|
||||
|
||||
/* CRITICAL: THE FOLLOWING TAILWIND IMPORTS MUST NEVER BE DELETED OR REORDERED — DO NOT DELETE THIS COMMENT */
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@@ -7,66 +5,13 @@
|
||||
|
||||
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-Light.woff2') format('woff2'),
|
||||
url('/fonts/pinar/Pinar-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-Regular.woff2') format('woff2'),
|
||||
url('/fonts/pinar/Pinar-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-Bold.woff2') format('woff2'),
|
||||
url('/fonts/pinar/Pinar-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-ExtraBold.woff2') format('woff2'),
|
||||
url('/fonts/pinar/Pinar-ExtraBold.ttf') format('truetype');
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-Black.woff2') format('woff2'),
|
||||
url('/fonts/pinar/Pinar-Black.ttf') format('truetype');
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Vazirmatn';
|
||||
src: url('/fonts/Vazirmatn-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Vazirmatn';
|
||||
src: url('/fonts/Vazirmatn-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Vazirmatn';
|
||||
src: url('/fonts/Vazirmatn-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
background-color: #27221c;
|
||||
color: #f5f3ef;
|
||||
font-family: 'Pinar', 'Vazirmatn', Tahoma, sans-serif;
|
||||
font-family: Tahoma, 'Segoe UI', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::selection {
|
||||
@@ -78,190 +23,3 @@ body {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- بدنه مقاله — رندر سند TipTap و ویرایشگر WYSIWYG ---------- */
|
||||
.article-body {
|
||||
line-height: 2.4;
|
||||
word-wrap: break-word;
|
||||
font-size: 14px; /* متن اصلی ۱۴ تنظیم شد */
|
||||
}
|
||||
.article-body p {
|
||||
margin-top: 1.75rem;
|
||||
color: rgba(245, 243, 239, 0.7);
|
||||
}
|
||||
.article-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.article-body h1,
|
||||
.article-body h2,
|
||||
.article-body h3 {
|
||||
color: #f5f3ef;
|
||||
font-weight: 700;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.9;
|
||||
/* فاصله از هدر چسبان هنگام پرش لنگرهای فهرست مطالب */
|
||||
scroll-margin-top: 7rem;
|
||||
}
|
||||
.article-body h1 {
|
||||
font-size: 18px; /* تیتر ۱ سایز ۱۸ */
|
||||
}
|
||||
.article-body h2 {
|
||||
font-size: 16px; /* تیتر ۲ سایز ۱۶ */
|
||||
}
|
||||
.article-body h3 {
|
||||
font-size: 14px; /* تیتر ۳ سایز ۱۴ */
|
||||
}
|
||||
.article-body span[data-small="true"] {
|
||||
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
|
||||
}
|
||||
/* مارک «متن انگلیسی» ویرایشگر — چپبهراست و ۱۲ پیکسل بدون بههمزدن جهت متن فارسی */
|
||||
.english-text {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
font-size: 12px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ---------- تقویم شمسی (react-multi-date-picker) — تم تیره با لهجه مسی ---------- */
|
||||
.aramesh-datepicker.rmdp-wrapper {
|
||||
width: max-content;
|
||||
border: 1px solid rgba(245, 243, 239, 0.12);
|
||||
border-radius: 0;
|
||||
background-color: #1e1a16;
|
||||
color: #f5f3ef;
|
||||
box-shadow: 0 24px 50px -18px rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
.aramesh-datepicker .rmdp-calendar {
|
||||
padding: 8px;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-header-values {
|
||||
color: #d5874c;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-arrow {
|
||||
border-color: #d5874c;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-arrow-container {
|
||||
box-shadow: none;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-arrow-container:hover {
|
||||
background-color: rgba(213, 135, 76, 0.2);
|
||||
box-shadow: none;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-week-day {
|
||||
color: rgba(245, 243, 239, 0.45);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day {
|
||||
color: rgba(245, 243, 239, 0.85);
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day span {
|
||||
border-radius: 0;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day:hover:not(.rmdp-disabled):not(.rmdp-selected) span {
|
||||
background-color: rgba(213, 135, 76, 0.16);
|
||||
color: #d5874c;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day.rmdp-selected span:not(.highlight) {
|
||||
background-color: #d5874c;
|
||||
color: #1e1a16;
|
||||
font-weight: 700;
|
||||
box-shadow: none;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day.rmdp-today span {
|
||||
background-color: rgba(213, 135, 76, 0.25);
|
||||
color: #f5f3ef;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day.rmdp-deactive {
|
||||
color: rgba(245, 243, 239, 0.22);
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day.rmdp-disabled {
|
||||
color: rgba(245, 243, 239, 0.12);
|
||||
}
|
||||
.aramesh-datepicker .rmdp-month-picker,
|
||||
.aramesh-datepicker .rmdp-year-picker {
|
||||
background-color: #1e1a16;
|
||||
border-radius: 0;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-panel {
|
||||
background-color: #1e1a16;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-panel-body li {
|
||||
background-color: rgba(213, 135, 76, 0.15);
|
||||
color: #f5f3ef;
|
||||
box-shadow: none;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-action-button {
|
||||
color: #d5874c;
|
||||
}
|
||||
@@ -6,13 +6,25 @@ import { Timeline } from '../components/home/Timeline'
|
||||
import { WhyUs } from '../components/home/WhyUs'
|
||||
import { Team } from '../components/home/Team'
|
||||
import { ContactSection } from '../components/ContactSection'
|
||||
import { IMAGES } from '../data/site'
|
||||
|
||||
export function About() {
|
||||
return (
|
||||
return (
|
||||
<>
|
||||
<PageHero pageKey="about" />
|
||||
|
||||
<Story showLink={false} showLabel={false} showQuote={true} alignTop={true} />
|
||||
<PageHero
|
||||
title="درباره ما"
|
||||
lead="گروه ساختمانی آرامش مجموعهای از معماران، مهندسان سازه و مدیران پروژه است که از سال ۱۳۸۳ در حوزه ساختوساز لوکس فعالیت میکند."
|
||||
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 />
|
||||
<Timeline />
|
||||
@@ -21,4 +33,4 @@ export function About() {
|
||||
<ContactSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||