Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
851e3fc2dc | ||
|
|
74770cd99d | ||
|
|
ce0735d775 | ||
|
|
805f4594d4 | ||
|
|
7a0defc3d4 | ||
|
|
fe6957bf83 | ||
|
|
049291c109 | ||
|
|
53673d0f9b | ||
|
|
9d55f521a0 | ||
|
|
35c03ef35e | ||
|
|
bb6c586d99 | ||
|
|
4fb57dfd67 | ||
|
|
6b49d035c2 | ||
|
|
6bb6226e06 | ||
|
|
9242b87677 | ||
|
|
20c95e73b3 | ||
|
|
5837e4b0d9 | ||
|
|
1fb5d57da1 | ||
|
|
8abc70dcf1 | ||
|
|
f2e5a9e58b | ||
|
|
5ae5421c5c | ||
|
|
c169703a17 | ||
|
|
07da45467b | ||
|
|
4b9cc13163 | ||
|
|
f1046a6598 | ||
|
|
5731eda438 | ||
|
|
17bddd3adf | ||
|
|
2ad3ad7367 | ||
|
|
b31124e1e4 | ||
|
|
a29deb8bb1 | ||
|
|
17345daa81 | ||
|
|
2c96562b66 | ||
|
|
70cf9616c1 | ||
|
|
cfe5512ec7 | ||
|
|
8f11df99ea | ||
|
|
b7d29d1028 | ||
|
|
5ed082631e | ||
|
|
6e422ec3dc | ||
|
|
bf3c67f405 | ||
|
|
3a7c7b0908 | ||
|
|
897fac4f15 | ||
|
|
1fde4e30fc |
@@ -0,0 +1,57 @@
|
||||
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
|
||||
# راه سادهتر: bash scripts/gen-env.sh — همهٔ رمزها را تصادفی و قوی میسازد.
|
||||
# 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"]
|
||||
|
||||
# --- بیلد فرانتاند (در زمان npm run build روی هاست خوانده میشود — stack.sh پاس میدهد) ---
|
||||
# اگر API روی مبدأ دیگری است (مثل توسعه ویندوز با پورت 19001) تنظیم کنید؛ هممبدأ یعنی خالی
|
||||
VITE_API_URL=
|
||||
# اسکریپت ردیاب Umami — خالی یعنی آنالیتیکس خاموش؛ در تولید مثل:
|
||||
# VITE_UMAMI_SRC=https://analytics.aramesh.example/script.js
|
||||
VITE_UMAMI_SRC=
|
||||
# شناسهٔ وبسایت از داشبورد Umami (اولین بار: وبسایت را در analytics.localhost بسازید)
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
|
||||
# --- چتبات (دستیار هوشمند) ---
|
||||
# LLM سازگار با OpenAI — اجباری برای پاسخگویی چتبات؛ خالی یعنی سرویس بالا میآید اما پاسخ نمیدهد
|
||||
LLM_BASE_URL=
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=openai/gpt-oss-120b
|
||||
# توکن محافظت از POST /chat/reindex — خالی یعنی فقط از شبکه داخلی
|
||||
CHAT_REINDEX_TOKEN=
|
||||
|
||||
# --- مانیتورینگ (docker-compose.monitoring.yml) ---
|
||||
GRAFANA_ADMIN_USER=admin
|
||||
GRAFANA_ADMIN_PASSWORD=change-me-grafana-password
|
||||
# هشدارها به بله — توکن بات (همان شکل تلگرام؛ از BotFather بله) و شناسه چت/گروه
|
||||
# شناسه چت: به بات یک پیام بدهید سپس:
|
||||
# curl "https://tapi.bale.ai/bot<توکن>/getUpdates"
|
||||
ALERT_BOT_TOKEN=
|
||||
ALERT_CHAT_ID=0
|
||||
|
||||
# --- عملیات ---
|
||||
# نگهداری پشتیبانهای شبانه (روز) — scripts/backup.sh
|
||||
RETENTION_DAYS=14
|
||||
# انتشار پشتیبان خارج از سرور (rclone remote مثل storage:aramesh-backups) — خالی یعنی خاموش
|
||||
OFFSITE_RCLONE_REMOTE=
|
||||
|
||||
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
|
||||
# DOMAIN=aramesh.example
|
||||
# ACME_EMAIL=admin@aramesh.example
|
||||
@@ -0,0 +1,7 @@
|
||||
# اسکریپتهای شل باید در لینوکس هم LF باشند — CRLF در bash میشکند
|
||||
*.sh text eol=lf
|
||||
*.yml text
|
||||
*.yaml text
|
||||
*.json text
|
||||
Dockerfile text
|
||||
*.py text
|
||||
@@ -0,0 +1,47 @@
|
||||
name: CI
|
||||
|
||||
# گیت سرور self-hosted شما (Gitea Actions / Forgejo) همین سینتکس را اجرا میکند؛
|
||||
# روی GitHub هم بیتغییر کار میکند.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install backend deps
|
||||
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: backend
|
||||
run: pytest -q
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Frontend typecheck/build
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build images
|
||||
run: docker compose -f docker-compose.yml build backend frontend chatbot
|
||||
+13
@@ -9,6 +9,15 @@ env/
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# مانیتورینگ — کانفیگ رندرشدهٔ alertmanager (حاوی توکن بله است)
|
||||
monitoring/alertmanager/alertmanager.rendered.yml
|
||||
|
||||
# تنظیمات محلی هر ماشین
|
||||
docker-compose.override.yml
|
||||
|
||||
# پشتیبانگیری محلی
|
||||
backups/
|
||||
|
||||
# Node / Vite
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -19,7 +28,11 @@ dist/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.zcode/
|
||||
|
||||
# Database
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# تصاویر محلی — از پنل/کنسول MinIO مدیریت میشوند
|
||||
frontend/public/images/
|
||||
|
||||
@@ -2,73 +2,294 @@
|
||||
|
||||
مونوریپوی سایت گروه ساختمانی آرامش شامل:
|
||||
|
||||
- **`frontend/`** — سایت React + Vite + Tailwind (همه محتوا از API خوانده میشود)
|
||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT
|
||||
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متنها و تصاویر از API خوانده میشود)
|
||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL (pgvector) + JWT + MinIO
|
||||
- **`chatbot/`** — دستیار هوشمند (FastAPI + LangGraph + RAG با pgvector، پاسخ استریمی)
|
||||
- **مانیتورینگ و آنالیتیکس** — Prometheus/Grafana/Loki و Umami بهصورت لایههای compose
|
||||
|
||||
## معماری
|
||||
|
||||
```
|
||||
Traefik (تنها نقطه ورود — 80/443 در تولید)
|
||||
├── / → Caddy (فایلهای استاتیک SPA)
|
||||
├── /api → FastAPI (بکاند) + متریک /metrics فقط داخلی
|
||||
├── /media → MinIO (باکت aramesh-media)
|
||||
├── /chat → چتبات (SSE استریم پاسخ)
|
||||
├── grafana.* → Grafana (مانیتورینگ — لایه monitoring)
|
||||
└── analytics.* → Umami (آنالیتیکس محصول)
|
||||
```
|
||||
|
||||
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانتاند هاردکد نشده؛
|
||||
با یک درخواست `GET /api/site` بارگذاری و از پنل مدیریت ویرایش میشود
|
||||
- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره میشوند و با مسیر نسبی
|
||||
`/media/...` برگردانده میشوند — بکاند ذخیرهسازی بدون تغییر محتوای دیتابیس قابل تعویض است
|
||||
- مسیریابی همهچیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
|
||||
- شبکههای compose تفکیک شدهاند: `edge` (سرویسهای پشت Traefik) و `internal`
|
||||
(دیتابیس/ذخیرهسازی) — فرانتاند هیچ راه مستقیمی به دیتابیس ندارد
|
||||
- **لاگها**: json-file با سقف ۱۰MB×۳ برای هر سرویس؛ علاوه بر آن Promtail همه را به
|
||||
Loki میفرستد تا در Grafana جستوجو شوند
|
||||
|
||||
## ساختار
|
||||
|
||||
```
|
||||
├── frontend/ # سایت React
|
||||
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
|
||||
│ ├── Dockerfile # بیلد Node + سرو با nginx
|
||||
│ └── nginx.conf # پروکسی /api به بکاند
|
||||
├── backend/ # بکاند FastAPI
|
||||
│ ├── app/ # کد منبع
|
||||
│ ├── requirements.txt
|
||||
│ ├── Dockerfile
|
||||
│ └── .env.example
|
||||
└── docker-compose.yml # اجرای کامل با یک دستور
|
||||
├── frontend/ # سایت React (بیلد روی هاست — ایمیج فقط سرو میکند)
|
||||
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
|
||||
│ ├── Caddyfile # سرو استاتیک + کش immutable برای /assets/*
|
||||
│ └── Dockerfile # Caddy — بدون root
|
||||
├── backend/ # بکاند FastAPI
|
||||
│ ├── app/ # کد منبع (آپلود: app/api/admin.py — سئو: app/seo_pages.py)
|
||||
│ ├── migrations/ # مهاجرتهای نسخهدار Alembic
|
||||
│ ├── tests/ # تستهای API (pytest)
|
||||
│ ├── alembic.ini
|
||||
│ └── Dockerfile # بدون root — خاموشی تمیز با exec
|
||||
├── chatbot/ # دستیار هوشمند (RAG + pgvector)
|
||||
├── monitoring/ # پیکربندی Prometheus/Alertmanager/Loki/Promtail/Grafana
|
||||
├── scripts/
|
||||
│ ├── stack.sh # ابزار اصلی عملیات — deploy/logs/health/backup/restore/update
|
||||
│ ├── backup.sh # پشتیبان کامل (DB + تصاویر) با چکسام و گزینه انتشار بیرونی
|
||||
│ ├── restore.sh # بازگردانی پشتیبان دیتابیس
|
||||
│ ├── gen-env.sh # ساخت .env با رمزهای قوی تصادفی
|
||||
│ ├── audit.sh # ممیزی آسیبپذیری وابستگیها و ایمیجها
|
||||
│ ├── migrate-media.py # انتقال یکباره تصاویر CDN خارجی به MinIO
|
||||
│ └── ci.sh # تست + بیلد ایمیج (محلی یا هر CI)
|
||||
├── docker-compose.yml # استک اصلی (شامل چتبات و Umami)
|
||||
├── docker-compose.monitoring.yml # لایه مانیتورینگ
|
||||
├── docker-compose.prod.yml # لایه تولید: TLS، دامنه، سختسازی پورتها
|
||||
└── docker-compose.override.yml # تنظیمات محلی این ماشین (در گیت نیست)
|
||||
```
|
||||
|
||||
## اجرا با Docker (سادهترین راه)
|
||||
## اجرا با Docker
|
||||
|
||||
پیشنیاز: Docker Desktop. بیلد فرانتاند روی هاست انجام میشود (کانتینر فقط فایلهای
|
||||
`dist/` را سرو میکند) — `scripts/stack.sh` همین را به یک دستور تبدیل میکند:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
cp .env.example .env # یا: bash scripts/gen-env.sh (رمزهای قوی تصادفی)
|
||||
cd frontend && npm install && cd ..
|
||||
bash scripts/stack.sh deploy # بیلد فرانت + بیلد و بالا آوردن استک + انتظار برای سلامت
|
||||
```
|
||||
|
||||
- سایت: http://localhost:5173
|
||||
- مستندات API: http://localhost:8000/docs
|
||||
- دیتابیس PostgreSQL در کانتینر جدا (دادهها در volume ذخیره میشوند)
|
||||
- بکاند هنگام شروع، خودش جداول و دادههای اولیه سایت را seed میکند
|
||||
- nginx درخواستهای `/api` را خودش به بکاند پروکسی میکند — نیازی به تنظیم CORS نیست
|
||||
- سایت: http://localhost:18081 (پورت از `TRAEFIK_HTTP_PORT` در `.env`) — ورود مدیریت: `/admin/login`
|
||||
- دستورهای عملیات: `bash scripts/stack.sh` — `logs`، `ps`، `health`، `backup`، `restore`، `update`، `seed-force`
|
||||
- مستندات API: http://localhost:19001/docs (با `docker-compose.override.yml` محلی)
|
||||
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env`
|
||||
- دیتابیس PostgreSQL (تصویر pgvector)، آبجکتاستور MinIO و کش مدلهای چتبات هر یک
|
||||
در کانتینر جدا با volume دائمی
|
||||
- بکاند هنگام شروع، جداول پایه را میسازد، **مهاجرتهای Alembic** را اعمال میکند و
|
||||
**فقط جدولهای خالی** را seed میکند — ارتقای نسخه دادههای فعلی را از بین نمیبرد
|
||||
- `minio-setup` باکت `aramesh-media` را میسازد و فقط **وقتی باکت خالی است** تصاویر
|
||||
`seed-media/` را میریزد — `db-init` هم دیتابیسهای `umami` و `aramesh_chatbot` را
|
||||
روی همان نمونهٔ Postgres میسازد
|
||||
|
||||
## اجرای محلی (توسعه)
|
||||
> نکته برای تازهواردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
|
||||
> فایلهای `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
|
||||
>
|
||||
> پایهٔ API فرانتاند در زمان بیلد از `VITE_API_URL` در `.env` خوانده میشود —
|
||||
> در توسعه ویندوزیِ این ماشین `VITE_API_URL=http://localhost:19001` بگذارید.
|
||||
|
||||
### بکاند
|
||||
## انتشار واقعی (TLS)
|
||||
|
||||
پیشنیاز: رکوردهای DNS (`example.com`، `analytics.example.com` و اگر مانیتورینگ فعال
|
||||
است `grafana.example.com`) به این سرور، سپس در `.env`: `DOMAIN`، `ACME_EMAIL` و
|
||||
`SITE_URL=https://example.com`:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate # ویندوز — در لینوکس: source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# فایل .env از روی .env.example بسازید و DATABASE_URL را تنظیم کنید
|
||||
python -m app.seed # ساخت جداول + محتوای اولیه
|
||||
uvicorn app.main:app --reload
|
||||
bash scripts/stack.sh deploy-prod
|
||||
```
|
||||
|
||||
### فرانتاند
|
||||
- Traefik گواهی Let's Encrypt میگیرد و تمدید میکند؛ همه درخواستهای HTTP به HTTPS هدایت میشوند
|
||||
- **پورتهای مدیریتی MinIO (۹۰۰۱ کنسول و ۱۹۰۰۰ API) در تولید فقط روی `127.0.0.1`
|
||||
بسته میشوند** — دسترسی از راه دور فقط با تونل SSH:
|
||||
`ssh -L 9001:127.0.0.1:9001 server`
|
||||
- همه روترهای websecure هدرهای امنیتی میگیرند (HSTS + nosniff)
|
||||
|
||||
### چکلیست رفتن به تولید
|
||||
|
||||
1. `bash scripts/gen-env.sh` — رمزهای قوی جدید (رمزهای `.env` توسعه را کپی نکنید)
|
||||
2. DNS: رکوردهای دامنه و زیردامنهها + باز بودن پورتهای ۸۰/۴۴۳ در فایروال
|
||||
3. `DOMAIN`، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید
|
||||
4. اولین انتشار را روی یک زیردامنه آزمایشی انجام دهید (نقش staging) — `deploy-prod`
|
||||
5. `python scripts/migrate-media.py` — انتقال یکباره تصاویر CDN خارجی به MinIO
|
||||
6. پشتیبانگیری خودکار: `docker compose --profile ops up -d db-backup` (پشتیبان ساعتی)
|
||||
+ `scripts/backup.sh` شبانه با cron/Task Scheduler
|
||||
7. یک تمرین بازگردانی: `bash scripts/restore.sh backups/db-<زمان>.sql.gz` روی محیط آزمایشی
|
||||
8. مانیتورینگ (بخش پایین) و سپس چتبات/آنالیتیکس را با متغیرهای LLM و Umami فعال کنید
|
||||
|
||||
## مانیتورینگ (Prometheus + Grafana + Loki)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
bash scripts/stack.sh monitoring
|
||||
# در تولید (سه فایل با هم):
|
||||
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
آدرس بکاند در `frontend/src/config.ts` از `VITE_API_URL` خوانده میشود؛
|
||||
اگر تنظیم نشود پیشفرض `http://localhost:8000` است.
|
||||
- داشبورد: `grafana.localhost` (توسعه) یا `grafana.example.com` (تولید — برچسبهای
|
||||
روتر آن را در `docker-compose.override.yml` سرور بگذارید؛ نمونه در انتهای
|
||||
`docker-compose.prod.yml`) — کاربر/رمز از `GRAFANA_ADMIN_*` در `.env`
|
||||
- دو داشبورد آمادهٔ «زیرساخت» و «اپلیکیشن» بهصورت code-provisioned سوار میشوند
|
||||
- **هشدارها به بله**: در `.env` مقدار `ALERT_BOT_TOKEN` (بات بله — همان شکل API تلگرام،
|
||||
از BotFather بله) و `ALERT_CHAT_ID` را بگذارید (شناسه چت: به بات یک پیام بدهید سپس
|
||||
`curl "https://tapi.bale.ai/bot<توکن>/getUpdates"`) — قواعد آماده در
|
||||
`monitoring/prometheus/alerts.yml`:
|
||||
پایینبودن هر سرویس، شکست بررسی عمق سلامت، پرشدن دیسک، نرخ خطای 5xx، انقضای گواهی TLS،
|
||||
**کهنهشدن پشتیبان** (سنجهٔ `backup_success_time` از `scripts/backup.sh`) و...
|
||||
- **لاگها در Grafana**: Promtail لاگ همهٔ کانتینرهای استک را به Loki میفرستد —
|
||||
برای خطاهای بکاند از داشبورد «اپلیکیشن» پنل لاگ را ببینید
|
||||
- **سلامت واقعی**: `/api/health` اتصال DB و MinIO را واقعاً میآزماید (503 در خرابی)؛
|
||||
پروب blackbox هم همین را از بیرون و نشانی `SITE_URL` را عمومی چک میکند
|
||||
- مصرف منابع مجموع لایه حدود ۱ تا ۱.۵ گیگابایت RAM است؛ حذف هر سرویس بقیه را نمیشکند
|
||||
|
||||
## آنالیتیکس محصول (Umami)
|
||||
|
||||
استک شامل سرویس Umami است؛ داشبورد: `analytics.localhost` (توسعه) یا
|
||||
`analytics.example.com` (تولید). بدون کوکی — بدون نیاز به بنر رضایت.
|
||||
|
||||
راهاندازی (یکبار):
|
||||
|
||||
1. استک را بالا بیاورید و به `analytics.localhost` وارد شوید (پیشفرض `admin`/`umami`
|
||||
— بلافاصله رمز را عوض کنید)
|
||||
2. یک وبسایت با دامنهٔ سایت بسازید؛ «شناسه وبسایت» را در `.env` بگذارید:
|
||||
`VITE_UMAMI_WEBSITE_ID=...` و آدرس اسکریپت: `VITE_UMAMI_SRC=https://analytics.example.com/script.js`
|
||||
3. `bash scripts/stack.sh deploy` — اسکریپت ردیاب در `index.html` تزریق میشود
|
||||
|
||||
رخدادهای سفارشی که خودکار ارسال میشوند: ارسال فرم تماس (`contact-submit`)، عضویت
|
||||
خبرنامه (`newsletter-signup`)، کلیک تلفن (`phone-click`)، باز کردن چتبات
|
||||
(`chat-open`)، پرسش از چتبات (`chat-question`)، تغییر زبان (`lang-switch`) —
|
||||
سرچشمه: `frontend/src/analytics.ts`.
|
||||
|
||||
## چتبات (دستیار هوشمند)
|
||||
|
||||
- RAG روی محتوای همان دیتابیس سایت: امبدینگ MiniLM چندزبانه + BM25 + رنکر — بردارها
|
||||
در دیتابیس جدا `aramesh_chatbot` (pgvector) روی همان نمونهٔ Postgres
|
||||
- مسیرها: `GET /chat/healthz`، `GET /chat/suggestions`، `GET /chat/stream` (SSE) —
|
||||
رابط کاربری: ویجت شناور پایین صفحه (`ChatWidget.tsx`)
|
||||
- راهاندازی: `LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL` در `.env` (هر LLM سازگار با
|
||||
OpenAI) — بدون آن سرویس بالا میآید اما پاسخ نمیدهد
|
||||
- اولین اجرا مدلها را دانلود میکند (~۵۰۰MB در حجم `chatbot_hf`) — کند است، طبیعی است
|
||||
- ایندکس هر ۱۰ دقیقه خودش با تغییرهای محتوا همگام میشود؛ همگامسازی فوری با
|
||||
`POST /chat/reindex` و هدر `x-reindex-token` (از `CHAT_REINDEX_TOKEN`)
|
||||
|
||||
## امکانات بکاند
|
||||
|
||||
- **API عمومی**: برند، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم، تایملاین، آمار و...
|
||||
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
|
||||
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`) و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
||||
- همه محتوا در یک درخواست: `GET /api/site`
|
||||
- **API عمومی**: برند، منو، هیرو، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم،
|
||||
تایملاین، آمار، دفاتر، مدلهای سرمایهگذاری و... — همه در دیتابیس
|
||||
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter` — هر دو با
|
||||
محدودسازی نرخ (تماس: ۵ در ۱۵ دقیقه، خبرنامه: ۳ در ساعت برای هر IP) + دامنهٔ پنهان
|
||||
(honeypot) ضدربات + میانافزار محدودسازی سیلاب روی Traefik
|
||||
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (مقادیر از `ADMIN_*` در `.env` —
|
||||
هیچ رمز پیشفرضی در کد وجود ندارد) و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
||||
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
|
||||
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد میشود
|
||||
(IP واقعی کلاینت با `--proxy-headers` از پشت Traefik درست خوانده میشود)
|
||||
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
|
||||
ذخیره و URL نسبی `/media/...` برمیگردد؛ در همان لحظه تا ۲۰۰۰ پیکسل کوچک و دوباره
|
||||
فشرده میشود (Pillow) تا سرعت صفحه حفظ شود
|
||||
- **خروجی CSV** از نوار مدیریت: پیامهای تماس و مشترکان خبرنامه (با BOM برای Excel)
|
||||
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسالشده عوض میشوند
|
||||
- **ترتیببندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
|
||||
- همه محتوا در یک درخواست: `GET /api/site` (مقالههای پیشنویس در آن نمیآیند)
|
||||
- **مهاجرتهای اسکیما**: Alembic (`backend/migrations/`) — هر تغییر اسکیمای جدید باید
|
||||
revision شود: `cd backend && alembic revision --autogenerate -m "..."` و پس از بازبینی
|
||||
commit؛ در استارت کانتینر خودکار `upgrade head` اجرا میشود
|
||||
- **تستها**: `cd backend && .venv/Scripts/activate && pytest tests` (۲۴ تست — شامل
|
||||
honeypot، محدودسازی نرخ و پاکسازی)
|
||||
|
||||
> در محیط واقعی حتماً `SECRET_KEY` و `ADMIN_PASSWORD` را تغییر دهید.
|
||||
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را قوی
|
||||
> بگیرید (`bash scripts/gen-env.sh`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
|
||||
|
||||
## ویرایش زنده سایت (دوقلوی دیجیتال)
|
||||
|
||||
ادمینها نیازی به داشبورد جدا ندارند — خود سایت ویرایشگر است:
|
||||
|
||||
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 تبدیل میکند، پس فقط تگهای شناختهشده
|
||||
(پاراگراف، تیتر، لیست، نقلقول، تصویر، لینک) تولید میشوند. مقالات قدیمی (لیست پاراگراف)
|
||||
در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند. در زمان ذخیره، آدرسهای
|
||||
`href/src` سند در سمت سرور پاکسازی میشوند (`app/sanitize.py`) تا اسکیمهای خطرناک
|
||||
مثل `javascript:` هرگز ذخیره نشوند.
|
||||
|
||||
## سئو
|
||||
|
||||
- **بررسی سئو در صفحه نوشتن**: فهرست سبز/نارنجی/قرمز به سبک یوست ولی سازگار با فارسی —
|
||||
طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیترها)، طول چکیده، تعداد واژه،
|
||||
تیتربندی 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/stack.sh backup # پشتیبان کامل: DB + تصاویر MinIO
|
||||
docker compose --profile ops up -d db-backup # پشتیبان ساعتی خودکار DB (بدون cron میزبان)
|
||||
```
|
||||
|
||||
- خروجی: `backups/db-<زمان>.sql.gz` (فشرده + فایل `.sha256`) و `backups/images-<زمان>/`
|
||||
- اسکریپت پشتیبان، خروجی خالی/شکسته را خطا میگیرد، سنجهٔ تازگی برای هشدار Grafana
|
||||
مینویسد و اگر `OFFSITE_RCLONE_REMOTE` در `.env` تنظیم باشد، پشتیبان را به حافظهٔ
|
||||
دیگری هم کپی میکند (rclone)
|
||||
- **بازگردانی**: `bash scripts/stack.sh restore backups/db-<زمان>.sql.gz`
|
||||
(احتیاط: محتوای فعلی جایگزین میشود — قبل از انتشار واقعی یکبار تمرین کنید!)
|
||||
- زمانبندی پشتیبان کامل با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
|
||||
|
||||
## تست و CI
|
||||
|
||||
- `bash scripts/ci.sh` — تستهای pytest + بیلد ایمیجها + ممیزی وابستگیها
|
||||
- `bash scripts/audit.sh` — pip-audit / npm audit / trivy (هر کدام نصب بود)
|
||||
- `.github/workflows/ci.yml` — همین مراحل؛ سینتکس GitHub Actions روی Gitea Actions/
|
||||
Forgejo هم اجرا میشود
|
||||
|
||||
## نکات امنیتی
|
||||
|
||||
- فایل `.env` هرگز commit نمیشود (در `.gitignore` است)
|
||||
- رمز عبور ادمین با bcrypt هش میشود
|
||||
- توکنهای JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
|
||||
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در compose با سینتکس
|
||||
`${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید؛ هیچ رمز پیشفرضی در کد نیست
|
||||
- رمز عبور ادمین با bcrypt هش میشود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
|
||||
- توکنهای JWT ۸ ساعت اعتبار دارند (stateless — تغییر `SECRET_KEY` همه را خارج میکند)
|
||||
- همهٔ کانتینرها **بدون root** اجرا میشوند؛ کانتکست بیلد هر ایمیج با `.dockerignore`
|
||||
باریک است و لاگها سقف دارند
|
||||
- فرمهای عمومی ضدرباتاند (honeypot + محدودسازی نرخ در بکاند + سقف سیلاب در Traefik)
|
||||
- کنسول MinIO و API مستقیمش در تولید فقط از `127.0.0.1` در دسترساند (تونل SSH)
|
||||
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
|
||||
- مقالههای پیشنویس در هیچ انتهای عمومی نمیآیند و در HTML تزریقشده سئو هم افشا نمیشوند
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# کانتکست بیلد باریک — .venv و کشهای تست محلی جایی در تصویر ندارند
|
||||
.venv/
|
||||
venv/
|
||||
tests/
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
README.md
|
||||
.env
|
||||
.env.*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -15,3 +15,15 @@ CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
|
||||
# مشخصات ادمین پیشفرض که هنگام seed ساخته میشود
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=aramesh123
|
||||
|
||||
# Object Storage (MinIO) برای آپلود تصاویر پنل مدیریت
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY=aramesh
|
||||
S3_SECRET_KEY=change-me-minio-secret
|
||||
S3_BUCKET=aramesh-media
|
||||
|
||||
# پیشوند عمومی مسیر تصاویر — پروکسی Reverse باید آن را به باکت برساند
|
||||
MEDIA_PUBLIC_BASE=/media
|
||||
|
||||
# حداکثر حجم آپلود تصویر (مگابایت)
|
||||
MEDIA_MAX_UPLOAD_MB=10
|
||||
|
||||
+12
-3
@@ -1,3 +1,4 @@
|
||||
# بکاند FastAPI — تصویر باریک و بدون root
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
@@ -9,8 +10,16 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY alembic.ini ./
|
||||
COPY migrations ./migrations
|
||||
|
||||
# کاربر بدون دسترسی root — برنامه چیزی روی دیسک محلی نمینویسد
|
||||
RUN useradd --system --create-home appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# ساخت جداول و seed (بدون حذف دادههای موجود) و سپس اجرای سرور
|
||||
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||
# ساخت جداول پایه، مهاجرتهای Alembic و seed (بدون حذف دادههای موجود) و سپس
|
||||
# اجرای سرور با exec — تا SIGTERM (docker stop) مستقیم به uvicorn برسد و خاموشی
|
||||
# تمیز انجام شود؛ --proxy-headers برای دیدن IP واقعی کلاینت پشت Traefik
|
||||
CMD ["sh", "-c", "python -m app.db_init && alembic upgrade head && python -m app.seed && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='*'"]
|
||||
|
||||
+42
-7
@@ -1,14 +1,15 @@
|
||||
# بکاند گروه ساختمانی آرامش (Aramesh Building Group API)
|
||||
|
||||
بکاند سایت گروه ساختمانی آرامش — ساختهشده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT**.
|
||||
بکاند سایت گروه ساختمانی آرامش — ساختهشده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT** + **MinIO** (ذخیره تصاویر) + **Traefik** (مسیریابی).
|
||||
|
||||
## امکانات
|
||||
|
||||
- **API عمومی محتوا**: برند، خدمات، پروژهها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایملاین، آمار، چرا آرامش، و بخش قبل/بعد
|
||||
- **API عمومی محتوا**: برند، منو، هیرو، داستان، تیتر بخشها، هیرو صفحات، خدمات، پروژهها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایملاین، آمار، چرا آرامش، قبل/بعد، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، فرم تماس، خبرنامه و فوتر — **تمام متنهای سایت در دیتابیس است**
|
||||
- **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`)
|
||||
- **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`)
|
||||
- **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیامهای تماس و مشترکین
|
||||
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار میگیرد
|
||||
- **آپلود تصویر**: `POST /api/admin/upload` — فایل در MinIO ذخیره و URL نسبی `/media/...` برمیگردد
|
||||
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار میگیرد (فقط جدولهای خالی پر میشوند — ارتقا بدون از دست رفتن داده)
|
||||
- مستندات تعاملی Swagger در مسیر `/docs`
|
||||
|
||||
## راهاندازی
|
||||
@@ -85,6 +86,13 @@ Authorization: Bearer <access_token>
|
||||
|
||||
> نام کاربری و رمز عبور پیشفرض از تنظیمات `.env` خوانده میشود — حتماً در محیط تولید تغییرشان دهید.
|
||||
|
||||
تغییر رمز (با توکن):
|
||||
|
||||
```
|
||||
POST /api/auth/change-password
|
||||
{ "current_password": "...", "new_password": "..." }
|
||||
```
|
||||
|
||||
## فهرست انتهاپوینتها
|
||||
|
||||
### عمومی (بدون نیاز به توکن)
|
||||
@@ -96,8 +104,8 @@ Authorization: Bearer <access_token>
|
||||
| GET | `/api/services` | خدمات |
|
||||
| GET | `/api/projects` | همه پروژهها |
|
||||
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
|
||||
| GET | `/api/articles` | همه مقالات |
|
||||
| GET | `/api/articles/{slug}` | جزئیات یک مقاله |
|
||||
| GET | `/api/articles` | مقالههای منتشرشده (پیشنویس نمیآید) |
|
||||
| GET | `/api/articles/{slug}` | جزئیات یک مقاله منتشرشده |
|
||||
| GET | `/api/testimonials` | نظرات مشتریان |
|
||||
| GET | `/api/faqs` | سوالات متداول |
|
||||
| GET | `/api/team` | اعضای تیم |
|
||||
@@ -108,7 +116,11 @@ Authorization: Bearer <access_token>
|
||||
| GET | `/api/site` | **همه محتوا در یک درخواست** |
|
||||
| POST | `/api/contact` | ثبت پیام تماس |
|
||||
| POST | `/api/newsletter` | عضویت در خبرنامه |
|
||||
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن |
|
||||
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن — بعد از ۵ تلاش ناموفق در ۱۵ دقیقه، 429 |
|
||||
| GET | `/robots.txt` | قوانین خزندهها |
|
||||
| GET | `/sitemap.xml` | نقشه سایت — مقاله/پروژههای منتشرشده |
|
||||
| GET | `/blog/{slug}` | HTML صفحه مقاله با متای سئوی تزریقشده |
|
||||
| GET | `/projects/{slug}` | HTML صفحه پروژه با متای سئوی تزریقشده |
|
||||
|
||||
### مدیریت (نیاز به توکن Bearer)
|
||||
|
||||
@@ -118,12 +130,35 @@ Authorization: Bearer <access_token>
|
||||
| متد | مسیر | توضیح |
|
||||
|---|---|---|
|
||||
| GET/POST | `/api/admin/{resource}` | لیست / ساخت |
|
||||
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش / حذف |
|
||||
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش کامل / حذف |
|
||||
| PATCH | `/api/admin/{resource}/{id}` | **ویرایش جزئی** — فقط فیلدهای ارسالشده بهروزرسانی میشوند |
|
||||
| POST | `/api/admin/{resource}/reorder` | ذخیره ترتیب: `{"ids": [3, 1, 2, ...]}` |
|
||||
| GET | `/api/admin/messages` | پیامهای تماس (با `?unread_only=true` فقط خواندهنشدهها) |
|
||||
| PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خواندهشدن |
|
||||
| DELETE | `/api/admin/messages/{id}` | حذف پیام |
|
||||
| GET | `/api/admin/subscribers` | مشترکین خبرنامه |
|
||||
| DELETE | `/api/admin/subscribers/{id}` | حذف مشترک |
|
||||
| POST | `/api/admin/upload` | آپلود تصویر (multipart، حداکثر ۱۰MB — jpg/png/webp/gif/avif) → `{"url": "/media/..."}` |
|
||||
|
||||
### بدنه مقاله — سند TipTap
|
||||
|
||||
`body` مقاله یک سند ساختاریافته TipTap (ProseMirror JSON) است: `{"type": "doc", "content": [...]}`.
|
||||
- فقط ساختار اعتبارسنجی میشود (`type: "doc"` + `content` لیست)؛ امنیت رندر بر عهده
|
||||
رندر استاتیک TipTap در فرانتاند است که فقط تگهای شناختهشده تولید میکند
|
||||
- ردیفهای قدیمی (لیست پاراگراف) با مهاجرت idempotent `migrate_article_bodies`
|
||||
در startup/seed خودکار به سند تبدیل میشوند
|
||||
- seed هم بدنههای لیستی را قبل از درج تبدیل میکند
|
||||
|
||||
| POST/PATCH | `/api/admin/seo-meta` | عنوان و توضیح متای صفحات — `page_key`: home / about / services / projects / investment / blog / faq / contact |
|
||||
|
||||
منابعی که `reorder` دارند (ستون `position`): `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `investment-models`، `process-steps`، `offices`، `gallery`، `nav`. ستون با مهاجرت سبک idempotent هنگام startup/seed به دیتابیسهای موجود اضافه میشود.
|
||||
|
||||
### تصاویر و MinIO
|
||||
|
||||
فایلهای آپلودشده در باکت `aramesh-media` روی MinIO ذخیره میشوند و با مسیر نسبی `/media/<سال>/<ماه>/<فایل>` برگردانده میشوند. Traefik مسیر `/media` را به MinIO میبرد، پس URLها مستقل از بکاند ذخیرهسازی هستند — بعداً برای مهاجرت به R2/S3 فقط تنظیمات را عوض کنید، نه محتوای دیتابیس را.
|
||||
|
||||
- کنسول MinIO: `http://localhost:9001` (کاربر/رمز از `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` در compose)
|
||||
- تصاویر عمومی و قابل نمایشاند؛ آپلود فقط با توکن ادمین انجام میشود.
|
||||
|
||||
## اتصال به فرانتاند
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
|
||||
# آدرس دیتابیس از DATABASE_URL (محیط) یا تنظیمات برنامه خوانده میشود — env.py
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
+286
-17
@@ -1,40 +1,106 @@
|
||||
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import csv
|
||||
import io
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import boto3
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status
|
||||
from PIL import Image, ImageOps
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models, schemas
|
||||
from ..auth import create_access_token, get_current_admin, verify_password
|
||||
from ..auth import create_access_token, get_current_admin, hash_password, verify_password
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..rate_limit import clear as clear_rate_limit
|
||||
from ..rate_limit import ensure_allowed, record_failure
|
||||
from ..schemas import (
|
||||
ArticleIn,
|
||||
ArticleOut,
|
||||
ArticlePatch,
|
||||
BeforeAfterIn,
|
||||
BeforeAfterOut,
|
||||
BeforeAfterPatch,
|
||||
BrandIn,
|
||||
BrandOut,
|
||||
BrandPatch,
|
||||
ChangePasswordIn,
|
||||
ContactSectionIn,
|
||||
ContactSectionOut,
|
||||
ContactSectionPatch,
|
||||
ContactMessageOut,
|
||||
FaqIn,
|
||||
FaqOut,
|
||||
FaqPatch,
|
||||
FooterIn,
|
||||
FooterOut,
|
||||
FooterPatch,
|
||||
GalleryImageIn,
|
||||
GalleryImageOut,
|
||||
GalleryImagePatch,
|
||||
HeroIn,
|
||||
HeroOut,
|
||||
HeroPatch,
|
||||
InvestmentModelIn,
|
||||
InvestmentModelOut,
|
||||
InvestmentModelPatch,
|
||||
LoginIn,
|
||||
MessageReadIn,
|
||||
NavItemIn,
|
||||
NavItemOut,
|
||||
NavItemPatch,
|
||||
NewsletterSectionIn,
|
||||
NewsletterOut,
|
||||
NewsletterSectionPatch,
|
||||
OfficeIn,
|
||||
OfficeOut,
|
||||
OfficePatch,
|
||||
PageHeroIn,
|
||||
PageHeroOut,
|
||||
PageHeroPatch,
|
||||
ProcessStepIn,
|
||||
ProcessStepOut,
|
||||
ProcessStepPatch,
|
||||
ProjectIn,
|
||||
ProjectOut,
|
||||
ProjectPatch,
|
||||
ReorderIn,
|
||||
SeoMetaIn,
|
||||
SeoMetaOut,
|
||||
SeoMetaPatch,
|
||||
SectionHeadingIn,
|
||||
SectionHeadingOut,
|
||||
SectionHeadingPatch,
|
||||
ServiceIn,
|
||||
ServiceOut,
|
||||
ServicePatch,
|
||||
StatIn,
|
||||
StatOut,
|
||||
StatPatch,
|
||||
StatusLabelIn,
|
||||
StatusLabelOut,
|
||||
StatusLabelPatch,
|
||||
StoryIn,
|
||||
StoryOut,
|
||||
StoryPatch,
|
||||
SubscriberOut,
|
||||
TeamMemberIn,
|
||||
TeamMemberOut,
|
||||
TeamMemberPatch,
|
||||
TestimonialIn,
|
||||
TestimonialOut,
|
||||
TestimonialPatch,
|
||||
TimelineItemIn,
|
||||
TimelineItemOut,
|
||||
TimelineItemPatch,
|
||||
TokenOut,
|
||||
WhyUsItemIn,
|
||||
WhyUsItemOut,
|
||||
WhyUsItemPatch,
|
||||
)
|
||||
|
||||
# روتر عمومی برای ورود — بدون نیاز به توکن
|
||||
@@ -45,26 +111,52 @@ router = APIRouter(dependencies=[Depends(get_current_admin)])
|
||||
|
||||
|
||||
@auth_router.post("/auth/login", response_model=TokenOut)
|
||||
def login(payload: LoginIn, db: Session = Depends(get_db)):
|
||||
def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
|
||||
ensure_allowed(request)
|
||||
user = db.query(models.AdminUser).filter(models.AdminUser.username == payload.username).first()
|
||||
if user is None or not verify_password(payload.password, user.password_hash):
|
||||
record_failure(request)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="نام کاربری یا رمز عبور اشتباه است",
|
||||
)
|
||||
clear_rate_limit(request)
|
||||
return TokenOut(access_token=create_access_token(user.username))
|
||||
|
||||
|
||||
def register_crud(path: str, model, out_schema, create_schema) -> None:
|
||||
"""ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت."""
|
||||
@auth_router.post("/auth/change-password")
|
||||
def change_password(
|
||||
payload: ChangePasswordIn,
|
||||
admin: models.AdminUser = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""تغییر رمز ادمین واردشده — رمز فعلی باید درست باشد."""
|
||||
if not verify_password(payload.current_password, admin.password_hash):
|
||||
raise HTTPException(status_code=400, detail="رمز عبور فعلی اشتباه است")
|
||||
admin.password_hash = hash_password(payload.new_password)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
|
||||
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
||||
حذف و ترتیببندی) روی روتر مدیریت."""
|
||||
|
||||
@router.get(f"/{path}", response_model=list[out_schema])
|
||||
def list_items(db: Session = Depends(get_db)):
|
||||
return db.query(model).order_by(model.id).all()
|
||||
query = db.query(model)
|
||||
if hasattr(model, "position"):
|
||||
query = query.order_by(model.position, model.id)
|
||||
else:
|
||||
query = query.order_by(model.id)
|
||||
return query.all()
|
||||
|
||||
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
||||
def create_item(payload: create_schema, db: Session = Depends(get_db)):
|
||||
item = model(**payload.model_dump())
|
||||
if hasattr(model, "position"):
|
||||
# آیتم جدید در انتهای لیست قرار میگیرد
|
||||
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
|
||||
db.add(item)
|
||||
try:
|
||||
db.commit()
|
||||
@@ -96,6 +188,38 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
if patch_schema is not None:
|
||||
|
||||
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
|
||||
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)):
|
||||
"""ویرایش جزئی — فقط فیلدهای ارسالشده بهروزرسانی میشوند (برای ویرایش زنده)."""
|
||||
item = db.get(model, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(item, key, value)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
@router.post(f"/{path}/reorder", response_model=list[out_schema])
|
||||
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)):
|
||||
"""ذخیره ترتیب جدید آیتمها بر اساس لیست idهای ارسالی."""
|
||||
if not hasattr(model, "position"):
|
||||
raise HTTPException(status_code=400, detail="این منبع از ترتیببندی پشتیبانی نمیکند")
|
||||
items = db.query(model).filter(model.id.in_(payload.ids)).all()
|
||||
if len(items) != len(payload.ids):
|
||||
raise HTTPException(status_code=404, detail="برخی رکوردها یافت نشدند")
|
||||
by_id = {item.id: item for item in items}
|
||||
for pos, item_id in enumerate(payload.ids):
|
||||
by_id[item_id].position = pos
|
||||
db.commit()
|
||||
return db.query(model).order_by(model.position, model.id).all()
|
||||
|
||||
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
||||
def delete_item(item_id: int, db: Session = Depends(get_db)):
|
||||
item = db.get(model, item_id)
|
||||
@@ -106,17 +230,162 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
|
||||
|
||||
|
||||
# CRUD محتوا
|
||||
register_crud("brand", models.Brand, BrandOut, BrandIn)
|
||||
register_crud("services", models.Service, ServiceOut, ServiceIn)
|
||||
register_crud("projects", models.Project, ProjectOut, ProjectIn)
|
||||
register_crud("articles", models.Article, ArticleOut, ArticleIn)
|
||||
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn)
|
||||
register_crud("faqs", models.Faq, FaqOut, FaqIn)
|
||||
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn)
|
||||
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn)
|
||||
register_crud("stats", models.Stat, StatOut, StatIn)
|
||||
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn)
|
||||
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn)
|
||||
register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch)
|
||||
register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch)
|
||||
register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch)
|
||||
register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch)
|
||||
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch)
|
||||
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
|
||||
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
|
||||
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
|
||||
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch)
|
||||
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
|
||||
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
|
||||
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
|
||||
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn, TimelineItemPatch)
|
||||
register_crud("stats", models.Stat, StatOut, StatIn, StatPatch)
|
||||
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn, WhyUsItemPatch)
|
||||
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn, BeforeAfterPatch)
|
||||
register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn, InvestmentModelPatch)
|
||||
register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn, ProcessStepPatch)
|
||||
register_crud("offices", models.Office, OfficeOut, OfficeIn, OfficePatch)
|
||||
register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn, GalleryImagePatch)
|
||||
register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn, StatusLabelPatch)
|
||||
register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch)
|
||||
register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch)
|
||||
register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
|
||||
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
|
||||
|
||||
|
||||
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمیگرداند تا بکاند ذخیرهسازی قابل تعویض باشد
|
||||
ALLOWED_IMAGE_TYPES = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"image/avif": "avif",
|
||||
}
|
||||
|
||||
# بهینهسازی در زمان آپلود — ابعاد بزرگ و فشردهسازی ضعیف، اصلیترین عامل کندی سایت است
|
||||
MAX_DIMENSION = 2000
|
||||
REENCODE_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
|
||||
|
||||
def _optimize_image(data: bytes, content_type: str) -> tuple[bytes, str, int, int]:
|
||||
"""تصویر را به حداکثر ۲۰۰۰ پیکسل محدود و دوباره فشرده میکند؛
|
||||
اگر خروجی بزرگتر از اصلی شد، همان اصلی برگردد. (gif/avif دستنخورده)"""
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img = ImageOps.exif_transpose(img)
|
||||
orig_w, orig_h = img.size
|
||||
if max(img.size) > MAX_DIMENSION:
|
||||
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
if content_type == "image/jpeg":
|
||||
if img.mode not in ("RGB", "L"):
|
||||
img = img.convert("RGB")
|
||||
img.save(buf, "JPEG", quality=82, optimize=True, progressive=True)
|
||||
elif content_type == "image/png":
|
||||
img.save(buf, "PNG", optimize=True)
|
||||
else:
|
||||
img.save(buf, "WEBP", quality=82, method=4)
|
||||
optimized = buf.getvalue()
|
||||
if len(optimized) < len(data):
|
||||
return optimized, content_type, *img.size
|
||||
return data, content_type, orig_w, orig_h
|
||||
|
||||
|
||||
@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} مگابایت است",
|
||||
)
|
||||
|
||||
width = height = None
|
||||
if content_type in REENCODE_TYPES:
|
||||
try:
|
||||
data, content_type, width, height = _optimize_image(data, content_type)
|
||||
except Exception:
|
||||
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره میشود
|
||||
pass
|
||||
|
||||
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,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
|
||||
|
||||
# ---------- خروجی CSV (برای تیم فروش) ----------
|
||||
|
||||
def _csv_response(filename: str, header: list[str], rows: list[list]) -> Response:
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(header)
|
||||
writer.writerows(rows)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
return Response(
|
||||
# BOM ابتدای فایل — تا Excel متن فارسی را درست باز کند
|
||||
content="\ufeff" + buf.getvalue(),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}-{stamp}.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/messages/export.csv")
|
||||
def export_messages(db: Session = Depends(get_db)):
|
||||
rows = [
|
||||
[m.id, m.name, m.phone, m.note, "yes" if m.is_read else "no", m.created_at.isoformat()]
|
||||
for m in db.query(models.ContactMessage).order_by(models.ContactMessage.created_at.desc()).all()
|
||||
]
|
||||
return _csv_response(
|
||||
"contact-messages", ["id", "name", "phone", "note", "is_read", "created_at"], rows
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subscribers/export.csv")
|
||||
def export_subscribers(db: Session = Depends(get_db)):
|
||||
rows = [
|
||||
[s.id, s.email, "yes" if s.is_active else "no", s.created_at.isoformat()]
|
||||
for s in db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all()
|
||||
]
|
||||
return _csv_response(
|
||||
"newsletter-subscribers", ["id", "email", "is_active", "created_at"], rows
|
||||
)
|
||||
|
||||
|
||||
# پیامهای تماس
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
"""انتهای فرمها — ثبت پیام تماس و عضویت در خبرنامه."""
|
||||
"""انتهای فرمها — ثبت پیام تماس و عضویت در خبرنامه.
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
هر فرم یک دامنهٔ پنهان (honeypot) دارد: رباتها همهٔ فیلدها را پُر میکنند؛
|
||||
در آن حالت پاسخ موفقِ قلابی برمیگردد و چیزی ذخیره نمیشود.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from .. import models
|
||||
from ..database import get_db
|
||||
from ..rate_limit import contact_limiter, newsletter_limiter
|
||||
from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _fake_contact(payload: ContactIn) -> ContactMessageOut:
|
||||
"""پاسخ موفق بدون ذخیره — تا ربات متوجه تله نشود."""
|
||||
return ContactMessageOut(
|
||||
id=0,
|
||||
name=payload.name.strip(),
|
||||
phone=payload.phone.strip(),
|
||||
note="",
|
||||
is_read=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _fake_subscriber(payload: NewsletterIn) -> SubscriberOut:
|
||||
return SubscriberOut(
|
||||
id=0,
|
||||
email=str(payload.email).lower(),
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/contact", response_model=ContactMessageOut, status_code=201)
|
||||
def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
|
||||
def submit_contact(payload: ContactIn, request: Request, db: Session = Depends(get_db)):
|
||||
contact_limiter.hit(request)
|
||||
if payload.website:
|
||||
return _fake_contact(payload)
|
||||
message = models.ContactMessage(
|
||||
name=payload.name.strip(),
|
||||
phone=payload.phone.strip(),
|
||||
@@ -24,7 +55,11 @@ def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/newsletter", response_model=SubscriberOut, status_code=201)
|
||||
def subscribe(payload: NewsletterIn, db: Session = Depends(get_db)):
|
||||
def subscribe(payload: NewsletterIn, request: Request, db: Session = Depends(get_db)):
|
||||
newsletter_limiter.hit(request)
|
||||
if payload.website:
|
||||
return _fake_subscriber(payload)
|
||||
|
||||
email = str(payload.email).lower()
|
||||
subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first()
|
||||
|
||||
|
||||
+88
-21
@@ -1,8 +1,13 @@
|
||||
"""انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت."""
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config as BotoConfig
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from .. import models
|
||||
from ..schemas import (
|
||||
@@ -22,9 +27,44 @@ from ..schemas import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/live")
|
||||
def live():
|
||||
"""زندهبودن پروسه — بدون وابستگی؛ برای restart gate استفاده نمیشود."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "app": "Aramesh Building Group API"}
|
||||
def health(db: Session = Depends(get_db)):
|
||||
"""بررسی عمق سلامت — اتصال DB و MinIO واقعاً آزموده میشود؛
|
||||
در صورت خرابی هر وابستگی، 503 با جزئیات برمیگردد."""
|
||||
checks: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
checks["database"] = "ok"
|
||||
except Exception:
|
||||
checks["database"] = "error"
|
||||
|
||||
try:
|
||||
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",
|
||||
config=BotoConfig(connect_timeout=2, read_timeout=2, retries={"max_attempts": 1}),
|
||||
)
|
||||
s3.head_bucket(Bucket=settings.s3_bucket)
|
||||
checks["storage"] = "ok"
|
||||
except Exception:
|
||||
checks["storage"] = "error"
|
||||
|
||||
if any(state != "ok" for state in checks.values()):
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"status": "degraded", "checks": checks},
|
||||
)
|
||||
return {"status": "ok", "app": "Aramesh Building Group API", "checks": checks}
|
||||
|
||||
|
||||
@router.get("/brand", response_model=BrandOut)
|
||||
@@ -37,12 +77,12 @@ def get_brand(db: Session = Depends(get_db)):
|
||||
|
||||
@router.get("/services", response_model=list[ServiceOut])
|
||||
def get_services(db: Session = Depends(get_db)):
|
||||
return db.query(models.Service).all()
|
||||
return db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
|
||||
|
||||
|
||||
@router.get("/projects", response_model=list[ProjectOut])
|
||||
def get_projects(db: Session = Depends(get_db)):
|
||||
return db.query(models.Project).order_by(models.Project.id).all()
|
||||
return db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
||||
|
||||
|
||||
@router.get("/projects/{slug}", response_model=ProjectOut)
|
||||
@@ -55,12 +95,21 @@ def get_project(slug: str, db: Session = Depends(get_db)):
|
||||
|
||||
@router.get("/articles", response_model=list[ArticleOut])
|
||||
def get_articles(db: Session = Depends(get_db)):
|
||||
return db.query(models.Article).order_by(models.Article.id).all()
|
||||
return (
|
||||
db.query(models.Article)
|
||||
.filter(models.Article.status == "published")
|
||||
.order_by(models.Article.position, models.Article.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/articles/{slug}", response_model=ArticleOut)
|
||||
def get_article(slug: str, db: Session = Depends(get_db)):
|
||||
article = db.query(models.Article).filter(models.Article.slug == slug).first()
|
||||
article = (
|
||||
db.query(models.Article)
|
||||
.filter(models.Article.slug == slug, models.Article.status == "published")
|
||||
.first()
|
||||
)
|
||||
if article is None:
|
||||
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||
return article
|
||||
@@ -68,32 +117,32 @@ def get_article(slug: str, db: Session = Depends(get_db)):
|
||||
|
||||
@router.get("/testimonials", response_model=list[TestimonialOut])
|
||||
def get_testimonials(db: Session = Depends(get_db)):
|
||||
return db.query(models.Testimonial).all()
|
||||
return db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all()
|
||||
|
||||
|
||||
@router.get("/faqs", response_model=list[FaqOut])
|
||||
def get_faqs(db: Session = Depends(get_db)):
|
||||
return db.query(models.Faq).all()
|
||||
return db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all()
|
||||
|
||||
|
||||
@router.get("/team", response_model=list[TeamMemberOut])
|
||||
def get_team(db: Session = Depends(get_db)):
|
||||
return db.query(models.TeamMember).all()
|
||||
return db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all()
|
||||
|
||||
|
||||
@router.get("/timeline", response_model=list[TimelineItemOut])
|
||||
def get_timeline(db: Session = Depends(get_db)):
|
||||
return db.query(models.TimelineItem).order_by(models.TimelineItem.id).all()
|
||||
return db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=list[StatOut])
|
||||
def get_stats(db: Session = Depends(get_db)):
|
||||
return db.query(models.Stat).all()
|
||||
return db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all()
|
||||
|
||||
|
||||
@router.get("/why-us", response_model=list[WhyUsItemOut])
|
||||
def get_why_us(db: Session = Depends(get_db)):
|
||||
return db.query(models.WhyUsItem).all()
|
||||
return db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all()
|
||||
|
||||
|
||||
@router.get("/before-after", response_model=BeforeAfterOut)
|
||||
@@ -109,14 +158,32 @@ def get_site(db: Session = Depends(get_db)):
|
||||
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانتاند."""
|
||||
return {
|
||||
"brand": db.query(models.Brand).first(),
|
||||
"services": db.query(models.Service).all(),
|
||||
"projects": db.query(models.Project).order_by(models.Project.id).all(),
|
||||
"articles": db.query(models.Article).order_by(models.Article.id).all(),
|
||||
"testimonials": db.query(models.Testimonial).all(),
|
||||
"faqs": db.query(models.Faq).all(),
|
||||
"team": db.query(models.TeamMember).all(),
|
||||
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(),
|
||||
"stats": db.query(models.Stat).all(),
|
||||
"why_us": db.query(models.WhyUsItem).all(),
|
||||
"nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(),
|
||||
"hero": db.query(models.Hero).first(),
|
||||
"story": db.query(models.StorySection).first(),
|
||||
"section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(),
|
||||
"page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(),
|
||||
"services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(),
|
||||
"projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(),
|
||||
# فقط مقالههای منتشرشده — پیشنویسها از انتهای مدیریتی خوانده میشوند
|
||||
"articles": db.query(models.Article)
|
||||
.filter(models.Article.status == "published")
|
||||
.order_by(models.Article.position, models.Article.id)
|
||||
.all(),
|
||||
"testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(),
|
||||
"faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(),
|
||||
"team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(),
|
||||
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all(),
|
||||
"stats": db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all(),
|
||||
"why_us": db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all(),
|
||||
"before_after": db.query(models.BeforeAfter).first(),
|
||||
"investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.position, models.InvestmentModel.id).all(),
|
||||
"process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.position, models.ProcessStep.id).all(),
|
||||
"offices": db.query(models.Office).order_by(models.Office.position, models.Office.id).all(),
|
||||
"gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.position, models.GalleryImage.id).all(),
|
||||
"status_labels": db.query(models.StatusLabel).order_by(models.StatusLabel.id).all(),
|
||||
"contact_section": db.query(models.ContactSectionContent).first(),
|
||||
"newsletter": db.query(models.NewsletterSectionContent).first(),
|
||||
"footer": db.query(models.FooterContent).first(),
|
||||
"seo_meta": db.query(models.SeoMeta).order_by(models.SeoMeta.id).all(),
|
||||
}
|
||||
|
||||
+16
-1
@@ -13,10 +13,25 @@ class Settings(BaseSettings):
|
||||
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api"
|
||||
secret_key: str = "dev-secret-change-me-in-production"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 1440
|
||||
# ۸ ساعت — توکن stateless است و امکان ابطال ندارد؛ عمر کوتاهتر ریسک نشت را کم میکند
|
||||
access_token_expire_minutes: int = 480
|
||||
cors_origins: list[str] = ["*"]
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""ساخت جداول پایه (baseline) — پیش از مهاجرتهای Alembic.
|
||||
|
||||
روی دیتابیس تازه، همهٔ جدولها از مدلهای فعلی ساخته میشوند؛ روی دیتابیس موجود
|
||||
بیاثر است (create_all جدولهای موجود را دست نمیزند). از اینجا به بعد تغییرات
|
||||
اسکیما فقط با revisionهای Alembic اعمال میشوند.
|
||||
"""
|
||||
|
||||
from . import models # noqa: F401 — ثبت مدلها
|
||||
from .database import Base, engine
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -1,11 +1,16 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from . import models # noqa: F401 — ثبت مدلها پیش از ساخت جداول
|
||||
from .api import admin, forms, public
|
||||
from .config import settings
|
||||
from .database import Base, engine
|
||||
from .seo_pages import router as seo_router
|
||||
|
||||
# ساخت جداول پایه — تغییرات بعدی اسکیما فقط با revisionهای Alembic اعمال میشوند
|
||||
# (مigrations/versions). اجرای اینجا برای اجرای مستقیم uvicorn در محیط توسعه است؛
|
||||
# در کانتینر، CMD همین کار را قبل از alembic upgrade head انجام میدهد.
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
@@ -26,11 +31,21 @@ app.add_middleware(
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
# پاسخهای JSON /api — فشردهسازی وقتی بدنه قابلتوجه است
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||
|
||||
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=["سئو"])
|
||||
|
||||
# متریکهای Prometheus — فقط Prometheus در شبکه داخلی اسکرپ میکند
|
||||
# (Traefik مسیر /metrics را نمیشناسد، پس از بیرون در دسترس نیست)
|
||||
from prometheus_fastapi_instrumentator import Instrumentator # noqa: E402
|
||||
|
||||
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""مهاجرتهای سبک دیتابیس — DDL و تبدیل دادههای قدیمی؛ همه idempotent.
|
||||
|
||||
هم main.py (startup) و هم seed.py اینها را صدا میزنند تا هر مسیر اجرایی
|
||||
دیتابیس موجود را بدون از دست رفتن داده ارتقا دهد.
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from .database import SessionLocal, engine
|
||||
|
||||
# جدولهای مجموعهای که ستون position دارند
|
||||
POSITION_TABLES = [
|
||||
"services", "projects", "articles", "testimonials", "faqs", "team",
|
||||
"timeline", "stats", "why_us", "investment_models", "process_steps",
|
||||
"offices", "gallery_images", "nav_items",
|
||||
]
|
||||
|
||||
# ستونهای انگلیسی — (جدول، ستون، نوع). ستونهای JSON بدون default اند تا
|
||||
# سطرهای موجود NULL بگیرند (یعنی «ترجمه ثبت نشده») و فرانت به فارسی برگردد.
|
||||
EN_COLUMNS: list[tuple[str, str, str]] = [
|
||||
("brand", "name_en", "VARCHAR(128)"),
|
||||
("brand", "address_en", "TEXT"),
|
||||
("services", "title_en", "VARCHAR(256)"),
|
||||
("services", "desc_en", "TEXT"),
|
||||
("projects", "title_en", "VARCHAR(256)"),
|
||||
("projects", "location_en", "VARCHAR(128)"),
|
||||
("projects", "year_en", "VARCHAR(64)"),
|
||||
("projects", "area_en", "VARCHAR(64)"),
|
||||
("projects", "floors_en", "VARCHAR(64)"),
|
||||
("projects", "units_en", "VARCHAR(64)"),
|
||||
("projects", "client_en", "VARCHAR(128)"),
|
||||
("projects", "summary_en", "TEXT"),
|
||||
("projects", "body_en", "JSON"),
|
||||
("articles", "title_en", "VARCHAR(256)"),
|
||||
("articles", "date_en", "VARCHAR(64)"),
|
||||
("articles", "category_en", "VARCHAR(64)"),
|
||||
("articles", "read_en", "VARCHAR(32)"),
|
||||
("articles", "excerpt_en", "TEXT"),
|
||||
("articles", "body_en", "JSON"),
|
||||
("testimonials", "name_en", "VARCHAR(128)"),
|
||||
("testimonials", "role_en", "VARCHAR(128)"),
|
||||
("testimonials", "quote_en", "TEXT"),
|
||||
("faqs", "question_en", "TEXT"),
|
||||
("faqs", "answer_en", "TEXT"),
|
||||
("team", "name_en", "VARCHAR(128)"),
|
||||
("team", "role_en", "VARCHAR(128)"),
|
||||
("team", "bio_en", "TEXT"),
|
||||
("timeline", "year_en", "VARCHAR(32)"),
|
||||
("timeline", "title_en", "VARCHAR(256)"),
|
||||
("timeline", "desc_en", "TEXT"),
|
||||
("stats", "suffix_en", "VARCHAR(16)"),
|
||||
("stats", "label_en", "VARCHAR(128)"),
|
||||
("why_us", "title_en", "VARCHAR(256)"),
|
||||
("why_us", "desc_en", "TEXT"),
|
||||
("before_after", "title_en", "VARCHAR(256)"),
|
||||
("before_after", "lead_en", "TEXT"),
|
||||
("before_after", "before_label_en", "VARCHAR(128)"),
|
||||
("before_after", "after_label_en", "VARCHAR(128)"),
|
||||
("nav_items", "label_en", "VARCHAR(64)"),
|
||||
("hero", "kicker_en", "TEXT"),
|
||||
("hero", "title_en", "TEXT"),
|
||||
("hero", "title_highlight_en", "VARCHAR(128)"),
|
||||
("hero", "subtitle_en", "TEXT"),
|
||||
("hero", "badge_value_en", "VARCHAR(32)"),
|
||||
("hero", "badge_label_en", "TEXT"),
|
||||
("hero", "cta_primary_label_en", "VARCHAR(128)"),
|
||||
("hero", "cta_secondary_label_en", "VARCHAR(128)"),
|
||||
("hero", "scroll_label_en", "VARCHAR(64)"),
|
||||
("story", "title_en", "VARCHAR(256)"),
|
||||
("story", "paragraphs_en", "JSON"),
|
||||
("story", "quote_en", "TEXT"),
|
||||
("story", "quote_author_en", "TEXT"),
|
||||
("story", "stat1_value_en", "VARCHAR(32)"),
|
||||
("story", "stat1_label_en", "VARCHAR(128)"),
|
||||
("story", "stat2_value_en", "VARCHAR(32)"),
|
||||
("story", "stat2_label_en", "VARCHAR(128)"),
|
||||
("story", "link_label_en", "VARCHAR(128)"),
|
||||
("story", "bento_en", "JSON"),
|
||||
("section_headings", "label_en", "VARCHAR(64)"),
|
||||
("section_headings", "title_en", "VARCHAR(256)"),
|
||||
("section_headings", "lead_en", "TEXT"),
|
||||
("section_headings", "cta_label_en", "VARCHAR(128)"),
|
||||
("page_heroes", "title_en", "VARCHAR(256)"),
|
||||
("page_heroes", "lead_en", "TEXT"),
|
||||
("investment_models", "title_en", "VARCHAR(256)"),
|
||||
("investment_models", "desc_en", "TEXT"),
|
||||
("investment_models", "points_en", "JSON"),
|
||||
("process_steps", "step_en", "VARCHAR(16)"),
|
||||
("process_steps", "title_en", "VARCHAR(256)"),
|
||||
("process_steps", "desc_en", "TEXT"),
|
||||
("offices", "city_en", "VARCHAR(64)"),
|
||||
("offices", "address_en", "TEXT"),
|
||||
("offices", "hours_en", "VARCHAR(128)"),
|
||||
("gallery_images", "alt_en", "TEXT"),
|
||||
("status_labels", "label_en", "VARCHAR(64)"),
|
||||
("contact_section", "label_en", "VARCHAR(64)"),
|
||||
("contact_section", "title_en", "VARCHAR(256)"),
|
||||
("contact_section", "lead_en", "TEXT"),
|
||||
("contact_section", "address_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "phone_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "email_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "name_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "name_placeholder_en", "VARCHAR(128)"),
|
||||
("contact_section", "phone_field_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "phone_placeholder_en", "VARCHAR(64)"),
|
||||
("contact_section", "note_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "note_optional_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "note_placeholder_en", "VARCHAR(256)"),
|
||||
("contact_section", "submit_label_en", "VARCHAR(128)"),
|
||||
("contact_section", "sending_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "success_title_en", "VARCHAR(256)"),
|
||||
("contact_section", "success_text_en", "TEXT"),
|
||||
("contact_section", "success_button_label_en", "VARCHAR(128)"),
|
||||
("newsletter", "title_en", "VARCHAR(256)"),
|
||||
("newsletter", "lead_en", "TEXT"),
|
||||
("newsletter", "placeholder_en", "VARCHAR(128)"),
|
||||
("newsletter", "button_label_en", "VARCHAR(64)"),
|
||||
("newsletter", "sending_label_en", "VARCHAR(64)"),
|
||||
("newsletter", "success_text_en", "VARCHAR(256)"),
|
||||
("footer", "about_text_en", "TEXT"),
|
||||
("footer", "pages_heading_en", "VARCHAR(64)"),
|
||||
("footer", "services_heading_en", "VARCHAR(64)"),
|
||||
("footer", "contact_heading_en", "VARCHAR(64)"),
|
||||
("footer", "copyright_en", "VARCHAR(256)"),
|
||||
("footer", "tagline_en", "VARCHAR(256)"),
|
||||
("footer", "socials_en", "JSON"),
|
||||
("seo_meta", "title_en", "VARCHAR(256)"),
|
||||
("seo_meta", "description_en", "TEXT"),
|
||||
]
|
||||
|
||||
|
||||
def run_ddl_migrations() -> None:
|
||||
"""ستونهای جدید — برای PostgreSQL با ADD COLUMN IF NOT EXISTS."""
|
||||
if engine.dialect.name != "postgresql":
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
for table in POSITION_TABLES:
|
||||
conn.execute(
|
||||
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0")
|
||||
)
|
||||
# مقالات موجود منتشر شدهاند؛ فقط مقالههای تازه میتوانند پیشنویس باشند
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'published'")
|
||||
)
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
||||
)
|
||||
# ستونهای انگلیسی — رشتهها با پیشفرض خالی، JSON بدون پیشفرض (NULL = بدون ترجمه)
|
||||
for table, column, col_type in EN_COLUMNS:
|
||||
if col_type == "JSON":
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} JSON"))
|
||||
else:
|
||||
conn.execute(
|
||||
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} {col_type} NOT NULL DEFAULT ''")
|
||||
)
|
||||
|
||||
|
||||
def paragraphs_to_doc(paragraphs: list) -> dict:
|
||||
"""تبدیل لیست پاراگرافهای متنی (فرمت قدیمی بدنه مقاله) به سند TipTap."""
|
||||
content = []
|
||||
for para in paragraphs:
|
||||
if isinstance(para, str) and para.strip():
|
||||
content.append({
|
||||
"type": "paragraph",
|
||||
"content": [{"type": "text", "text": para}],
|
||||
})
|
||||
if not content:
|
||||
content = [{"type": "paragraph"}]
|
||||
return {"type": "doc", "content": content}
|
||||
|
||||
|
||||
def migrate_article_bodies() -> None:
|
||||
"""تبدیل بدنه مقالات از لیست پاراگراف به سند JSON — idempotent."""
|
||||
from . import models # lazy — جلوگیری از import حلقهای
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
changed = 0
|
||||
for article in db.query(models.Article).all():
|
||||
if isinstance(article.body, list):
|
||||
article.body = paragraphs_to_doc(article.body)
|
||||
changed += 1
|
||||
db.commit()
|
||||
if changed:
|
||||
print(f"مقالات تبدیلشده به فرمت سند: {changed}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _is_empty(value: object) -> bool:
|
||||
"""مقدار خالی انگلیسی — یعنی هنوز ترجمهای ثبت نشده و قابل پر کردن است."""
|
||||
if value is None or value == "" or value == [] or value == {}:
|
||||
return True
|
||||
if isinstance(value, dict) and value.get("content") == [{"type": "paragraph"}]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _backfill_row(row, en_fields: dict) -> int:
|
||||
"""پر کردن فیلدهای خالی *_en یک سطر از دیکشنری ترجمه — تعداد فیلدهای پرشده."""
|
||||
filled = 0
|
||||
for key, value in en_fields.items():
|
||||
if not key.endswith("_en"):
|
||||
continue
|
||||
if not _is_empty(getattr(row, key, None)):
|
||||
continue
|
||||
setattr(row, key, value)
|
||||
filled += 1
|
||||
return filled
|
||||
|
||||
|
||||
def backfill_en_content() -> None:
|
||||
"""پر کردن ترجمههای انگلیسی سطرهای موجود از seed_data_en — idempotent.
|
||||
|
||||
تطبیق سطرها: جدولهای تکسطری مستقیم؛ بقیه با کلیدهای طبیعی/متنی فارسی
|
||||
(مثلاً slug پروژه، href منو، عنوان خدمت، متن پرسش) — اگر ادمین متن فارسی را
|
||||
ویرایش کرده باشد سطر تطبیق نمیخورد و ترجمهاش خالی میماند تا از پنل پر شود.
|
||||
هیچ مقدار انگلیسیِ ثبتشده بازنویسی نمیشود.
|
||||
"""
|
||||
from . import models, seed_data, seed_data_en # lazy — جلوگیری از import حلقهای
|
||||
|
||||
# (مدل، فهرست فارسی، فهرست انگلیسی، کلیدهای تطبیق — خالی برای تکسطریها)
|
||||
PLAN: list[tuple[object, list[dict], list[dict], tuple[str, ...]]] = [
|
||||
(models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], ()),
|
||||
(models.Hero, [seed_data.HERO], [seed_data_en.HERO], ()),
|
||||
(models.StorySection, [seed_data.STORY], [seed_data_en.STORY], ()),
|
||||
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, ("key", "title")),
|
||||
(models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, ("key", "title")),
|
||||
(models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, ("href", "label")),
|
||||
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, ("title",)),
|
||||
(models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, ("title",)),
|
||||
(models.Office, seed_data.OFFICES, seed_data_en.OFFICES, ("city",)),
|
||||
(models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, ("alt",)),
|
||||
(models.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, ("key",)),
|
||||
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], ()),
|
||||
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], ()),
|
||||
(models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], ()),
|
||||
(models.Service, seed_data.SERVICES, seed_data_en.SERVICES, ("title",)),
|
||||
(models.Stat, seed_data.STATS, seed_data_en.STATS, ("label",)),
|
||||
(models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, ("slug", "title")),
|
||||
(models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, ("slug", "title")),
|
||||
(models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, ("name",)),
|
||||
(models.Faq, seed_data.FAQS, seed_data_en.FAQS, ("question",)),
|
||||
(models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, ("name",)),
|
||||
(models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, ("title",)),
|
||||
(models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, ("title",)),
|
||||
(models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], ()),
|
||||
(models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, ("page_key",)),
|
||||
]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
total_rows = 0
|
||||
total_fields = 0
|
||||
unmatched: list[str] = []
|
||||
for model, fa_rows, en_rows, match_keys in PLAN:
|
||||
rows = db.query(model).all()
|
||||
if not rows or not en_rows:
|
||||
continue
|
||||
table_name = getattr(model, "__tablename__", str(model))
|
||||
|
||||
if not match_keys:
|
||||
if len(rows) != 1 or len(en_rows) != 1:
|
||||
unmatched.append(table_name)
|
||||
continue
|
||||
pairs = [(rows[0], en_rows[0])]
|
||||
else:
|
||||
# برای هر کلید، نگاشت مقدار → سطر ترجمه (اولین کلید برنده است)
|
||||
en_lookups: list[dict[object, dict]] = []
|
||||
for key in match_keys:
|
||||
en_lookups.append({r[key]: r for r in en_rows if r.get(key) is not None})
|
||||
pairs = []
|
||||
matched_en_ids: set[int] = set()
|
||||
for row in rows:
|
||||
en_row = None
|
||||
for key, lookup in zip(match_keys, en_lookups):
|
||||
en_row = lookup.get(getattr(row, key, None))
|
||||
if en_row is not None:
|
||||
break
|
||||
if en_row is not None and id(en_row) not in matched_en_ids:
|
||||
matched_en_ids.add(id(en_row))
|
||||
pairs.append((row, en_row))
|
||||
|
||||
for row, en_row in pairs:
|
||||
# بدنه مقاله انگلیسی هم مثل فارسی از پاراگراف به سند تبدیل میشود
|
||||
en_fields = dict(en_row)
|
||||
if model is models.Article and isinstance(en_fields.get("body_en"), list):
|
||||
en_fields["body_en"] = paragraphs_to_doc(en_fields["body_en"])
|
||||
filled = _backfill_row(row, en_fields)
|
||||
total_fields += filled
|
||||
if filled:
|
||||
total_rows += 1
|
||||
|
||||
db.commit()
|
||||
if total_fields:
|
||||
print(f"ترجمه انگلیسی پر شد: {total_fields} فیلد در {total_rows} سطر.")
|
||||
if unmatched:
|
||||
print("سطرهایی که با seed تطبیق نکردند (از پنل مدیریت ترجمه کنید):")
|
||||
for name in unmatched:
|
||||
print(f" - {name}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def run_all() -> None:
|
||||
run_ddl_migrations()
|
||||
migrate_article_bodies()
|
||||
backfill_en_content()
|
||||
+329
-1
@@ -10,6 +10,10 @@ def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ستونهای *_en معادل انگلیسی فیلدهای متنیِ قابل نمایش هستند؛
|
||||
# مقدار خالی یعنی «ترجمهای ثبت نشده» و فرانتاند به نسخه فارسی برمیگردد.
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
@@ -23,6 +27,7 @@ class Brand(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), default="")
|
||||
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
logo: Mapped[str] = mapped_column(Text, default="")
|
||||
hero: Mapped[str] = mapped_column(Text, default="")
|
||||
contact_bg: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -30,101 +35,156 @@ class Brand(Base):
|
||||
phone_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
email: Mapped[str] = mapped_column(String(128), default="")
|
||||
address: Mapped[str] = mapped_column(Text, default="")
|
||||
address_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Service(Base):
|
||||
__tablename__ = "services"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
icon: Mapped[str] = mapped_column(String(32), default="draft")
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
location: Mapped[str] = mapped_column(String(128), default="")
|
||||
location_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
year: Mapped[str] = mapped_column(String(64), default="")
|
||||
year_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
status: Mapped[str] = mapped_column(String(16), default="done") # done | progress
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
gallery: Mapped[list] = mapped_column(JSON, default=list)
|
||||
area: Mapped[str] = mapped_column(String(64), default="")
|
||||
area_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
floors: Mapped[str] = mapped_column(String(64), default="")
|
||||
floors_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
units: Mapped[str] = mapped_column(String(64), default="")
|
||||
units_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
client: Mapped[str] = mapped_column(String(128), default="")
|
||||
client_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
summary_en: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||
body_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class Article(Base):
|
||||
__tablename__ = "articles"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
date: Mapped[str] = mapped_column(String(64), default="")
|
||||
date_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
category: Mapped[str] = mapped_column(String(64), default="")
|
||||
category_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
read: Mapped[str] = mapped_column(String(32), default="")
|
||||
read_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
excerpt: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||
excerpt_en: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
|
||||
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
|
||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||
seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class Testimonial(Base):
|
||||
__tablename__ = "testimonials"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
role: Mapped[str] = mapped_column(String(128), default="")
|
||||
role_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
quote: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Faq(Base):
|
||||
__tablename__ = "faqs"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
question: Mapped[str] = mapped_column(Text)
|
||||
question_en: Mapped[str] = mapped_column(Text, default="")
|
||||
answer: Mapped[str] = mapped_column(Text, default="")
|
||||
answer_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class TeamMember(Base):
|
||||
__tablename__ = "team"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
role: Mapped[str] = mapped_column(String(128), default="")
|
||||
role_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
bio: Mapped[str] = mapped_column(Text, default="")
|
||||
bio_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class TimelineItem(Base):
|
||||
__tablename__ = "timeline"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
year: Mapped[str] = mapped_column(String(32))
|
||||
year_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Stat(Base):
|
||||
__tablename__ = "stats"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
value: Mapped[int] = mapped_column(Integer)
|
||||
suffix: Mapped[str] = mapped_column(String(16), default="")
|
||||
suffix_en: Mapped[str] = mapped_column(String(16), default="")
|
||||
label: Mapped[str] = mapped_column(String(128))
|
||||
label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class WhyUsItem(Base):
|
||||
__tablename__ = "why_us"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class BeforeAfter(Base):
|
||||
@@ -132,11 +192,279 @@ class BeforeAfter(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
before: Mapped[str] = mapped_column(Text, default="")
|
||||
after: Mapped[str] = mapped_column(Text, default="")
|
||||
before_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
before_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
after_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
after_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class NavItem(Base):
|
||||
__tablename__ = "nav_items"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
label: Mapped[str] = mapped_column(String(64))
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
href: Mapped[str] = mapped_column(String(128))
|
||||
|
||||
|
||||
class Hero(Base):
|
||||
"""محتوای هیرو صفحه اصلی — رکورد یگانه."""
|
||||
|
||||
__tablename__ = "hero"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
kicker: Mapped[str] = mapped_column(Text, default="")
|
||||
kicker_en: Mapped[str] = mapped_column(Text, default="")
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
title_en: Mapped[str] = mapped_column(Text, default="")
|
||||
title_highlight: Mapped[str] = mapped_column(String(128), default="")
|
||||
title_highlight_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
subtitle: Mapped[str] = mapped_column(Text, default="")
|
||||
subtitle_en: Mapped[str] = mapped_column(Text, default="")
|
||||
badge_value: Mapped[str] = mapped_column(String(32), default="")
|
||||
badge_value_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
badge_label: Mapped[str] = mapped_column(Text, default="")
|
||||
badge_label_en: Mapped[str] = mapped_column(Text, default="")
|
||||
cta_primary_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_primary_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_primary_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_secondary_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_secondary_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_secondary_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
scroll_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
scroll_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
||||
|
||||
class StorySection(Base):
|
||||
"""بخش «درباره ما» در خانه و صفحه درباره — رکورد یگانه."""
|
||||
|
||||
__tablename__ = "story"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
paragraphs: Mapped[list] = mapped_column(JSON, default=list)
|
||||
paragraphs_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
quote: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_en: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_author: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_author_en: Mapped[str] = mapped_column(Text, default="")
|
||||
stat1_value: Mapped[str] = mapped_column(String(32), default="")
|
||||
stat1_value_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
stat1_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
stat1_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
stat2_value: Mapped[str] = mapped_column(String(32), default="")
|
||||
stat2_value_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
stat2_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
stat2_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
link_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
link_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
link_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
bento: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}]
|
||||
bento_en: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}] انگلیسی
|
||||
|
||||
|
||||
class SectionHeadingContent(Base):
|
||||
"""تیتر و توضیح بخشها — فرانتاند بر اساس key آن را پیدا میکند."""
|
||||
|
||||
__tablename__ = "section_headings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
label: Mapped[str] = mapped_column(String(64), default="")
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
cta_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class PageHeroContent(Base):
|
||||
"""هیرو بالای هر صفحه — key: about | services | projects | investment | blog | faq | contact."""
|
||||
|
||||
__tablename__ = "page_heroes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class InvestmentModel(Base):
|
||||
__tablename__ = "investment_models"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
points: Mapped[list] = mapped_column(JSON, default=list)
|
||||
points_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class ProcessStep(Base):
|
||||
__tablename__ = "process_steps"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
step: Mapped[str] = mapped_column(String(16), default="")
|
||||
step_en: Mapped[str] = mapped_column(String(16), default="")
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Office(Base):
|
||||
__tablename__ = "offices"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
city: Mapped[str] = mapped_column(String(64))
|
||||
city_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
address: Mapped[str] = mapped_column(Text, default="")
|
||||
address_en: Mapped[str] = mapped_column(Text, default="")
|
||||
hours: Mapped[str] = mapped_column(String(128), default="")
|
||||
hours_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class GalleryImage(Base):
|
||||
__tablename__ = "gallery_images"
|
||||
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
src: Mapped[str] = mapped_column(Text, default="")
|
||||
alt: Mapped[str] = mapped_column(Text, default="")
|
||||
alt_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class StatusLabel(Base):
|
||||
"""برچسب فارسی وضعیت پروژه — key: done | progress."""
|
||||
|
||||
__tablename__ = "status_labels"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(16), unique=True, index=True)
|
||||
label: Mapped[str] = mapped_column(String(64))
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
||||
|
||||
class ContactSectionContent(Base):
|
||||
"""بخش فرم تماس — رکورد یگانه."""
|
||||
|
||||
__tablename__ = "contact_section"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
label: Mapped[str] = mapped_column(String(64), default="")
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
address_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
address_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
email_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
email_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
name_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
name_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
name_placeholder: Mapped[str] = mapped_column(String(128), default="")
|
||||
name_placeholder_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
phone_field_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_field_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_placeholder: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_placeholder_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_optional_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_optional_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_placeholder: Mapped[str] = mapped_column(String(256), default="")
|
||||
note_placeholder_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
submit_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
submit_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
sending_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
sending_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
success_title: Mapped[str] = mapped_column(String(256), default="")
|
||||
success_title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
success_text: Mapped[str] = mapped_column(Text, default="")
|
||||
success_text_en: Mapped[str] = mapped_column(Text, default="")
|
||||
success_button_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
success_button_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class NewsletterSectionContent(Base):
|
||||
"""بخش خبرنامه — رکورد یگانه."""
|
||||
|
||||
__tablename__ = "newsletter"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
placeholder: Mapped[str] = mapped_column(String(128), default="")
|
||||
placeholder_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
button_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
button_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
sending_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
sending_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
success_text: Mapped[str] = mapped_column(String(256), default="")
|
||||
success_text_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
|
||||
|
||||
class FooterContent(Base):
|
||||
"""متنهای فوتر — رکورد یگانه."""
|
||||
|
||||
__tablename__ = "footer"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
about_text: Mapped[str] = mapped_column(Text, default="")
|
||||
about_text_en: Mapped[str] = mapped_column(Text, default="")
|
||||
pages_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||
pages_heading_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
services_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||
services_heading_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
contact_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||
contact_heading_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
copyright: Mapped[str] = mapped_column(String(256), default="")
|
||||
copyright_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
tagline: Mapped[str] = mapped_column(String(256), default="")
|
||||
tagline_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}]
|
||||
socials_en: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}] انگلیسی
|
||||
|
||||
|
||||
class SeoMeta(Base):
|
||||
"""عنوان و توضیح سئوی هر صفحه — page_key: home / about / services / ..."""
|
||||
|
||||
__tablename__ = "seo_meta"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
description_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class ContactMessage(Base):
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""محدودسازی نرخ درخواست — پنجرهٔ لغزان درونحافظهای.
|
||||
|
||||
برای ورود ادمین (بر پایه تلاش ناموفق) و فرمهای عمومی (بر پایه هر ارسال)
|
||||
استفاده میشود. شمارندهها در حافظهٔ همین پروسهاند — با ریاستارت پاک میشوند؛
|
||||
به همین دلیل uvicorn تککارگر نگه داشته شده است.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""IP واقعی کلاینت — پشت Traefik از X-Forwarded-For خوانده میشود
|
||||
(uvicorn با --proxy-headers هم client.host را درست میکند)."""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""پنجرهٔ لغزان بر پایه کلید (عملاً IP) — رویدادها پس از پنجره منقضی میشوند."""
|
||||
|
||||
def __init__(self, max_events: int, window_seconds: int, message: str):
|
||||
self.max_events = max_events
|
||||
self.window_seconds = window_seconds
|
||||
self.message = message
|
||||
self._events: dict[str, list[float]] = {}
|
||||
|
||||
def _recent(self, key: str, now: float) -> list[float]:
|
||||
recent = [t for t in self._events.get(key, []) if now - t < self.window_seconds]
|
||||
self._events[key] = recent
|
||||
return recent
|
||||
|
||||
def _reject(self, recent: list[float]) -> None:
|
||||
now = time.monotonic()
|
||||
retry_after = max(1, int(self.window_seconds - (now - recent[0])))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=self.message,
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
def hit(self, request: Request) -> None:
|
||||
"""یک رویداد ثبت میکند؛ اگر سهمیه کلید پر باشد 429 برمیگرداند."""
|
||||
key = client_ip(request)
|
||||
now = time.monotonic()
|
||||
recent = self._recent(key, now)
|
||||
if len(recent) >= self.max_events:
|
||||
self._reject(recent)
|
||||
self._events.setdefault(key, []).append(now)
|
||||
|
||||
def clear(self, request: Request) -> None:
|
||||
self._events.pop(client_ip(request), None)
|
||||
|
||||
|
||||
# ورود ادمین: ۵ تلاش ناموفق در ۱۵ دقیقه — فقط شکستها شمرده میشوند
|
||||
_failed: dict[str, list[float]] = {}
|
||||
LOGIN_MAX_FAILED_ATTEMPTS = 5
|
||||
LOGIN_WINDOW_SECONDS = 15 * 60
|
||||
|
||||
|
||||
def ensure_allowed(request: Request) -> None:
|
||||
"""اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد میشود."""
|
||||
key = client_ip(request)
|
||||
now = time.monotonic()
|
||||
recent = [t for t in _failed.get(key, []) if now - t < LOGIN_WINDOW_SECONDS]
|
||||
_failed[key] = recent
|
||||
if len(recent) >= LOGIN_MAX_FAILED_ATTEMPTS:
|
||||
retry_after = max(1, int(LOGIN_WINDOW_SECONDS - (now - 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)
|
||||
|
||||
|
||||
# فرم تماس: ۵ ارسال در ۱۵ دقیقه برای هر IP
|
||||
contact_limiter = RateLimiter(
|
||||
max_events=5,
|
||||
window_seconds=15 * 60,
|
||||
message="تعداد پیامهای ارسالی زیاد است — کمی بعد دوباره امتحان کنید",
|
||||
)
|
||||
|
||||
# خبرنامه: ۳ عضویت در ساعت برای هر IP
|
||||
newsletter_limiter = RateLimiter(
|
||||
max_events=3,
|
||||
window_seconds=60 * 60,
|
||||
message="تعداد درخواستها زیاد است — کمی بعد دوباره امتحان کنید",
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""پاکسازی ورودیهای غنی در زمان ذخیره.
|
||||
|
||||
بدنه مقالهها سند TipTap (ProseMirror) است و فرانتاند همان را با
|
||||
renderToHTMLString به HTML تبدیل و مستقیم تزریق میکند؛ متن گرهها در آن
|
||||
مسیر escape میشود، اما آدرسهای href/src عیناً وارد HTML میشوند — پس
|
||||
اسکیمهای خطرناک (javascript: و data: و ...) باید در سمت سرور حذف شوند.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# اسکیمهای مجاز برای href/src — مسیرهای نسبی و لنگر هم بیاسکیماند و میگذرند
|
||||
SAFE_URL_SCHEMES = {"http", "https", "mailto", "tel"}
|
||||
URL_ATTRS = ("href", "src")
|
||||
|
||||
|
||||
def _clean_url(value: Any) -> str | None:
|
||||
"""آدرس امن را برمیگرداند؛ در غیر این صورت None (مشخصه حذف میشود)."""
|
||||
if not isinstance(value, str):
|
||||
return value if value is not None else None
|
||||
stripped = value.strip().replace("\n", "").replace("\r", "").replace("\t", "")
|
||||
if not stripped:
|
||||
return None
|
||||
parsed = urlparse(stripped)
|
||||
if parsed.scheme and parsed.scheme.lower() not in SAFE_URL_SCHEMES:
|
||||
return None
|
||||
return stripped
|
||||
|
||||
|
||||
def _walk(node: Any) -> Any:
|
||||
if isinstance(node, list):
|
||||
return [_walk(item) for item in node]
|
||||
if not isinstance(node, dict):
|
||||
return node
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in node.items():
|
||||
if key == "attrs" and isinstance(value, dict):
|
||||
attrs = dict(value)
|
||||
for attr in URL_ATTRS:
|
||||
if attr in attrs:
|
||||
cleaned_url = _clean_url(attrs[attr])
|
||||
if cleaned_url is None:
|
||||
attrs.pop(attr, None)
|
||||
else:
|
||||
attrs[attr] = cleaned_url
|
||||
cleaned[key] = attrs
|
||||
else:
|
||||
cleaned[key] = _walk(value)
|
||||
return cleaned
|
||||
|
||||
|
||||
def sanitize_doc(doc: Any) -> Any:
|
||||
"""سند TipTap را درجا پاک میکند (سایر ساختارها دستنخورده برمیگردند)."""
|
||||
if not isinstance(doc, dict):
|
||||
return doc
|
||||
return _walk(doc)
|
||||
+582
-4
@@ -1,7 +1,9 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
|
||||
|
||||
from .sanitize import sanitize_doc
|
||||
|
||||
# ---------- پایه ----------
|
||||
|
||||
@@ -10,12 +12,16 @@ class ORMModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# فیلدهای *_en معادل انگلیسیاند؛ خالی بودنشان یعنی «بدون ترجمه».
|
||||
|
||||
|
||||
# ---------- خروجیها ----------
|
||||
|
||||
|
||||
class BrandOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str = ""
|
||||
logo: str
|
||||
hero: str
|
||||
contact_bg: str
|
||||
@@ -23,12 +29,15 @@ class BrandOut(ORMModel):
|
||||
phone_href: str
|
||||
email: str
|
||||
address: str
|
||||
address_en: str = ""
|
||||
|
||||
|
||||
class ServiceOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
icon: str
|
||||
|
||||
|
||||
@@ -36,56 +45,84 @@ class ProjectOut(ORMModel):
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
location: str
|
||||
location_en: str = ""
|
||||
year: str
|
||||
year_en: str = ""
|
||||
status: str
|
||||
image: str
|
||||
gallery: list[str]
|
||||
area: str
|
||||
area_en: str = ""
|
||||
floors: str
|
||||
floors_en: str = ""
|
||||
units: str
|
||||
units_en: str = ""
|
||||
client: str
|
||||
client_en: str = ""
|
||||
summary: str
|
||||
summary_en: str = ""
|
||||
body: list[str]
|
||||
body_en: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ArticleOut(ORMModel):
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
date: str
|
||||
date_en: str = ""
|
||||
category: str
|
||||
category_en: str = ""
|
||||
image: str
|
||||
read: str
|
||||
read_en: str = ""
|
||||
excerpt: str
|
||||
body: list[str]
|
||||
excerpt_en: str = ""
|
||||
body: dict
|
||||
body_en: Optional[dict] = None
|
||||
status: str
|
||||
seo_keyphrase: str
|
||||
|
||||
|
||||
class TestimonialOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str = ""
|
||||
role: str
|
||||
role_en: str = ""
|
||||
quote: str
|
||||
quote_en: str = ""
|
||||
|
||||
|
||||
class FaqOut(ORMModel):
|
||||
id: int
|
||||
question: str
|
||||
question_en: str = ""
|
||||
answer: str
|
||||
answer_en: str = ""
|
||||
|
||||
|
||||
class TeamMemberOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str = ""
|
||||
role: str
|
||||
role_en: str = ""
|
||||
bio: str
|
||||
bio_en: str = ""
|
||||
|
||||
|
||||
class TimelineItemOut(ORMModel):
|
||||
id: int
|
||||
year: str
|
||||
year_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
image: str
|
||||
|
||||
|
||||
@@ -93,23 +130,239 @@ class StatOut(ORMModel):
|
||||
id: int
|
||||
value: int
|
||||
suffix: str
|
||||
suffix_en: str = ""
|
||||
label: str
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class WhyUsItemOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class BeforeAfterOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
before: str
|
||||
after: str
|
||||
before_label: str
|
||||
before_label_en: str = ""
|
||||
after_label: str
|
||||
after_label_en: str = ""
|
||||
|
||||
|
||||
class NavItemOut(ORMModel):
|
||||
id: int
|
||||
label: str
|
||||
label_en: str = ""
|
||||
href: str
|
||||
|
||||
|
||||
class HeroOut(ORMModel):
|
||||
id: int
|
||||
kicker: str
|
||||
kicker_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
title_highlight: str
|
||||
title_highlight_en: str = ""
|
||||
subtitle: str
|
||||
subtitle_en: str = ""
|
||||
badge_value: str
|
||||
badge_value_en: str = ""
|
||||
badge_label: str
|
||||
badge_label_en: str = ""
|
||||
cta_primary_label: str
|
||||
cta_primary_label_en: str = ""
|
||||
cta_primary_href: str
|
||||
cta_secondary_label: str
|
||||
cta_secondary_label_en: str = ""
|
||||
cta_secondary_href: str
|
||||
scroll_label: str
|
||||
scroll_label_en: str = ""
|
||||
|
||||
|
||||
class StoryOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
paragraphs: list[str]
|
||||
paragraphs_en: Optional[list[str]] = None
|
||||
quote: str
|
||||
quote_en: str = ""
|
||||
quote_author: str
|
||||
quote_author_en: str = ""
|
||||
stat1_value: str
|
||||
stat1_value_en: str = ""
|
||||
stat1_label: str
|
||||
stat1_label_en: str = ""
|
||||
stat2_value: str
|
||||
stat2_value_en: str = ""
|
||||
stat2_label: str
|
||||
stat2_label_en: str = ""
|
||||
link_label: str
|
||||
link_label_en: str = ""
|
||||
link_href: str
|
||||
bento: list[dict]
|
||||
bento_en: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class SectionHeadingOut(ORMModel):
|
||||
id: int
|
||||
key: str
|
||||
label: str
|
||||
label_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
image: str
|
||||
cta_label: str
|
||||
cta_label_en: str = ""
|
||||
cta_href: str
|
||||
|
||||
|
||||
class PageHeroOut(ORMModel):
|
||||
id: int
|
||||
key: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
image: str
|
||||
|
||||
|
||||
class InvestmentModelOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
points: list[str]
|
||||
points_en: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ProcessStepOut(ORMModel):
|
||||
id: int
|
||||
step: str
|
||||
step_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class OfficeOut(ORMModel):
|
||||
id: int
|
||||
city: str
|
||||
city_en: str = ""
|
||||
address: str
|
||||
address_en: str = ""
|
||||
hours: str
|
||||
hours_en: str = ""
|
||||
|
||||
|
||||
class GalleryImageOut(ORMModel):
|
||||
id: int
|
||||
src: str
|
||||
alt: str
|
||||
alt_en: str = ""
|
||||
|
||||
|
||||
class StatusLabelOut(ORMModel):
|
||||
id: int
|
||||
key: str
|
||||
label: str
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class ContactSectionOut(ORMModel):
|
||||
id: int
|
||||
label: str
|
||||
label_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
address_label: str
|
||||
address_label_en: str = ""
|
||||
phone_label: str
|
||||
phone_label_en: str = ""
|
||||
email_label: str
|
||||
email_label_en: str = ""
|
||||
name_label: str
|
||||
name_label_en: str = ""
|
||||
name_placeholder: str
|
||||
name_placeholder_en: str = ""
|
||||
phone_field_label: str
|
||||
phone_field_label_en: str = ""
|
||||
phone_placeholder: str
|
||||
phone_placeholder_en: str = ""
|
||||
note_label: str
|
||||
note_label_en: str = ""
|
||||
note_optional_label: str
|
||||
note_optional_label_en: str = ""
|
||||
note_placeholder: str
|
||||
note_placeholder_en: str = ""
|
||||
submit_label: str
|
||||
submit_label_en: str = ""
|
||||
sending_label: str
|
||||
sending_label_en: str = ""
|
||||
success_title: str
|
||||
success_title_en: str = ""
|
||||
success_text: str
|
||||
success_text_en: str = ""
|
||||
success_button_label: str
|
||||
success_button_label_en: str = ""
|
||||
|
||||
|
||||
class NewsletterOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
placeholder: str
|
||||
placeholder_en: str = ""
|
||||
button_label: str
|
||||
button_label_en: str = ""
|
||||
sending_label: str
|
||||
sending_label_en: str = ""
|
||||
success_text: str
|
||||
success_text_en: str = ""
|
||||
|
||||
|
||||
class FooterOut(ORMModel):
|
||||
id: int
|
||||
about_text: str
|
||||
about_text_en: str = ""
|
||||
pages_heading: str
|
||||
pages_heading_en: str = ""
|
||||
services_heading: str
|
||||
services_heading_en: str = ""
|
||||
contact_heading: str
|
||||
contact_heading_en: str = ""
|
||||
copyright: str
|
||||
copyright_en: str = ""
|
||||
tagline: str
|
||||
tagline_en: str = ""
|
||||
socials: list[dict]
|
||||
socials_en: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class SeoMetaOut(ORMModel):
|
||||
id: int
|
||||
page_key: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
description: str
|
||||
description_en: str = ""
|
||||
|
||||
|
||||
class ContactMessageOut(ORMModel):
|
||||
@@ -135,10 +388,14 @@ class ContactIn(BaseModel):
|
||||
name: str = Field(min_length=3, max_length=128)
|
||||
phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$")
|
||||
note: str = Field(default="", max_length=2000)
|
||||
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمیکند
|
||||
website: str = Field(default="", max_length=128)
|
||||
|
||||
|
||||
class NewsletterIn(BaseModel):
|
||||
email: EmailStr
|
||||
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمیکند
|
||||
website: str = Field(default="", max_length=128)
|
||||
|
||||
|
||||
# ---------- ورودیهای پنل مدیریت ----------
|
||||
@@ -154,8 +411,14 @@ class TokenOut(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class BrandIn(BaseModel):
|
||||
name: str = ""
|
||||
name_en: str = ""
|
||||
logo: str = ""
|
||||
hero: str = ""
|
||||
contact_bg: str = ""
|
||||
@@ -163,84 +426,399 @@ class BrandIn(BaseModel):
|
||||
phone_href: str = ""
|
||||
email: str = ""
|
||||
address: str = ""
|
||||
address_en: str = ""
|
||||
|
||||
|
||||
class ServiceIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
icon: str = "draft"
|
||||
|
||||
|
||||
class ProjectIn(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=128)
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
location: str = ""
|
||||
location_en: str = ""
|
||||
year: str = ""
|
||||
year_en: str = ""
|
||||
status: Literal["done", "progress"] = "done"
|
||||
image: str = ""
|
||||
gallery: list[str] = []
|
||||
area: str = ""
|
||||
area_en: str = ""
|
||||
floors: str = ""
|
||||
floors_en: str = ""
|
||||
units: str = ""
|
||||
units_en: str = ""
|
||||
client: str = ""
|
||||
client_en: str = ""
|
||||
summary: str = ""
|
||||
summary_en: str = ""
|
||||
body: list[str] = []
|
||||
body_en: list[str] = []
|
||||
|
||||
|
||||
EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
|
||||
def validate_doc_body(value: object) -> dict:
|
||||
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد و
|
||||
آدرسهای href/src آن به اسکیمهای امن محدود شوند."""
|
||||
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 sanitize_doc(value)
|
||||
|
||||
|
||||
class ArticleIn(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=128)
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
date: str = ""
|
||||
date_en: str = ""
|
||||
category: str = ""
|
||||
category_en: str = ""
|
||||
image: str = ""
|
||||
read: str = ""
|
||||
read_en: str = ""
|
||||
excerpt: str = ""
|
||||
body: list[str] = []
|
||||
excerpt_en: str = ""
|
||||
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||
status: Literal["draft", "published"] = "draft"
|
||||
seo_keyphrase: str = Field(default="", max_length=128)
|
||||
_validate_body = field_validator("body")(validate_doc_body)
|
||||
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||
|
||||
|
||||
class TestimonialIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
name_en: str = ""
|
||||
role: str = ""
|
||||
role_en: str = ""
|
||||
quote: str = ""
|
||||
quote_en: str = ""
|
||||
|
||||
|
||||
class FaqIn(BaseModel):
|
||||
question: str = Field(min_length=1)
|
||||
question_en: str = ""
|
||||
answer: str = ""
|
||||
answer_en: str = ""
|
||||
|
||||
|
||||
class TeamMemberIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
name_en: str = ""
|
||||
role: str = ""
|
||||
role_en: str = ""
|
||||
bio: str = ""
|
||||
bio_en: str = ""
|
||||
|
||||
|
||||
class TimelineItemIn(BaseModel):
|
||||
year: str = Field(min_length=1, max_length=32)
|
||||
year_en: str = ""
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
image: str = ""
|
||||
|
||||
|
||||
class StatIn(BaseModel):
|
||||
value: int
|
||||
suffix: str = ""
|
||||
suffix_en: str = ""
|
||||
label: str = Field(min_length=1, max_length=128)
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class WhyUsItemIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class BeforeAfterIn(BaseModel):
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
before: str = ""
|
||||
after: str = ""
|
||||
before_label: str = ""
|
||||
before_label_en: str = ""
|
||||
after_label: str = ""
|
||||
after_label_en: str = ""
|
||||
|
||||
|
||||
class NavItemIn(BaseModel):
|
||||
label: str = Field(min_length=1, max_length=64)
|
||||
label_en: str = ""
|
||||
href: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class HeroIn(BaseModel):
|
||||
kicker: str = ""
|
||||
kicker_en: str = ""
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
title_highlight: str = ""
|
||||
title_highlight_en: str = ""
|
||||
subtitle: str = ""
|
||||
subtitle_en: str = ""
|
||||
badge_value: str = ""
|
||||
badge_value_en: str = ""
|
||||
badge_label: str = ""
|
||||
badge_label_en: str = ""
|
||||
cta_primary_label: str = ""
|
||||
cta_primary_label_en: str = ""
|
||||
cta_primary_href: str = ""
|
||||
cta_secondary_label: str = ""
|
||||
cta_secondary_label_en: str = ""
|
||||
cta_secondary_href: str = ""
|
||||
scroll_label: str = ""
|
||||
scroll_label_en: str = ""
|
||||
|
||||
|
||||
class StoryIn(BaseModel):
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
paragraphs: list[str] = []
|
||||
paragraphs_en: list[str] = []
|
||||
quote: str = ""
|
||||
quote_en: str = ""
|
||||
quote_author: str = ""
|
||||
quote_author_en: str = ""
|
||||
stat1_value: str = ""
|
||||
stat1_value_en: str = ""
|
||||
stat1_label: str = ""
|
||||
stat1_label_en: str = ""
|
||||
stat2_value: str = ""
|
||||
stat2_value_en: str = ""
|
||||
stat2_label: str = ""
|
||||
stat2_label_en: str = ""
|
||||
link_label: str = ""
|
||||
link_label_en: str = ""
|
||||
link_href: str = ""
|
||||
bento: list[dict] = []
|
||||
bento_en: list[dict] = []
|
||||
|
||||
|
||||
class SectionHeadingIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=64)
|
||||
label: str = ""
|
||||
label_en: str = ""
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
image: str = ""
|
||||
cta_label: str = ""
|
||||
cta_label_en: str = ""
|
||||
cta_href: str = ""
|
||||
|
||||
|
||||
class PageHeroIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=32)
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
image: str = ""
|
||||
|
||||
|
||||
class InvestmentModelIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
points: list[str] = []
|
||||
points_en: list[str] = []
|
||||
|
||||
|
||||
class ProcessStepIn(BaseModel):
|
||||
step: str = ""
|
||||
step_en: str = ""
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class OfficeIn(BaseModel):
|
||||
city: str = Field(min_length=1, max_length=64)
|
||||
city_en: str = ""
|
||||
address: str = ""
|
||||
address_en: str = ""
|
||||
hours: str = ""
|
||||
hours_en: str = ""
|
||||
|
||||
|
||||
class GalleryImageIn(BaseModel):
|
||||
src: str = ""
|
||||
alt: str = ""
|
||||
alt_en: str = ""
|
||||
|
||||
|
||||
class StatusLabelIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=16)
|
||||
label: str = Field(min_length=1, max_length=64)
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class ContactSectionIn(BaseModel):
|
||||
label: str = ""
|
||||
label_en: str = ""
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
address_label: str = ""
|
||||
address_label_en: str = ""
|
||||
phone_label: str = ""
|
||||
phone_label_en: str = ""
|
||||
email_label: str = ""
|
||||
email_label_en: str = ""
|
||||
name_label: str = ""
|
||||
name_label_en: str = ""
|
||||
name_placeholder: str = ""
|
||||
name_placeholder_en: str = ""
|
||||
phone_field_label: str = ""
|
||||
phone_field_label_en: str = ""
|
||||
phone_placeholder: str = ""
|
||||
phone_placeholder_en: str = ""
|
||||
note_label: str = ""
|
||||
note_label_en: str = ""
|
||||
note_optional_label: str = ""
|
||||
note_optional_label_en: str = ""
|
||||
note_placeholder: str = ""
|
||||
note_placeholder_en: str = ""
|
||||
submit_label: str = ""
|
||||
submit_label_en: str = ""
|
||||
sending_label: str = ""
|
||||
sending_label_en: str = ""
|
||||
success_title: str = ""
|
||||
success_title_en: str = ""
|
||||
success_text: str = ""
|
||||
success_text_en: str = ""
|
||||
success_button_label: str = ""
|
||||
success_button_label_en: str = ""
|
||||
|
||||
|
||||
class NewsletterSectionIn(BaseModel):
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
placeholder: str = ""
|
||||
placeholder_en: str = ""
|
||||
button_label: str = ""
|
||||
button_label_en: str = ""
|
||||
sending_label: str = ""
|
||||
sending_label_en: str = ""
|
||||
success_text: str = ""
|
||||
success_text_en: str = ""
|
||||
|
||||
|
||||
class FooterIn(BaseModel):
|
||||
about_text: str = ""
|
||||
about_text_en: str = ""
|
||||
pages_heading: str = ""
|
||||
pages_heading_en: str = ""
|
||||
services_heading: str = ""
|
||||
services_heading_en: str = ""
|
||||
contact_heading: str = ""
|
||||
contact_heading_en: str = ""
|
||||
copyright: str = ""
|
||||
copyright_en: str = ""
|
||||
tagline: str = ""
|
||||
tagline_en: str = ""
|
||||
socials: list[dict] = []
|
||||
socials_en: list[dict] = []
|
||||
|
||||
|
||||
class SeoMetaIn(BaseModel):
|
||||
page_key: str = Field(min_length=1, max_length=32)
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
description: str = ""
|
||||
description_en: str = ""
|
||||
|
||||
|
||||
class SeoMetaPatch(BaseModel):
|
||||
page_key: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
|
||||
|
||||
class MessageReadIn(BaseModel):
|
||||
is_read: bool = True
|
||||
|
||||
|
||||
# ---------- اسکیماهای PATCH (ویرایش جزئی — همه فیلدها اختیاری) ----------
|
||||
|
||||
|
||||
def _make_patch(name: str, model_cls: type[BaseModel]) -> type[BaseModel]:
|
||||
"""از اسکیمای ورودی، اسکیمای PATCH میسازد؛ فیلدهای ارسالنشده دستنخورده میمانند."""
|
||||
fields = {fname: (Optional[f.annotation], None) for fname, f in model_cls.model_fields.items()}
|
||||
return create_model(name, **fields)
|
||||
|
||||
|
||||
BrandPatch = _make_patch("BrandPatch", BrandIn)
|
||||
ServicePatch = _make_patch("ServicePatch", ServiceIn)
|
||||
ProjectPatch = _make_patch("ProjectPatch", ProjectIn)
|
||||
class ArticlePatch(BaseModel):
|
||||
slug: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
date_en: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
category_en: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
read: Optional[str] = None
|
||||
read_en: Optional[str] = None
|
||||
excerpt: Optional[str] = None
|
||||
excerpt_en: Optional[str] = None
|
||||
body: Optional[dict] = None
|
||||
body_en: Optional[dict] = None
|
||||
status: Optional[Literal["draft", "published"]] = None
|
||||
seo_keyphrase: Optional[str] = None
|
||||
_validate_body = field_validator("body")(validate_doc_body)
|
||||
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
|
||||
FaqPatch = _make_patch("FaqPatch", FaqIn)
|
||||
TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn)
|
||||
TimelineItemPatch = _make_patch("TimelineItemPatch", TimelineItemIn)
|
||||
StatPatch = _make_patch("StatPatch", StatIn)
|
||||
WhyUsItemPatch = _make_patch("WhyUsItemPatch", WhyUsItemIn)
|
||||
BeforeAfterPatch = _make_patch("BeforeAfterPatch", BeforeAfterIn)
|
||||
NavItemPatch = _make_patch("NavItemPatch", NavItemIn)
|
||||
HeroPatch = _make_patch("HeroPatch", HeroIn)
|
||||
StoryPatch = _make_patch("StoryPatch", StoryIn)
|
||||
SectionHeadingPatch = _make_patch("SectionHeadingPatch", SectionHeadingIn)
|
||||
PageHeroPatch = _make_patch("PageHeroPatch", PageHeroIn)
|
||||
InvestmentModelPatch = _make_patch("InvestmentModelPatch", InvestmentModelIn)
|
||||
ProcessStepPatch = _make_patch("ProcessStepPatch", ProcessStepIn)
|
||||
OfficePatch = _make_patch("OfficePatch", OfficeIn)
|
||||
GalleryImagePatch = _make_patch("GalleryImagePatch", GalleryImageIn)
|
||||
StatusLabelPatch = _make_patch("StatusLabelPatch", StatusLabelIn)
|
||||
ContactSectionPatch = _make_patch("ContactSectionPatch", ContactSectionIn)
|
||||
NewsletterSectionPatch = _make_patch("NewsletterSectionPatch", NewsletterSectionIn)
|
||||
FooterPatch = _make_patch("FooterPatch", FooterIn)
|
||||
|
||||
|
||||
class ReorderIn(BaseModel):
|
||||
"""ترتیب جدید آیتمهای یک مجموعه بر اساس id."""
|
||||
|
||||
ids: list[int]
|
||||
|
||||
+82
-40
@@ -1,16 +1,79 @@
|
||||
"""اسکریپت seed دیتابیس.
|
||||
|
||||
اجرا:
|
||||
python -m app.seed # فقط اگر دیتابیس خالی باشد
|
||||
python -m app.seed # فقط جدولهای خالی پر میشوند
|
||||
python -m app.seed --force # حذف کامل دادهها و ساخت مجدد
|
||||
|
||||
پر کردن هر جدول مستقل است: روی یک دیتابیس موجود، فقط جدولهای جدید یا خالی
|
||||
seed میشوند و دادههای قبلی دستنخورده میمانند.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from typing import Any
|
||||
|
||||
from . import models, seed_data
|
||||
from . import models, seed_data, seed_data_en
|
||||
from .auth import hash_password
|
||||
from .config import settings
|
||||
from .database import Base, SessionLocal, engine
|
||||
from .migrations import paragraphs_to_doc, run_all as run_migrations
|
||||
|
||||
# (مدل، دادههای اولیه، برچسب گزارش)
|
||||
CONTENT: list[tuple[Any, list[dict], str]] = [
|
||||
(models.Brand, [seed_data.BRAND], "برند"),
|
||||
(models.Hero, [seed_data.HERO], "هیرو صفحه اصلی"),
|
||||
(models.StorySection, [seed_data.STORY], "داستان ما"),
|
||||
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, "تیتر بخشها"),
|
||||
(models.PageHeroContent, seed_data.PAGE_HEROES, "هیرو صفحات"),
|
||||
(models.NavItem, seed_data.NAV_ITEMS, "منوی ناوبری"),
|
||||
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, "مدلهای سرمایهگذاری"),
|
||||
(models.ProcessStep, seed_data.PROCESS_STEPS, "مراحل فرآیند کار"),
|
||||
(models.Office, seed_data.OFFICES, "دفاتر"),
|
||||
(models.GalleryImage, seed_data.GALLERY_IMAGES, "گالری تصاویر"),
|
||||
(models.StatusLabel, seed_data.STATUS_LABELS, "برچسب وضعیت پروژه"),
|
||||
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], "بخش تماس"),
|
||||
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], "خبرنامه"),
|
||||
(models.FooterContent, [seed_data.FOOTER], "فوتر"),
|
||||
(models.Service, seed_data.SERVICES, "خدمات"),
|
||||
(models.Stat, seed_data.STATS, "آمار"),
|
||||
(models.Project, seed_data.PROJECTS, "پروژهها"),
|
||||
(models.Article, seed_data.ARTICLES, "مقالات"),
|
||||
(models.Testimonial, seed_data.TESTIMONIALS, "نظرات"),
|
||||
(models.Faq, seed_data.FAQS, "سوالات متداول"),
|
||||
(models.TeamMember, seed_data.TEAM, "تیم"),
|
||||
(models.TimelineItem, seed_data.TIMELINE, "تایملاین"),
|
||||
(models.WhyUsItem, seed_data.WHY, "چرا آرامش"),
|
||||
(models.BeforeAfter, [seed_data.BEFORE_AFTER], "قبل/بعد"),
|
||||
(models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"),
|
||||
]
|
||||
|
||||
# ترجمه انگلیسی همان جدولها — همترتیب با CONTENT تا با zip ادغام شود
|
||||
EN_CONTENT: list[list[dict]] = [
|
||||
[seed_data_en.BRAND],
|
||||
[seed_data_en.HERO],
|
||||
[seed_data_en.STORY],
|
||||
seed_data_en.SECTION_HEADINGS,
|
||||
seed_data_en.PAGE_HEROES,
|
||||
seed_data_en.NAV_ITEMS,
|
||||
seed_data_en.INVESTMENT_MODELS,
|
||||
seed_data_en.PROCESS_STEPS,
|
||||
seed_data_en.OFFICES,
|
||||
seed_data_en.GALLERY_IMAGES,
|
||||
seed_data_en.STATUS_LABELS,
|
||||
[seed_data_en.CONTACT_SECTION],
|
||||
[seed_data_en.NEWSLETTER],
|
||||
[seed_data_en.FOOTER],
|
||||
seed_data_en.SERVICES,
|
||||
seed_data_en.STATS,
|
||||
seed_data_en.PROJECTS,
|
||||
seed_data_en.ARTICLES,
|
||||
seed_data_en.TESTIMONIALS,
|
||||
seed_data_en.FAQS,
|
||||
seed_data_en.TEAM,
|
||||
seed_data_en.TIMELINE,
|
||||
seed_data_en.WHY,
|
||||
[seed_data_en.BEFORE_AFTER],
|
||||
seed_data_en.SEO_META,
|
||||
]
|
||||
|
||||
|
||||
def seed(force: bool = False) -> None:
|
||||
@@ -21,32 +84,22 @@ def seed(force: bool = False) -> None:
|
||||
print("جداول قبلی حذف شدند.")
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
if not force and db.query(models.Service).count() > 0:
|
||||
print("دیتابیس قبلاً seed شده است. برای بازنشانی کامل: python -m app.seed --force")
|
||||
return
|
||||
|
||||
db.add(models.Brand(**seed_data.BRAND))
|
||||
|
||||
for item in seed_data.SERVICES:
|
||||
db.add(models.Service(**item))
|
||||
for item in seed_data.STATS:
|
||||
db.add(models.Stat(**item))
|
||||
for item in seed_data.PROJECTS:
|
||||
db.add(models.Project(**item))
|
||||
for item in seed_data.ARTICLES:
|
||||
db.add(models.Article(**item))
|
||||
for item in seed_data.TESTIMONIALS:
|
||||
db.add(models.Testimonial(**item))
|
||||
for item in seed_data.FAQS:
|
||||
db.add(models.Faq(**item))
|
||||
for item in seed_data.TEAM:
|
||||
db.add(models.TeamMember(**item))
|
||||
for item in seed_data.TIMELINE:
|
||||
db.add(models.TimelineItem(**item))
|
||||
for item in seed_data.WHY:
|
||||
db.add(models.WhyUsItem(**item))
|
||||
db.add(models.BeforeAfter(**seed_data.BEFORE_AFTER))
|
||||
for (model, rows, label), en_rows in zip(CONTENT, EN_CONTENT):
|
||||
if db.query(model).count() > 0:
|
||||
continue
|
||||
for idx, row in enumerate(rows):
|
||||
# فایل فارسی مرجع است؛ فقط فیلدهای *_en از فایل انگلیسی اضافه میشود
|
||||
payload = {**dict(en_rows[idx]), **dict(row)} if idx < len(en_rows) else dict(row)
|
||||
if hasattr(model, "position") and "position" not in payload:
|
||||
payload["position"] = idx
|
||||
# بدنه مقاله (فارسی و انگلیسی) در seed بهصورت لیست پاراگراف است — به سند تبدیل میشود
|
||||
if model is models.Article:
|
||||
for body_key in ("body", "body_en"):
|
||||
if isinstance(payload.get(body_key), list):
|
||||
payload[body_key] = paragraphs_to_doc(payload[body_key])
|
||||
db.add(model(**payload))
|
||||
|
||||
if db.query(models.AdminUser).count() == 0:
|
||||
db.add(
|
||||
@@ -59,19 +112,8 @@ def seed(force: bool = False) -> None:
|
||||
|
||||
db.commit()
|
||||
|
||||
counts = {
|
||||
"خدمات": db.query(models.Service).count(),
|
||||
"پروژهها": db.query(models.Project).count(),
|
||||
"مقالات": db.query(models.Article).count(),
|
||||
"نظرات": db.query(models.Testimonial).count(),
|
||||
"سوالات متداول": db.query(models.Faq).count(),
|
||||
"تیم": db.query(models.TeamMember).count(),
|
||||
"تایملاین": db.query(models.TimelineItem).count(),
|
||||
"آمار": db.query(models.Stat).count(),
|
||||
"چرا آرامش": db.query(models.WhyUsItem).count(),
|
||||
}
|
||||
for label, count in counts.items():
|
||||
print(f" {label}: {count} رکورد")
|
||||
for model, _, label in CONTENT:
|
||||
print(f" {label}: {db.query(model).count()} رکورد")
|
||||
print("✔ seed با موفقیت انجام شد.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -289,3 +289,170 @@ FAQS = [
|
||||
{"question": "خدمات پس از تحویل شامل چه مواردی میشود؟", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دورهای سالانه و تضمین اجرای نما و عایقکاری."},
|
||||
{"question": "پروژهها در چه شهرهایی اجرا میشوند؟", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژهای نیز در جزیره کیش در دست ساخت است."},
|
||||
]
|
||||
|
||||
# ---------- محتوای قبلاً هاردکدشده در فرانتاند (الان در دیتابیس) ----------
|
||||
|
||||
NAV_ITEMS = [
|
||||
{"label": "صفحه اصلی", "href": "/"},
|
||||
{"label": "درباره ما", "href": "/about"},
|
||||
{"label": "خدمات", "href": "/services"},
|
||||
{"label": "پروژهها", "href": "/projects"},
|
||||
{"label": "سرمایهگذاری", "href": "/investment"},
|
||||
{"label": "وبلاگ", "href": "/blog"},
|
||||
{"label": "سوالات متداول", "href": "/faq"},
|
||||
{"label": "تماس با ما", "href": "/contact"},
|
||||
]
|
||||
|
||||
HERO = {
|
||||
"kicker": "از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال",
|
||||
"title": "ساختمانی که آرامش را معماری میکند",
|
||||
"title_highlight": "معماری",
|
||||
"subtitle": "طراحی، ساخت و سرمایهگذاری در پروژههای مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده میشود و در سالها دوام میآورد.",
|
||||
"badge_value": "۴۷",
|
||||
"badge_label": "پروژه تحویلشده در بیستویک سال فعالیت حرفهای",
|
||||
"cta_primary_label": "مشاهده پروژهها",
|
||||
"cta_primary_href": "/projects",
|
||||
"cta_secondary_label": "مشاوره سرمایهگذاری",
|
||||
"cta_secondary_href": "/contact",
|
||||
"scroll_label": "اسکرول کنید",
|
||||
}
|
||||
|
||||
STORY = {
|
||||
"title": "درباره ی ما",
|
||||
"paragraphs": [
|
||||
"گروه ساختمانی آرامش با یک دفتر کوچک معماری آغاز شد و امروز یکی از مجموعههای شناختهشده ساختوساز لوکس در ایران است. در تمام این سالها یک اصل تغییر نکرده است: ساختمان باید برای زندگی ساخته شود، نه برای فروش سریع.",
|
||||
"هر پروژه با مطالعه زمین، نور، اقلیم و الگوی زندگی ساکنان آغاز میشود؛ سپس تیم طراحی، سازه و اجرا در یک فرآیند واحد آن را تا تحویل کلید پیش میبرند.",
|
||||
],
|
||||
"quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی میسازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»",
|
||||
"quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
|
||||
"stat1_value": "۹۴٪",
|
||||
"stat1_label": "تحویل بهموقع پروژهها",
|
||||
"stat2_value": "۵ سال",
|
||||
"stat2_label": "پشتیبانی پس از تحویل",
|
||||
"link_label": "داستان کامل ما",
|
||||
"link_href": "/about",
|
||||
"bento": [
|
||||
{"src": IMAGES[0], "alt": "نمای برج مسکونی آرامش در تهران"},
|
||||
{"src": IMAGES[1], "alt": "آتریوم مرکزی مجتمع تجاری پارسه"},
|
||||
{"src": IMAGES[2], "alt": "ویلاهای ساختهشده در سواحل شمال"},
|
||||
{"src": IMAGES[3], "alt": "نمای شبانه برج دوقلوی نیاوران"},
|
||||
],
|
||||
}
|
||||
|
||||
SECTION_HEADINGS = [
|
||||
{"key": "services", "title": "خدمات", "lead": "از نخستین خط روی کاغذ تا تحویل کلید، مدیریت سرمایه و طراحی محوطه؛ همه مراحل در یک مجموعه و زیر یک نظام کنترل کیفیت."},
|
||||
{"key": "featured_projects", "title": "پروژههای شاخص", "lead": "کارهایی که بیشترین چیز را درباره استاندارد ما میگویند؛ چه آنهایی که تحویل شدهاند و چه آنهایی که همین امروز در حال ساختاند.", "cta_label": "همه پروژهها", "cta_href": "/projects"},
|
||||
{"key": "gallery", "title": "گالری تصاویر", "lead": "قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."},
|
||||
{"key": "timeline", "title": "مسیر ما", "lead": "از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژههای ما را ببینید."},
|
||||
{"key": "why_us", "title": "چرا گروه ساختمانی آرامش", "lead": "شش دلیلی که کارفرمایان و سرمایهگذاران بار دوم هم به ما برمیگردند.", "image": IMAGES[3]},
|
||||
{"key": "testimonials", "title": "نظرات مشتریان"},
|
||||
{"key": "team", "title": "تیم ما", "lead": "کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."},
|
||||
{"key": "articles", "label": "وبلاگ", "title": "آخرین مقالات", "lead": "یادداشتهای تیم معماری، اجرا و سرمایهگذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار میگذرد.", "cta_label": "همه مقالات", "cta_href": "/blog"},
|
||||
{"key": "faq", "title": "سوالات متداول", "lead": "پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."},
|
||||
{"key": "investment_models", "title": "مدلهای همکاری", "lead": "بسته به میزان سرمایه، افق زمانی و سطح ریسکی که میپذیرید، یکی از این سه مسیر مناسب شماست."},
|
||||
{"key": "process", "title": "فرآیند کار ما", "lead": "چهار مرحله شفاف، از نخستین جلسه تا تحویل کلید."},
|
||||
]
|
||||
|
||||
PAGE_HEROES = [
|
||||
{"key": "about", "title": "درباره ما", "lead": "گروه ساختمانی آرامش مجموعهای از معماران، مهندسان سازه و مدیران پروژه است که از سال ۱۳۸۳ در حوزه ساختوساز لوکس فعالیت میکند.", "image": IMAGES[3]},
|
||||
{"key": "services", "title": "خدمات", "lead": "هشت حوزه تخصصی که چرخه کامل یک پروژه ساختمانی را از ایده تا بهرهبرداری پوشش میدهند.", "image": IMAGES[1]},
|
||||
{"key": "projects", "title": "پروژهها", "lead": "مجموعهای از برجهای مسکونی، مراکز تجاری و ویلاهای اختصاصی؛ از پروژههای تحویلشده تا کارگاههای فعال امروز.", "image": IMAGES[0]},
|
||||
{"key": "investment", "title": "سرمایهگذاری", "lead": "سه مسیر روشن برای ورود به بازار ساختوساز لوکس، با ساختار مالی شفاف و گزارشدهی منظم.", "image": IMAGES[4]},
|
||||
{"key": "blog", "title": "وبلاگ", "lead": "یادداشتهای تیم معماری، اجرا و سرمایهگذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار میگذرد.", "image": IMAGES[2]},
|
||||
{"key": "faq", "title": "سوالات متداول", "lead": "پیش از تماس، شاید پاسخ پرسش شما همینجا باشد. اگر نبود، خوشحال میشویم مستقیم صحبت کنیم.", "image": IMAGES[1]},
|
||||
{"key": "contact", "title": "تماس با ما", "lead": "برای مشاوره ساخت، سرمایهگذاری یا بازدید از پروژهها، تیم ما در سه شهر آماده پاسخگویی است.", "image": IMAGES[1]},
|
||||
]
|
||||
|
||||
INVESTMENT_MODELS = [
|
||||
{
|
||||
"title": "مشارکت در ساخت",
|
||||
"desc": "مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک میشوند؛ اجرا، مجوزها و فروش بر عهده ماست.",
|
||||
"points": ["ارزیابی رایگان زمین", "قرارداد شفاف با ضمانت اجرا", "گزارش ماهانه پیشرفت"],
|
||||
},
|
||||
{
|
||||
"title": "صندوق پروژهمحور",
|
||||
"desc": "سرمایهگذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبهشده در پایان دوره.",
|
||||
"points": ["حداقل دوره ۲۴ ماه", "گزارش ارزشگذاری فصلی", "امکان انتقال واحد سرمایه"],
|
||||
},
|
||||
{
|
||||
"title": "پیشخرید واحد",
|
||||
"desc": "خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحلهای.",
|
||||
"points": ["پرداخت مرحلهای متناسب با پیشرفت", "امکان شخصیسازی پلان", "تضمین زمان تحویل"],
|
||||
},
|
||||
]
|
||||
|
||||
PROCESS_STEPS = [
|
||||
{"step": "۰۱", "title": "مطالعه و امکانسنجی", "desc": "بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه."},
|
||||
{"step": "۰۲", "title": "طراحی و فاز اجرایی", "desc": "کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشههای اجرایی."},
|
||||
{"step": "۰۳", "title": "اجرا و کنترل کیفیت", "desc": "ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه."},
|
||||
{"step": "۰۴", "title": "تحویل و پشتیبانی", "desc": "تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی."},
|
||||
]
|
||||
|
||||
OFFICES = [
|
||||
{"city": "تهران", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸"},
|
||||
{"city": "اصفهان", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷"},
|
||||
{"city": "نوشهر", "address": "بلوار دریا، دفتر پروژه دریاکنار", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶"},
|
||||
]
|
||||
|
||||
GALLERY_IMAGES = [
|
||||
{"src": IMAGES[0], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۱"},
|
||||
{"src": IMAGES[1], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۲"},
|
||||
{"src": IMAGES[2], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۳"},
|
||||
{"src": IMAGES[3], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۴"},
|
||||
{"src": IMAGES[4], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۵"},
|
||||
{"src": IMAGES[1], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۶"},
|
||||
]
|
||||
|
||||
STATUS_LABELS = [
|
||||
{"key": "done", "label": "انجام شده"},
|
||||
{"key": "progress", "label": "در حال انجام"},
|
||||
]
|
||||
|
||||
CONTACT_SECTION = {
|
||||
"label": "تماس با ما",
|
||||
"title": "پروژهتان را با ما در میان بگذارید",
|
||||
"lead": "شمارهتان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس میگیرد.",
|
||||
"address_label": "دفتر مرکزی",
|
||||
"phone_label": "تلفن",
|
||||
"email_label": "ایمیل",
|
||||
"name_label": "نام",
|
||||
"name_placeholder": "نام و نام خانوادگی",
|
||||
"phone_field_label": "شماره تلفن",
|
||||
"phone_placeholder": "09xx xxx xxxx",
|
||||
"note_label": "توضیحات",
|
||||
"note_optional_label": "(اختیاری)",
|
||||
"note_placeholder": "درباره پروژه یا درخواست خود بنویسید…",
|
||||
"submit_label": "ارسال درخواست مشاوره",
|
||||
"sending_label": "در حال ارسال…",
|
||||
"success_title": "درخواست شما ثبت شد",
|
||||
"success_text": "از اعتماد شما سپاسگزاریم. کارشناسان ما بهزودی با شما تماس میگیرند.",
|
||||
"success_button_label": "ثبت درخواست جدید",
|
||||
}
|
||||
|
||||
NEWSLETTER = {
|
||||
"title": "خبرنامه گروه ساختمانی آرامش",
|
||||
"lead": "هر ماه یک ایمیل: پروژههای جدید، فرصتهای سرمایهگذاری و یادداشتهای تیم معماری.",
|
||||
"placeholder": "نشانی ایمیل شما",
|
||||
"button_label": "عضویت",
|
||||
"sending_label": "در حال ارسال…",
|
||||
"success_text": "عضویت شما ثبت شد.",
|
||||
}
|
||||
|
||||
FOOTER = {
|
||||
"about_text": "گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمانهایی میسازد که قرار است چند نسل در آنها زندگی کنند؛ معماری آرام، اجرای دقیق و سرمایهگذاری شفاف.",
|
||||
"pages_heading": "صفحات",
|
||||
"services_heading": "خدمات",
|
||||
"contact_heading": "ارتباط با ما",
|
||||
"copyright": "© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.",
|
||||
"tagline": "طراحی و ساخت با دقتِ معماری",
|
||||
"socials": [
|
||||
{"label": "اینستاگرام", "href": "#", "icon": "instagram"},
|
||||
{"label": "لینکدین", "href": "#", "icon": "linkedin"},
|
||||
{"label": "تلگرام", "href": "#", "icon": "telegram"},
|
||||
],
|
||||
}
|
||||
|
||||
SEO_META = [
|
||||
{"page_key": key, "title": "", "description": ""}
|
||||
for key in ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
|
||||
]
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
"""ترجمه انگلیسی دادههای اولیه سایت — ساختار یکبهیک با seed_data.py.
|
||||
|
||||
هر فهرست همطول و همترتیب با معادل فارسی خود است تا seed.py بتواند با zip
|
||||
ادغام کند و backfill مهاجرت بتواند سطرهای موجود را پر کند. فقط فیلدهای
|
||||
`*_en` ملاک ادغاماند؛ کلیدهای همنام با فارسی (مثل title یا body) صرفاً
|
||||
برای خواندن راحتتر مترجم آمدهاند و در ادغام نادیده گرفته میشوند.
|
||||
فیلدهای تطبیق (slug، key، page_key، src) عیناً مقدار فارسیاند.
|
||||
"""
|
||||
|
||||
from .seed_data import IMAGES
|
||||
|
||||
BRAND = {
|
||||
"name": "گروه ساختمانی آرامش",
|
||||
"name_en": "Aramesh Construction Group",
|
||||
"address": "تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم",
|
||||
"address_en": "Valiasr St., Aramesh Tower, 18th Floor, Tehran, Iran",
|
||||
}
|
||||
|
||||
SERVICES = [
|
||||
{"title": "طراحی معماری", "title_en": "Architectural Design", "desc": "خلق فرمهای معاصر با نگاه ایرانی؛ از کانسپت اولیه تا نقشههای اجرایی و جزئیات نما.", "desc_en": "Contemporary forms with an Iranian eye — from the first concept to construction drawings and facade details.", "icon": "draft"},
|
||||
{"title": "ساخت برجهای مسکونی", "title_en": "Residential Towers", "desc": "اجرای برجهای بلندمرتبه با استانداردهای سازهای بینالمللی و کیفیت تحویل بینقص.", "desc_en": "High-rise execution built on international structural standards and a flawless handover quality.", "icon": "tower"},
|
||||
{"title": "مجتمعهای تجاری", "title_en": "Commercial Complexes", "desc": "طراحی و ساخت مراکز تجاری با مطالعه دقیق جریان مخاطب و بازدهی بلندمدت سرمایه.", "desc_en": "Retail destinations designed and built around precise visitor-flow studies and long-term returns.", "icon": "mall"},
|
||||
{"title": "ویلاسازی", "title_en": "Private Villas", "desc": "ویلاهای اختصاصی همنشین با طبیعت؛ محرمانگی، نور طبیعی و مصالح ماندگار.", "desc_en": "Bespoke villas in harmony with nature — privacy, natural light and enduring materials.", "icon": "villa"},
|
||||
{"title": "بازسازی", "title_en": "Renovation", "desc": "بازآفرینی ساختمانهای موجود با ارتقای سازه، نما و تجربه زندگی در فضا.", "desc_en": "Reimagining existing buildings with upgraded structure, facades and living experience.", "icon": "renew"},
|
||||
{"title": "مدیریت پروژه", "title_en": "Project Management", "desc": "کنترل همزمان زمان، هزینه و کیفیت با گزارشدهی شفاف در تمام مراحل ساخت.", "desc_en": "Controlling time, cost and quality in parallel with transparent reporting at every stage.", "icon": "manage"},
|
||||
{"title": "سرمایهگذاری ساختمانی", "title_en": "Real Estate Investment", "desc": "ساختارهای مشارکت شفاف، ارزیابی ریسک و طراحی سبد ملکی متناسب با سرمایه شما.", "desc_en": "Transparent partnership structures, risk assessment and property portfolios tailored to your capital.", "icon": "invest"},
|
||||
{"title": "محوطهسازی و لنداسکیپ", "title_en": "Landscape Design", "desc": "طراحی حیاط، بام سبز و مسیرهای دسترسی؛ ادامه معماری در فضای باز پروژه.", "desc_en": "Courtyards, green roofs and pathways — architecture carried on into the open air.", "icon": "landscape"},
|
||||
]
|
||||
|
||||
STATS = [
|
||||
{"value": 21, "suffix": "+", "suffix_en": "+", "label": "سال تجربه ساخت", "label_en": "Years of building experience"},
|
||||
{"value": 47, "suffix": "", "suffix_en": "", "label": "پروژه تحویلشده", "label_en": "Projects delivered"},
|
||||
{"value": 860, "suffix": "هزار", "suffix_en": "K", "label": "مترمربع زیربنا", "label_en": "Square meters built"},
|
||||
{"value": 1200, "suffix": "+", "suffix_en": "+", "label": "خانواده ساکن", "label_en": "Resident families"},
|
||||
]
|
||||
|
||||
PROJECTS = [
|
||||
{
|
||||
"slug": "aramesh-elahieh",
|
||||
"title": "برج آرامش الهیه",
|
||||
"title_en": "Aramesh Elahieh Tower",
|
||||
"location": "تهران، الهیه",
|
||||
"location_en": "Elahieh, Tehran",
|
||||
"year": "۱۴۰۲",
|
||||
"year_en": "2023",
|
||||
"area": "۲۴٬۰۰۰ متر مربع",
|
||||
"area_en": "24,000 m²",
|
||||
"floors": "۱۸ طبقه",
|
||||
"floors_en": "18 floors",
|
||||
"units": "۵۶ واحد",
|
||||
"units_en": "56 units",
|
||||
"client": "سرمایهگذاری خصوصی",
|
||||
"client_en": "Private investor",
|
||||
"summary": "یک برج مسکونی لوکس با نمای سنگ تراورتن و پوسته دوپوستهای که مصرف انرژی را تا ۳۵ درصد کاهش داده است.",
|
||||
"summary_en": "A luxury residential tower with travertine cladding and a double-skin facade that cuts energy use by up to 35%.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"Aramesh Elahieh Tower was designed on a site with a natural northward slope — an opportunity that placed the lobby, pool and shared spaces on three different levels and gave every unit an unobstructed view of the city.",
|
||||
"The outer shell combines light travertine stone with floor-to-ceiling triple-glazed windows. Copper-toned vertical louvers break the direct summer sun while letting winter light pour in.",
|
||||
"Construction was completed in thirty-two months, and every unit was sold before handover.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "parseh-mall",
|
||||
"title": "مجتمع تجاری پارسه",
|
||||
"title_en": "Parseh Shopping Mall",
|
||||
"location": "اصفهان، مرکز شهر",
|
||||
"location_en": "City center, Isfahan",
|
||||
"year": "۱۴۰۱",
|
||||
"year_en": "2022",
|
||||
"area": "۳۸٬۵۰۰ متر مربع",
|
||||
"area_en": "38,500 m²",
|
||||
"floors": "۷ طبقه",
|
||||
"floors_en": "7 floors",
|
||||
"units": "۱۴۰ واحد تجاری",
|
||||
"units_en": "140 retail units",
|
||||
"client": "گروه سرمایهگذاری پارسه",
|
||||
"client_en": "Parseh Investment Group",
|
||||
"summary": "مرکز تجاری با آتریوم مرکزی و نورگیر سقفی که جریان مخاطب را بهصورت طبیعی در همه ترازها توزیع میکند.",
|
||||
"summary_en": "A retail center with a central atrium and roof light-wells that naturally distribute visitor flow across every level.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"A visitor-behavior study before design showed that the shortest vertical path was the key to the complex's commercial success — so the central atrium with diagonal escalators sits at the heart of the plan.",
|
||||
"Low-E glazing on the roof light-well carries daylight down to level B2 and measurably cuts artificial lighting consumption.",
|
||||
"The complex opened two months ahead of the contractual schedule.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "daryakenar-villas",
|
||||
"title": "ویلاهای دریاکنار",
|
||||
"title_en": "Daryakenar Villas",
|
||||
"location": "مازندران، نوشهر",
|
||||
"location_en": "Nowshahr, Mazandaran",
|
||||
"year": "۱۴۰۰",
|
||||
"year_en": "2021",
|
||||
"area": "۹٬۲۰۰ متر مربع",
|
||||
"area_en": "9,200 m²",
|
||||
"floors": "۲ طبقه",
|
||||
"floors_en": "2 floors",
|
||||
"units": "۱۲ ویلا",
|
||||
"units_en": "12 villas",
|
||||
"client": "مجموعه خصوصی دریاکنار",
|
||||
"client_en": "Daryakenar Private Estate",
|
||||
"summary": "دوازده ویلای اختصاصی با چیدمان شانهای که برای هر واحد، دید مستقیم به دریا و محرمانگی کامل فراهم میکند.",
|
||||
"summary_en": "Twelve bespoke villas in a comb layout that gives each unit a direct sea view and complete privacy.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"The comb layout of the villas is the result of dozens of view-angle studies: no villa overlooks another, and all of them open onto the sea.",
|
||||
"The materials answer the humid northern climate: thermowood, exposed concrete and salt-resistant coatings on the facade metals.",
|
||||
"The landscape was designed with native plants to keep annual irrigation demand to a minimum.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "niavaran-twins",
|
||||
"title": "برج دوقلوی نیاوران",
|
||||
"title_en": "Niavaran Twin Towers",
|
||||
"location": "تهران، نیاوران",
|
||||
"location_en": "Niavaran, Tehran",
|
||||
"year": "۱۳۹۹",
|
||||
"year_en": "2020",
|
||||
"area": "۳۱٬۷۰۰ متر مربع",
|
||||
"area_en": "31,700 m²",
|
||||
"floors": "۲۰ طبقه",
|
||||
"floors_en": "20 floors",
|
||||
"units": "۸۰ واحد",
|
||||
"units_en": "80 units",
|
||||
"client": "مشارکت با مالکان زمین",
|
||||
"client_en": "Partnership with landowners",
|
||||
"summary": "دو برج همسان با پادست مشترک که فضاهای رفاهی و پارکینگها را در یک تراز واحد ادغام میکند.",
|
||||
"summary_en": "Two identical towers on a shared podium that merges amenities and parking into a single level.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"The two towers rest on a shared podium — a decision that let the amenities, event hall and parking be shared between the buildings and reduced the footprint on the garden.",
|
||||
"The structure uses shear walls with post-tensioned slabs so the interior spans stay free and the floor plans remain customizable.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "kish-garden-tower",
|
||||
"title": "باغبرج کیش",
|
||||
"title_en": "Kish Garden Tower",
|
||||
"location": "کیش، مروارید",
|
||||
"location_en": "Marvàrid, Kish Island",
|
||||
"year": "۱۴۰۵ (پیشبینی)",
|
||||
"year_en": "2026 (expected)",
|
||||
"area": "۱۸٬۴۰۰ متر مربع",
|
||||
"area_en": "18,400 m²",
|
||||
"floors": "۱۴ طبقه",
|
||||
"floors_en": "14 floors",
|
||||
"units": "۶۲ واحد اقامتی",
|
||||
"units_en": "62 hospitality units",
|
||||
"client": "صندوق پروژهمحور آرامش",
|
||||
"client_en": "Aramesh Project Fund",
|
||||
"summary": "باغبرجی اقامتی با تراسهای سبز پیوسته که در حال ساخت است؛ پیشرفت فیزیکی پروژه ۴۸ درصد.",
|
||||
"summary_en": "A hospitality garden tower with continuous green terraces, currently under construction — 48% complete.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"The continuous green terraces form a living skin that measurably lowers the facade surface temperature in the island's hot climate.",
|
||||
"The structure has reached the ninth floor, and facade installation has begun on the southern elevation.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "aramesh-business-center",
|
||||
"title": "مرکز تجاری آرامش تهران",
|
||||
"title_en": "Aramesh Business Center Tehran",
|
||||
"location": "تهران، سعادتآباد",
|
||||
"location_en": "Saadatabad, Tehran",
|
||||
"year": "۱۴۰۶ (پیشبینی)",
|
||||
"year_en": "2027 (expected)",
|
||||
"area": "۲۷٬۳۰۰ متر مربع",
|
||||
"area_en": "27,300 m²",
|
||||
"floors": "۱۲ طبقه",
|
||||
"floors_en": "12 floors",
|
||||
"units": "۹۰ دفتر اداری",
|
||||
"units_en": "90 offices",
|
||||
"client": "مشارکت با هلدینگ آرامش",
|
||||
"client_en": "Partnership with Aramesh Holding",
|
||||
"summary": "مجتمع اداری-تجاری با هسته مرکزی شفاف و لابی دوبلکس؛ در مرحله اجرای سفتکاری.",
|
||||
"summary_en": "An office-retail complex with a transparent central core and double-height lobby; currently in structural works.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"The space planning was built around flexible offices: every level can be divided into two, three or six independent units.",
|
||||
"Mechanical installations are progressing in parallel with the structural works to shorten the overall timeline.",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
BEFORE_AFTER = {
|
||||
"title": "از کانسپت تا خلق واقعیت",
|
||||
"title_en": "From Concept to Reality",
|
||||
"lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل میشود.",
|
||||
"lead_en": "Drag the handle to see how a building facade turns from a raw concept into its modern form.",
|
||||
"before_label": "کانسپت اولیه",
|
||||
"before_label_en": "Initial concept",
|
||||
"after_label": "پروژه تکمیلشده",
|
||||
"after_label_en": "Completed project",
|
||||
}
|
||||
|
||||
TIMELINE = [
|
||||
{"year": "۱۳۸۳", "year_en": "2004", "title": "آغاز مسیر", "title_en": "The Journey Begins", "desc": "تأسیس دفتر معماری آرامش با تیمی چهارنفره و نخستین پروژه بازسازی در تهران.", "desc_en": "Aramesh Architecture Office is founded by a four-person team, taking on its first renovation project in Tehran.", "image": IMAGES[2]},
|
||||
{"year": "۱۳۸۵", "year_en": "2006", "title": "نخستین ساخت مستقل", "title_en": "First Independent Build", "desc": "تحویل اولین ساختمان مسکونی هفتطبقه؛ آغاز همکاری با پیمانکاران سازهای بزرگ.", "desc_en": "Delivery of the first seven-story residential building and the start of partnerships with major structural contractors.", "image": IMAGES[0]},
|
||||
{"year": "۱۳۸۷", "year_en": "2008", "title": "ورود به بازار تجاری", "title_en": "Entering Commercial Market", "desc": "طراحی و اجرای اولین مجتمع تجاری با رویکرد بازدهی سرمایه و جریان مخاطب.", "desc_en": "Design and execution of the first commercial complex, driven by capital returns and visitor flow.", "image": IMAGES[1]},
|
||||
{"year": "۱۳۸۹", "year_en": "2010", "title": "استاندارد بینالمللی", "title_en": "International Standards", "desc": "استقرار نظام کنترل کیفیت و دریافت گواهینامههای مدیریت پروژه.", "desc_en": "A quality-control system is established and project-management certifications are earned.", "image": IMAGES[4]},
|
||||
{"year": "۱۳۹۵", "year_en": "2016", "title": "بلندمرتبهسازی", "title_en": "Going High-Rise", "desc": "آغاز ساخت نخستین برج بیستطبقه شرکت در شمال تهران.", "desc_en": "Construction begins on the company's first twenty-story tower in northern Tehran.", "image": IMAGES[3]},
|
||||
{"year": "۱۴۰۳", "year_en": "Today", "title": "گروه ساختمانی آرامش امروز", "title_en": "Aramesh Construction Group Today", "desc": "پرتفوی ۴۷ پروژه، دفاتر در سه شهر و صندوق سرمایهگذاری ساختمانی اختصاصی.", "desc_en": "A portfolio of 47 projects, offices in three cities and a dedicated real estate investment fund.", "image": IMAGES[0]},
|
||||
]
|
||||
|
||||
WHY = [
|
||||
{"title": "کیفیت بدون مصالحه", "title_en": "Quality Without Compromise", "desc": "انتخاب مصالح و مجریان بر اساس ماندگاری چنددههای، نه سریعترین تحویل.", "desc_en": "Materials and subcontractors are chosen for decades of durability, not the fastest delivery."},
|
||||
{"title": "شفافیت مالی کامل", "title_en": "Full Financial Transparency", "desc": "گزارش ماهانه هزینهها و پیشرفت فیزیکی برای همه شرکای پروژه.", "desc_en": "Monthly reporting of costs and physical progress for every project partner."},
|
||||
{"title": "تحویل بهموقع", "title_en": "On-Time Delivery", "desc": "۹۴٪ پروژههای ما در بازه زمانی قراردادی یا زودتر تحویل داده شدهاند.", "desc_en": "94% of our projects have been delivered on or ahead of the contractual schedule."},
|
||||
{"title": "طراحی اختصاصی", "title_en": "Bespoke Design", "desc": "هیچ نقشهای دو بار اجرا نمیشود؛ هر پروژه هویت معماری خودش را دارد.", "desc_en": "No drawing is ever executed twice — every project has its own architectural identity."},
|
||||
{"title": "خدمات پس از تحویل", "title_en": "After-Sales Service", "desc": "پنج سال پشتیبانی فنی و نگهداری تأسیسات پس از تحویل کلید.", "desc_en": "Five years of technical support and building-systems maintenance after handover."},
|
||||
{"title": "پایداری و انرژی", "title_en": "Sustainability & Energy", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد.", "desc_en": "Envelope and building systems designed to cut energy consumption by up to 35%."},
|
||||
]
|
||||
|
||||
TESTIMONIALS = [
|
||||
{"name": "مهندس سارا کیانی", "name_en": "Sara Kiani", "role": "سرمایهگذار پروژه پارسه", "role_en": "Investor, Parseh Mall project", "quote": "گزارشهای ماهانه گروه ساختمانی آرامش آنقدر دقیق بود که هیچوقت نیاز نشد نگران سرمایهام باشم. پروژه دو ماه زودتر از موعد به بهرهبرداری رسید.", "quote_en": "Aramesh's monthly reports were so precise that I never had to worry about my investment. The project opened two months ahead of schedule."},
|
||||
{"name": "دکتر امیرحسین راد", "name_en": "Dr. Amirhossein Rad", "role": "ساکن برج الهیه", "role_en": "Resident, Elahieh Tower", "quote": "کیفیت اجرا در جزئیاتی دیده میشود که معمولاً کسی به آنها فکر نمیکند؛ از عایق صوتی پنجرهها تا نور غیرمستقیم راهروها.", "quote_en": "The build quality shows in details nobody usually thinks about — from the acoustic insulation of the windows to the indirect lighting in the corridors."},
|
||||
{"name": "مریم دلاور", "name_en": "Maryam Delavar", "role": "مدیر مجموعه ویلاهای دریاکنار", "role_en": "Manager, Daryakenar Villas", "quote": "همکاری با تیم طراحی، تجربهای واقعاً حرفهای بود. ایدههای ما را شنیدند و به زبان معماری ترجمه کردند.", "quote_en": "Working with the design team was a truly professional experience. They listened to our ideas and translated them into architecture."},
|
||||
]
|
||||
|
||||
TEAM = [
|
||||
{"name": "کامران آرامش", "name_en": "Kamran Aramesh", "role": "بنیانگذار و مدیرعامل", "role_en": "Founder & CEO", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه.", "bio_en": "Three decades of high-rise construction experience."},
|
||||
{"name": "نگار شریفی", "name_en": "Negar Sharifi", "role": "مدیر ارشد طراحی", "role_en": "Chief Design Officer", "bio": "معمار ارشد، فارغالتحصیل دانشگاه تهران و میلان.", "bio_en": "Senior architect, graduate of the University of Tehran and Milan."},
|
||||
{"name": "بهزاد مرادی", "name_en": "Behzad Moradi", "role": "مدیر اجرایی پروژهها", "role_en": "Director of Project Execution", "bio": "مسئول کنترل کیفیت و زمانبندی همه کارگاهها.", "bio_en": "Responsible for quality control and scheduling across all sites."},
|
||||
{"name": "الهام نیکآیین", "name_en": "Elham Nikaein", "role": "مدیر سرمایهگذاری", "role_en": "Investment Director", "bio": "طراح ساختارهای مشارکت و تحلیل بازدهی ملکی.", "bio_en": "Designs partnership structures and analyzes property returns."},
|
||||
]
|
||||
|
||||
ARTICLES = [
|
||||
{
|
||||
"slug": "natural-light",
|
||||
"title": "معماری آرام: چرا نور طبیعی گرانترین مصالح ساختمان است؟",
|
||||
"title_en": "Quiet Architecture: Why Is Natural Light the Most Expensive Building Material?",
|
||||
"date": "۲۴ مرداد ۱۴۰۳",
|
||||
"date_en": "August 14, 2024",
|
||||
"category": "معماری",
|
||||
"category_en": "Architecture",
|
||||
"read": "۶ دقیقه",
|
||||
"read_en": "6 min read",
|
||||
"excerpt": "نور طبیعی نهتنها کیفیت زندگی در فضا را تعیین میکند، بلکه مستقیماً بر مصرف انرژی و ارزش بلندمدت ملک اثر میگذارد.",
|
||||
"excerpt_en": "Natural light not only defines the quality of life in a space — it directly affects energy consumption and the long-term value of the property.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"In the construction market, a unit's value is usually measured in square meters. But our experience over the past twenty years shows that the quality of light is more decisive than a few extra meters.",
|
||||
"Building orientation, bay depth and floor-to-ceiling height are the three main variables of natural light. The decision on all three is made before the first plan is drawn — and is hardly reversible afterwards.",
|
||||
"In Aramesh Elahieh Tower, rotating the building mass 12 degrees off north added two hours of useful winter light to the units — a cost-free decision with a decades-long effect.",
|
||||
"Our advice for choosing a unit is simple: visit at mid-morning and late afternoon, and judge the quality of light at exactly the hours you are actually home.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "real-estate-portfolio",
|
||||
"title": "راهنمای انتخاب سبد سرمایهگذاری ملکی در بازار امروز",
|
||||
"title_en": "A Guide to Building a Real Estate Portfolio in Today's Market",
|
||||
"date": "۱۰ مرداد ۱۴۰۳",
|
||||
"date_en": "July 31, 2024",
|
||||
"category": "سرمایهگذاری",
|
||||
"category_en": "Investment",
|
||||
"read": "۹ دقیقه",
|
||||
"read_en": "9 min read",
|
||||
"excerpt": "سه معیار کلیدی برای تشکیل سبدی متعادل از دارایی ملکی، بر اساس افق زمانی و سطح ریسک شما.",
|
||||
"excerpt_en": "Three key criteria for building a balanced property portfolio, based on your time horizon and risk appetite.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"A balanced property portfolio mixes assets with different liquidity — not all capital in one long-term project, not all in ready units.",
|
||||
"Time horizon is the first criterion. If your plan is shorter than two years, pre-purchasing in a project that has just started is a risky choice.",
|
||||
"The second criterion is reporting transparency. Without monthly reports of physical and financial progress, assessing the state of your capital is practically impossible.",
|
||||
"The third criterion is diversity of use. A residential–commercial mix measurably reduces the volatility of portfolio returns in downturns.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "facade-quality",
|
||||
"title": "پنج نکته فنی که کیفیت نما را پس از ده سال مشخص میکند",
|
||||
"title_en": "Five Technical Details That Reveal Facade Quality After Ten Years",
|
||||
"date": "۲۸ تیر ۱۴۰۳",
|
||||
"date_en": "July 18, 2024",
|
||||
"category": "اجرا",
|
||||
"category_en": "Construction",
|
||||
"read": "۵ دقیقه",
|
||||
"read_en": "5 min read",
|
||||
"excerpt": "نمای ساختمان در سال اول زیبا به نظر میرسد؛ تفاوت کیفیت اجرا در سال دهم آشکار میشود.",
|
||||
"excerpt_en": "A facade looks beautiful in its first year; the difference in workmanship shows in its tenth.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"Mechanical fixings of facade stone matter more than the stone itself. Adhesive fixing has a short life in Iran's hot-and-cold climate.",
|
||||
"Contraction joints must be designed on the drawing, not decided on site — their absence is the first cause of horizontal facade cracks.",
|
||||
"Window perimeter waterproofing is the most frequent point of failure. Installing waterproofing tape before the sill goes in removes that risk.",
|
||||
"Hot-dip galvanized sub-structures cost marginally more than painted ones — and last many times longer.",
|
||||
"Finally, take the annual facade inspection seriously: repairing one joint costs next to nothing compared with redoing the whole facade.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "commercial-yield",
|
||||
"title": "چگونه بازدهی یک پروژه تجاری را پیش از ساخت تخمین بزنیم؟",
|
||||
"title_en": "How to Estimate a Commercial Project's Yield Before Building It",
|
||||
"date": "۱۵ تیر ۱۴۰۳",
|
||||
"date_en": "July 5, 2024",
|
||||
"category": "سرمایهگذاری",
|
||||
"category_en": "Investment",
|
||||
"read": "۷ دقیقه",
|
||||
"read_en": "7 min read",
|
||||
"excerpt": "مدل سادهای برای برآورد بازدهی، بر پایه جریان مخاطب، ترکیب واحدها و نرخ اجاره منطقه.",
|
||||
"excerpt_en": "A simple yield-estimation model based on visitor flow, unit mix and local rental rates.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"Estimating commercial yield starts with three numbers: daily visitor flow, the rental rate per square meter in the area, and the net-to-gross floor ratio.",
|
||||
"A common mistake is ignoring the upper levels. Without a well-designed vertical path, levels three and above usually fail to reach even half of the ground-floor rate.",
|
||||
"Finally, write the conservative scenario too — if the project still makes sense in the pessimistic case, the investment is a rational one.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "facade-materials",
|
||||
"title": "مصالح نما: مقایسه سنگ، کامپوزیت و ترموود در اقلیم ایران",
|
||||
"title_en": "Facade Materials: Stone, Composite and Thermowood in Iran's Climate",
|
||||
"date": "۲ تیر ۱۴۰۳",
|
||||
"date_en": "June 22, 2024",
|
||||
"category": "اجرا",
|
||||
"category_en": "Construction",
|
||||
"read": "۸ دقیقه",
|
||||
"read_en": "8 min read",
|
||||
"excerpt": "هر مصالح نما در اقلیم متفاوتی درست کار میکند؛ انتخاب اشتباه، هزینه نگهداری را چند برابر میکند.",
|
||||
"excerpt_en": "Every facade material works in the right climate; the wrong choice multiplies maintenance costs.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"In a dry climate, stone has the best durability — but its weight demands a precise sub-structure and structural calculations.",
|
||||
"Composite is the fast, light choice, provided you pick a fire-rated grade and account for thermal expansion along the sheet.",
|
||||
"In the humid northern climate, thermowood is the best aesthetic answer — with a twice-yearly maintenance schedule.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "renovate-or-rebuild",
|
||||
"title": "بازسازی یا نوسازی؟ معیارهای تصمیمگیری برای مالکان",
|
||||
"title_en": "Renovate or Rebuild? Decision Criteria for Property Owners",
|
||||
"date": "۲۰ خرداد ۱۴۰۳",
|
||||
"date_en": "June 9, 2024",
|
||||
"category": "معماری",
|
||||
"category_en": "Architecture",
|
||||
"read": "۶ دقیقه",
|
||||
"read_en": "6 min read",
|
||||
"excerpt": "اگر عمر سازه و ضوابط شهری اجازه دهند، بازسازی میتواند بازدهی بالاتری از تخریب کامل داشته باشد.",
|
||||
"excerpt_en": "If the structure's age and city regulations allow, renovation can outperform full demolition and rebuild.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"First the structure is assessed: if seismic retrofitting costs more than 40% of a new build, rebuilding is the more rational option.",
|
||||
"The second criterion is today's urban regulations. Sometimes demolition grants you less permissible floor area — which changes the decision entirely.",
|
||||
"In renovation, the greatest added value comes from three places: the facade, the entrance and the re-planning of the interior.",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
FAQS = [
|
||||
{"question": "حداقل سرمایه برای مشارکت در پروژههای گروه ساختمانی آرامش چقدر است؟", "question_en": "What is the minimum capital to join Aramesh Construction Group projects?", "answer": "ساختار مشارکت هر پروژه متفاوت است، اما معمولاً واحدهای سرمایهگذاری از سطح مشخصی آغاز میشوند و جزئیات دقیق در جلسه ارزیابی اولیه ارائه میشود.", "answer_en": "Each project's partnership structure is different, but investment units usually start from a defined level, and the exact details are presented in the initial assessment meeting."},
|
||||
{"question": "مدت زمان معمول ساخت یک برج مسکونی چقدر است؟", "question_en": "How long does it take to build a residential tower?", "answer": "بسته به تعداد طبقات و شرایط زمین، میانگین اجرای یک برج مسکونی بین ۲۸ تا ۴۲ ماه از زمان صدور پروانه تا تحویل کلید است.", "answer_en": "Depending on the number of floors and site conditions, a residential tower takes on average 28 to 42 months from permit issuance to key handover."},
|
||||
{"question": "آیا امکان تغییر نقشه داخلی واحد وجود دارد؟", "question_en": "Can the unit's interior layout be customized?", "answer": "بله. تا پیش از پایان مرحله سفتکاری، امکان شخصیسازی پلان داخلی با هماهنگی تیم طراحی و تأیید سازه فراهم است.", "answer_en": "Yes. Until the structural phase is complete, the interior plan can be customized in coordination with the design team and structural approval."},
|
||||
{"question": "خدمات پس از تحویل شامل چه مواردی میشود؟", "question_en": "What does the after-sales service include?", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دورهای سالانه و تضمین اجرای نما و عایقکاری.", "answer_en": "Five years of technical support for mechanical and electrical systems, an annual inspection, and warranty on facade work and insulation."},
|
||||
{"question": "پروژهها در چه شهرهایی اجرا میشوند؟", "question_en": "In which cities do you build?", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژهای نیز در جزیره کیش در دست ساخت است.", "answer_en": "Tehran, Isfahan and the northern coasts are currently our main regions, and a project is also under construction on Kish Island."},
|
||||
]
|
||||
|
||||
NAV_ITEMS = [
|
||||
{"label": "صفحه اصلی", "label_en": "Home", "href": "/"},
|
||||
{"label": "درباره ما", "label_en": "About", "href": "/about"},
|
||||
{"label": "خدمات", "label_en": "Services", "href": "/services"},
|
||||
{"label": "پروژهها", "label_en": "Projects", "href": "/projects"},
|
||||
{"label": "سرمایهگذاری", "label_en": "Investment", "href": "/investment"},
|
||||
{"label": "وبلاگ", "label_en": "Blog", "href": "/blog"},
|
||||
{"label": "سوالات متداول", "label_en": "FAQ", "href": "/faq"},
|
||||
{"label": "تماس با ما", "label_en": "Contact", "href": "/contact"},
|
||||
]
|
||||
|
||||
HERO = {
|
||||
"kicker": "از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال",
|
||||
"kicker_en": "Since 2004 — Tehran, Isfahan, Northern Coasts",
|
||||
"title": "ساختمانی که آرامش را معماری میکند",
|
||||
"title_en": "A building that architects tranquility",
|
||||
"title_highlight": "معماری",
|
||||
"title_highlight_en": "architects",
|
||||
"subtitle": "طراحی، ساخت و سرمایهگذاری در پروژههای مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده میشود و در سالها دوام میآورد.",
|
||||
"subtitle_en": "Design, construction and investment in luxury residential and commercial projects — to a standard you can see in the details and trust for years.",
|
||||
"badge_value": "۴۷",
|
||||
"badge_value_en": "47",
|
||||
"badge_label": "پروژه تحویلشده در بیستویک سال فعالیت حرفهای",
|
||||
"badge_label_en": "Projects delivered in twenty-one years of professional work",
|
||||
"cta_primary_label": "مشاهده پروژهها",
|
||||
"cta_primary_label_en": "View Projects",
|
||||
"cta_secondary_label": "مشاوره سرمایهگذاری",
|
||||
"cta_secondary_label_en": "Investment Advisory",
|
||||
"scroll_label": "اسکرول کنید",
|
||||
"scroll_label_en": "Scroll",
|
||||
}
|
||||
|
||||
STORY = {
|
||||
"title": "درباره ی ما",
|
||||
"title_en": "About Us",
|
||||
"paragraphs": [],
|
||||
"paragraphs_en": [
|
||||
"Aramesh Construction Group began as a small architecture office and is today one of Iran's recognized names in luxury construction. Through all these years one principle has not changed: a building must be made for living, not for a quick sale.",
|
||||
"Every project starts with a study of the land, light, climate and the residents' way of life; then the design, structural and execution teams carry it through a single process to key handover.",
|
||||
],
|
||||
"quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی میسازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»",
|
||||
"quote_en": "\u201cA building, for us, is not just brick and concrete; it is a commitment to the quality of life of its residents. We build spaces that keep their value and identity as time passes.\u201d",
|
||||
"quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
|
||||
"quote_author_en": "Kamran Aramesh — CEO, Aramesh Construction Group",
|
||||
"stat1_value": "۹۴٪",
|
||||
"stat1_value_en": "94%",
|
||||
"stat1_label": "تحویل بهموقع پروژهها",
|
||||
"stat1_label_en": "On-time project delivery",
|
||||
"stat2_value": "۵ سال",
|
||||
"stat2_value_en": "5 years",
|
||||
"stat2_label": "پشتیبانی پس از تحویل",
|
||||
"stat2_label_en": "Post-handover support",
|
||||
"link_label": "داستان کامل ما",
|
||||
"link_label_en": "Our full story",
|
||||
"bento": [],
|
||||
"bento_en": [
|
||||
{"src": IMAGES[0], "alt": "Facade of an Aramesh residential tower in Tehran"},
|
||||
{"src": IMAGES[1], "alt": "Central atrium of Parseh Shopping Mall"},
|
||||
{"src": IMAGES[2], "alt": "Villas built on the northern coasts"},
|
||||
{"src": IMAGES[3], "alt": "Night view of the Niavaran Twin Towers"},
|
||||
],
|
||||
}
|
||||
|
||||
SECTION_HEADINGS = [
|
||||
{"key": "services", "title_en": "Services", "lead_en": "From the first line on paper to key handover, capital management and landscape design — every stage under one roof and one quality-control system."},
|
||||
{"key": "featured_projects", "title_en": "Featured Projects", "lead_en": "The work that says the most about our standard — both the delivered projects and the ones under construction today.", "cta_label_en": "All Projects"},
|
||||
{"key": "gallery", "title_en": "Gallery", "lead_en": "Frames from the sites and delivered projects of Aramesh Construction Group."},
|
||||
{"key": "timeline", "title_en": "Our Path", "lead_en": "From a four-person office to 47 projects. Walk through the timeline of our work."},
|
||||
{"key": "why_us", "title_en": "Why Aramesh Construction Group", "lead_en": "Six reasons clients and investors come back to us a second time."},
|
||||
{"key": "testimonials", "title_en": "Client Testimonials"},
|
||||
{"key": "team", "title_en": "Our Team", "lead_en": "The people who make each project's key decisions — and answer for them."},
|
||||
{"key": "articles", "label_en": "Blog", "title_en": "Latest Articles", "lead_en": "Notes from the architecture, execution and investment teams of Aramesh Construction Group on what's happening on site and in the market.", "cta_label_en": "All Articles"},
|
||||
{"key": "faq", "title_en": "Frequently Asked Questions", "lead_en": "Short answers to the questions investors and buyers ask most."},
|
||||
{"key": "investment_models", "title_en": "Partnership Models", "lead_en": "Depending on your capital, time horizon and risk appetite, one of these three paths is right for you."},
|
||||
{"key": "process", "title_en": "How We Work", "lead_en": "Four transparent steps, from the first meeting to key handover."},
|
||||
]
|
||||
|
||||
PAGE_HEROES = [
|
||||
{"key": "about", "title_en": "About Us", "lead_en": "Aramesh Construction Group is a team of architects, structural engineers and project managers working in luxury construction since 2004."},
|
||||
{"key": "services", "title_en": "Services", "lead_en": "Eight specialized fields covering a building project's full cycle, from idea to operation."},
|
||||
{"key": "projects", "title_en": "Projects", "lead_en": "A collection of residential towers, commercial centers and private villas — from delivered projects to today's active sites."},
|
||||
{"key": "investment", "title_en": "Investment", "lead_en": "Three clear paths into the luxury construction market, with transparent financial structures and regular reporting."},
|
||||
{"key": "blog", "title_en": "Blog", "lead_en": "Notes from the architecture, execution and investment teams of Aramesh Construction Group on what's happening on site and in the market."},
|
||||
{"key": "faq", "title_en": "Frequently Asked Questions", "lead_en": "Before you call, the answer to your question might be right here. If not, we would be glad to talk directly."},
|
||||
{"key": "contact", "title_en": "Contact Us", "lead_en": "For construction consultancy, investment or a project visit, our teams in three cities are ready to respond."},
|
||||
]
|
||||
|
||||
INVESTMENT_MODELS = [
|
||||
{
|
||||
"title": "مشارکت در ساخت",
|
||||
"title_en": "Joint Construction Partnership",
|
||||
"desc": "مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک میشوند؛ اجرا، مجوزها و فروش بر عهده ماست.",
|
||||
"desc_en": "The landowner and Aramesh share the project's profit; execution, permits and sales are on us.",
|
||||
"points": [],
|
||||
"points_en": ["Free land assessment", "Transparent contract with performance guarantee", "Monthly progress reporting"],
|
||||
},
|
||||
{
|
||||
"title": "صندوق پروژهمحور",
|
||||
"title_en": "Project-Based Fund",
|
||||
"desc": "سرمایهگذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبهشده در پایان دوره.",
|
||||
"desc_en": "Investment in a specific project with purchasable units and a calculated yield at the end of the term.",
|
||||
"points": [],
|
||||
"points_en": ["Minimum 24-month term", "Quarterly valuation reports", "Investment units are transferable"],
|
||||
},
|
||||
{
|
||||
"title": "پیشخرید واحد",
|
||||
"title_en": "Pre-Purchase",
|
||||
"desc": "خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحلهای.",
|
||||
"desc_en": "Buying a residential or commercial unit in the early stages of construction, at a pre-completion price with staged payments.",
|
||||
"points": [],
|
||||
"points_en": ["Payments staged to match progress", "Interior layout customization", "Delivery-time guarantee"],
|
||||
},
|
||||
]
|
||||
|
||||
PROCESS_STEPS = [
|
||||
{"step": "۰۱", "step_en": "01", "title": "مطالعه و امکانسنجی", "title_en": "Study & Feasibility", "desc": "بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه.", "desc_en": "Site review, urban regulations and an economic yield analysis of the project."},
|
||||
{"step": "۰۲", "step_en": "02", "title": "طراحی و فاز اجرایی", "title_en": "Design & Construction Drawings", "desc": "کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشههای اجرایی.", "desc_en": "Architectural concept, structural and MEP design, and construction documentation."},
|
||||
{"step": "۰۳", "step_en": "03", "title": "اجرا و کنترل کیفیت", "title_en": "Execution & Quality Control", "desc": "ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه.", "desc_en": "Construction with continuous supervision, monthly progress reports and cost control."},
|
||||
{"step": "۰۴", "step_en": "04", "title": "تحویل و پشتیبانی", "title_en": "Handover & Support", "desc": "تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی.", "desc_en": "Key handover, operating walkthrough and five years of technical support."},
|
||||
]
|
||||
|
||||
OFFICES = [
|
||||
{"city": "تهران", "city_en": "Tehran", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "address_en": "Valiasr St., Aramesh Tower, 18th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸", "hours_en": "Saturday–Wednesday, 9 AM–6 PM"},
|
||||
{"city": "اصفهان", "city_en": "Isfahan", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "address_en": "Chaharbagh Bala St., Parseh Complex, 6th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷", "hours_en": "Saturday–Wednesday, 9 AM–5 PM"},
|
||||
{"city": "نوشهر", "city_en": "Nowshahr", "address": "بلوار دریا، دفتر پروژه دریاکنار", "address_en": "Darya Blvd., Daryakenar Project Office", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶", "hours_en": "Saturday–Thursday, 10 AM–4 PM"},
|
||||
]
|
||||
|
||||
GALLERY_IMAGES = [
|
||||
{"src": IMAGES[0], "alt_en": "Aramesh Construction Group project, No. 1"},
|
||||
{"src": IMAGES[1], "alt_en": "Aramesh Construction Group project, No. 2"},
|
||||
{"src": IMAGES[2], "alt_en": "Aramesh Construction Group project, No. 3"},
|
||||
{"src": IMAGES[3], "alt_en": "Aramesh Construction Group project, No. 4"},
|
||||
{"src": IMAGES[4], "alt_en": "Aramesh Construction Group project, No. 5"},
|
||||
{"src": IMAGES[1], "alt_en": "Aramesh Construction Group project, No. 6"},
|
||||
]
|
||||
|
||||
STATUS_LABELS = [
|
||||
{"key": "done", "label_en": "Completed"},
|
||||
{"key": "progress", "label_en": "In Progress"},
|
||||
]
|
||||
|
||||
CONTACT_SECTION = {
|
||||
"label": "تماس با ما",
|
||||
"label_en": "Contact Us",
|
||||
"title": "پروژهتان را با ما در میان بگذارید",
|
||||
"title_en": "Tell Us About Your Project",
|
||||
"lead": "شمارهتان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس میگیرد.",
|
||||
"lead_en": "Leave your number — our expert will call you within one business day for a free consultation session.",
|
||||
"address_label": "دفتر مرکزی",
|
||||
"address_label_en": "Head Office",
|
||||
"phone_label": "تلفن",
|
||||
"phone_label_en": "Phone",
|
||||
"email_label": "ایمیل",
|
||||
"email_label_en": "Email",
|
||||
"name_label": "نام",
|
||||
"name_label_en": "Name",
|
||||
"name_placeholder": "نام و نام خانوادگی",
|
||||
"name_placeholder_en": "Full name",
|
||||
"phone_field_label": "شماره تلفن",
|
||||
"phone_field_label_en": "Phone number",
|
||||
"note_label": "توضیحات",
|
||||
"note_label_en": "Details",
|
||||
"note_optional_label": "(اختیاری)",
|
||||
"note_optional_label_en": "(optional)",
|
||||
"note_placeholder": "درباره پروژه یا درخواست خود بنویسید…",
|
||||
"note_placeholder_en": "Tell us about your project or request…",
|
||||
"submit_label": "ارسال درخواست مشاوره",
|
||||
"submit_label_en": "Request a Consultation",
|
||||
"sending_label": "در حال ارسال…",
|
||||
"sending_label_en": "Sending…",
|
||||
"success_title": "درخواست شما ثبت شد",
|
||||
"success_title_en": "Your Request Was Received",
|
||||
"success_text": "از اعتماد شما سپاسگزاریم. کارشناسان ما بهزودی با شما تماس میگیرند.",
|
||||
"success_text_en": "Thank you for your trust. Our experts will contact you shortly.",
|
||||
"success_button_label": "ثبت درخواست جدید",
|
||||
"success_button_label_en": "Submit Another Request",
|
||||
}
|
||||
|
||||
NEWSLETTER = {
|
||||
"title": "خبرنامه گروه ساختمانی آرامش",
|
||||
"title_en": "Aramesh Construction Group Newsletter",
|
||||
"lead": "هر ماه یک ایمیل: پروژههای جدید، فرصتهای سرمایهگذاری و یادداشتهای تیم معماری.",
|
||||
"lead_en": "One email a month: new projects, investment opportunities and notes from the architecture team.",
|
||||
"placeholder": "نشانی ایمیل شما",
|
||||
"placeholder_en": "Your email address",
|
||||
"button_label": "عضویت",
|
||||
"button_label_en": "Subscribe",
|
||||
"sending_label": "در حال ارسال…",
|
||||
"sending_label_en": "Sending…",
|
||||
"success_text": "عضویت شما ثبت شد.",
|
||||
"success_text_en": "Your subscription has been registered.",
|
||||
}
|
||||
|
||||
FOOTER = {
|
||||
"about_text": "گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمانهایی میسازد که قرار است چند نسل در آنها زندگی کنند؛ معماری آرام، اجرای دقیق و سرمایهگذاری شفاف.",
|
||||
"about_text_en": "Since 2004, Aramesh Construction Group has been building for generations to come: calm architecture, precise execution and transparent investment.",
|
||||
"pages_heading": "صفحات",
|
||||
"pages_heading_en": "Pages",
|
||||
"services_heading": "خدمات",
|
||||
"services_heading_en": "Services",
|
||||
"contact_heading": "ارتباط با ما",
|
||||
"contact_heading_en": "Contact",
|
||||
"copyright": "© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.",
|
||||
"copyright_en": "© 2025 Aramesh Construction Group — All rights reserved.",
|
||||
"tagline": "طراحی و ساخت با دقتِ معماری",
|
||||
"tagline_en": "Designed and built with architectural precision",
|
||||
"socials_en": [
|
||||
{"label": "Instagram", "href": "#", "icon": "instagram"},
|
||||
{"label": "LinkedIn", "href": "#", "icon": "linkedin"},
|
||||
{"label": "Telegram", "href": "#", "icon": "telegram"},
|
||||
],
|
||||
}
|
||||
|
||||
SEO_META = [
|
||||
{"page_key": "home", "title_en": "Aramesh Construction Group | Luxury Residential & Commercial Buildings", "description_en": "Design, construction and investment in luxury residential and commercial projects in Iran — 21 years of experience, 47 delivered projects."},
|
||||
{"page_key": "about", "title_en": "About Us | Aramesh Construction Group", "description_en": "Architects, structural engineers and project managers in luxury construction since 2004."},
|
||||
{"page_key": "services", "title_en": "Services | Aramesh Construction Group", "description_en": "Architectural design, high-rise construction, commercial complexes, villas, renovation and project management."},
|
||||
{"page_key": "projects", "title_en": "Projects | Aramesh Construction Group", "description_en": "Residential towers, commercial centers and private villas — delivered projects and active sites."},
|
||||
{"page_key": "investment", "title_en": "Investment | Aramesh Construction Group", "description_en": "Three transparent paths into the luxury construction market: joint construction, project-based funds and pre-purchase."},
|
||||
{"page_key": "blog", "title_en": "Blog | Aramesh Construction Group", "description_en": "Articles on architecture, construction and real estate investment from the Aramesh team."},
|
||||
{"page_key": "faq", "title_en": "FAQ | Aramesh Construction Group", "description_en": "Short answers to the most frequent questions from investors and buyers."},
|
||||
{"page_key": "contact", "title_en": "Contact Us | Aramesh Construction Group", "description_en": "Get in touch for construction consultancy, investment or a project visit — offices in Tehran, Isfahan and Nowshahr."},
|
||||
]
|
||||
@@ -0,0 +1,436 @@
|
||||
"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریقشده.
|
||||
|
||||
فرانتاند یک SPA است؛ خزندههای اجتماعی (تلگرام، واتساپ و...) جاوااسکریپت اجرا نمیکنند.
|
||||
این ماژول همان index.html بیلد فرانتاند را میخواند و پیش از ارسال، تگهای
|
||||
title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
||||
بازدیدکنندههای عادی همان SPA همیشگی را میبینند و خزندهها متای درست را میخوانند.
|
||||
|
||||
دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو میشود؛
|
||||
هر دو در sitemap با hreflang متقابل و در head هر صفحه با link alternate معرفی میشوند.
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"]
|
||||
# صفحاتی که متای آنها از seo_meta خوانده میشود — کلید همان page_key است
|
||||
META_KEYS = ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
|
||||
FALLBACK_FA_BRAND = "گروه ساختمانی آرامش"
|
||||
FALLBACK_EN_BRAND = "Aramesh Construction Group"
|
||||
|
||||
|
||||
def _absolute(path: str) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
if path.startswith(("http://", "https://")):
|
||||
return path
|
||||
site = settings.site_url.rstrip("/")
|
||||
return f"{site}{path}" if site else path
|
||||
|
||||
|
||||
def _en_path(path: str) -> str:
|
||||
"""مسیر انگلیسی معادل یک مسیر فارسی — / و /en یکدیگرند."""
|
||||
return "/en" if path == "/" else f"/en{path}"
|
||||
|
||||
|
||||
def _fa_path(path: str) -> str:
|
||||
"""مسیر فارسی معادل یک مسیر انگلیسی."""
|
||||
if path == "/en":
|
||||
return "/"
|
||||
return path[3:] if path.startswith("/en/") else path
|
||||
|
||||
|
||||
def _alternates(path: str, lang: str) -> str:
|
||||
"""تگهای hreflang متقابل فارسی/انگلیسی برای head."""
|
||||
fa = path if lang == "fa" else _fa_path(path)
|
||||
en = _en_path(path) if lang == "fa" else path
|
||||
return (
|
||||
f'<link rel="alternate" hreflang="fa" href="{html.escape(_absolute(fa))}" />\n'
|
||||
f' <link rel="alternate" hreflang="en" href="{html.escape(_absolute(en))}" />\n'
|
||||
f' <link rel="alternate" hreflang="x-default" href="{html.escape(_absolute(fa))}" />'
|
||||
)
|
||||
|
||||
|
||||
def _read_index() -> str | None:
|
||||
try:
|
||||
return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _doc_text(doc: object, limit: int = 300) -> str:
|
||||
"""متن ساده سند TipTap — برای noscript و پیشنمایش."""
|
||||
words: list[str] = []
|
||||
|
||||
def walk(node: object) -> None:
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
if node.get("type") == "text" and isinstance(node.get("text"), str):
|
||||
words.append(node["text"])
|
||||
for child in node.get("content") or []:
|
||||
walk(child)
|
||||
|
||||
if isinstance(doc, dict):
|
||||
for block in doc.get("content") or []:
|
||||
walk(block)
|
||||
text = " ".join(words).strip()
|
||||
return text[:limit] + ("…" if len(text) > limit else "")
|
||||
|
||||
|
||||
def _shell(
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
path: str,
|
||||
lang: str = "fa",
|
||||
og_image: str = "",
|
||||
og_type: str = "website",
|
||||
jsonld: dict | None = None,
|
||||
noscript: str = "",
|
||||
) -> HTMLResponse:
|
||||
"""index.html فرانتاند با متای تزریقشده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
|
||||
title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND)
|
||||
description = description.strip()
|
||||
canonical = _absolute(path)
|
||||
image = _absolute(og_image)
|
||||
esc = html.escape
|
||||
alternates = _alternates(path, lang)
|
||||
|
||||
# تگ title جای تگ موجود index.html را میگیرد و در بلوک تکرار نمیشود
|
||||
tags = [
|
||||
f'<meta name="description" content="{esc(description)}" />',
|
||||
f'<meta property="og:title" content="{esc(title)}" />',
|
||||
f'<meta property="og:description" content="{esc(description)}" />',
|
||||
f'<meta property="og:type" content="{esc(og_type)}" />',
|
||||
f'<meta property="og:url" content="{esc(canonical)}" />',
|
||||
f'<meta property="og:locale" content="{"en_US" if lang == "en" else "fa_IR"}" />',
|
||||
f'<meta name="twitter:card" content="summary_large_image" />',
|
||||
]
|
||||
if image:
|
||||
tags.append(f'<meta property="og:image" content="{esc(image)}" />')
|
||||
if jsonld:
|
||||
payload = json.dumps(jsonld, ensure_ascii=False).replace("</", "<\\/")
|
||||
tags.append(f'<script type="application/ld+json">{payload}</script>')
|
||||
block = "\n ".join(tags)
|
||||
|
||||
raw = _read_index()
|
||||
if raw is None:
|
||||
# بیلد فرانتاند سوار نشده — صفحه حداقلی با خودِ متا تا خزندهها دست خالی نمانند
|
||||
lang_attr = 'lang="en" dir="ltr"' if lang == "en" else 'lang="fa" dir="rtl"'
|
||||
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
|
||||
return HTMLResponse(
|
||||
f'<!doctype html><html {lang_attr}><head><meta charset="utf-8">'
|
||||
f"<title>{esc(title)}</title>{alternates}{block}</head><body>{body}</body></html>"
|
||||
)
|
||||
|
||||
if lang == "en":
|
||||
# پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت، زبان و برند سند عوض میشود
|
||||
raw = re.sub(r'<html[^>]*>', '<html lang="en" dir="ltr">', raw, count=1)
|
||||
raw = raw.replace(FALLBACK_FA_BRAND, FALLBACK_EN_BRAND)
|
||||
raw = raw.replace('"inLanguage":"fa"', '"inLanguage":"en"')
|
||||
# description استاتیک حذف میشود تا با متای تزریقی تکراری نشود
|
||||
raw = re.sub(r'<meta name="description"[^>]*/>\s*', '', raw, count=1)
|
||||
# og:locale — در پوسته استاتیک بهروز میشود و از بلوک تزریقی حذف تا دوباره نوشته نشود
|
||||
og_locale = "en_US" if lang == "en" else "fa_IR"
|
||||
if re.search(r'<meta property="og:locale"', raw):
|
||||
raw = re.sub(
|
||||
r'(<meta property="og:locale" content=")[^"]*(")',
|
||||
lambda m: f'{m.group(1)}{og_locale}{m.group(2)}',
|
||||
raw,
|
||||
count=1,
|
||||
)
|
||||
tags = [t for t in tags if 'og:locale' not in t]
|
||||
block = "\n ".join(tags)
|
||||
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
|
||||
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1)
|
||||
if noscript:
|
||||
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
|
||||
return HTMLResponse(raw)
|
||||
|
||||
|
||||
def _brand_name(db: Session, lang: str = "fa") -> str:
|
||||
brand = db.query(models.Brand).first()
|
||||
if brand is None:
|
||||
return FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND
|
||||
if lang == "en":
|
||||
return brand.name_en or brand.name or FALLBACK_EN_BRAND
|
||||
return brand.name or FALLBACK_FA_BRAND
|
||||
|
||||
|
||||
def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None:
|
||||
return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first()
|
||||
|
||||
|
||||
def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]:
|
||||
"""عنوان و توضیح سئوی یک صفحه به زبان درخواستی — با برگشت به فارسی."""
|
||||
meta = _page_meta(db, page_key)
|
||||
if meta is None:
|
||||
return "", ""
|
||||
if lang == "en":
|
||||
return meta.title_en or meta.title or "", meta.description_en or meta.description or ""
|
||||
return meta.title or "", meta.description or ""
|
||||
|
||||
|
||||
# ---------- robots.txt و sitemap.xml ----------
|
||||
|
||||
|
||||
@router.get("/robots.txt", response_class=Response)
|
||||
def robots(db: Session = Depends(get_db)):
|
||||
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
|
||||
if settings.site_url:
|
||||
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
|
||||
return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
def _sitemap_entry(loc: str, fa: str, en: str) -> str:
|
||||
esc = html.escape
|
||||
return (
|
||||
f" <url><loc>{esc(_absolute(loc))}</loc>\n"
|
||||
f' <xhtml:link rel="alternate" hreflang="fa" href="{esc(_absolute(fa))}" />\n'
|
||||
f' <xhtml:link rel="alternate" hreflang="en" href="{esc(_absolute(en))}" />\n'
|
||||
f" </url>"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sitemap.xml", response_class=Response)
|
||||
def sitemap(db: Session = Depends(get_db)):
|
||||
fa_urls = list(STATIC_PATHS)
|
||||
fa_urls += [
|
||||
f"/blog/{a.slug}"
|
||||
for a in db.query(models.Article)
|
||||
.filter(models.Article.status == "published")
|
||||
.order_by(models.Article.position, models.Article.id)
|
||||
.all()
|
||||
]
|
||||
fa_urls += [
|
||||
f"/projects/{p.slug}"
|
||||
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
||||
]
|
||||
items = "\n".join(_sitemap_entry(u, u, _en_path(u)) for u in fa_urls)
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" '
|
||||
'xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'
|
||||
f"{items}\n</urlset>\n"
|
||||
)
|
||||
return Response(content=xml, media_type="application/xml; charset=utf-8")
|
||||
|
||||
|
||||
# ---------- صفحات وبلاگ ----------
|
||||
|
||||
|
||||
@router.get("/blog", response_class=HTMLResponse)
|
||||
def blog_list(db: Session = Depends(get_db)):
|
||||
meta = _page_meta(db, "blog")
|
||||
return _shell(
|
||||
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
|
||||
description=meta.description if meta else "",
|
||||
path="/blog",
|
||||
)
|
||||
|
||||
|
||||
def _article_pair(article: models.Article, lang: str) -> tuple[str, str]:
|
||||
"""عنوان و چکیده مقاله به زبان درخواستی — با برگشت به فارسی."""
|
||||
if lang == "en":
|
||||
return article.title_en or article.title, article.excerpt_en or article.excerpt
|
||||
return article.title, article.excerpt
|
||||
|
||||
|
||||
@router.get("/blog/{slug}", response_class=HTMLResponse)
|
||||
def blog_detail(slug: str, db: Session = Depends(get_db)):
|
||||
article = (
|
||||
db.query(models.Article)
|
||||
.filter(models.Article.slug == slug, models.Article.status == "published")
|
||||
.first()
|
||||
)
|
||||
if article is None:
|
||||
# مقاله ناموجود یا پیشنویس — پوسته ساده بدون متا؛ SPA پیام «یافت نشد» را نشان میدهد
|
||||
return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}")
|
||||
|
||||
brand = _brand_name(db)
|
||||
title, excerpt = _article_pair(article, "fa")
|
||||
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
||||
jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": title,
|
||||
"description": excerpt,
|
||||
"image": _absolute(article.image),
|
||||
"inLanguage": "fa",
|
||||
"author": {"@type": "Organization", "name": brand},
|
||||
"publisher": {"@type": "Organization", "name": brand},
|
||||
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
||||
}
|
||||
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||
return _shell(
|
||||
title=f"{title} | {brand}",
|
||||
description=excerpt,
|
||||
path=f"/blog/{article.slug}",
|
||||
og_image=article.image,
|
||||
og_type="article",
|
||||
jsonld=jsonld,
|
||||
noscript=noscript,
|
||||
)
|
||||
|
||||
|
||||
# ---------- صفحات پروژه ----------
|
||||
|
||||
|
||||
@router.get("/projects", response_class=HTMLResponse)
|
||||
def projects_list(db: Session = Depends(get_db)):
|
||||
meta = _page_meta(db, "projects")
|
||||
return _shell(
|
||||
title=meta.title if meta and meta.title else "پروژهها | " + _brand_name(db),
|
||||
description=meta.description if meta else "",
|
||||
path="/projects",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{slug}", response_class=HTMLResponse)
|
||||
def project_detail(slug: str, db: Session = Depends(get_db)):
|
||||
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
||||
if project is None:
|
||||
return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}")
|
||||
|
||||
brand = _brand_name(db)
|
||||
jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": project.title,
|
||||
"description": project.summary,
|
||||
"image": _absolute(project.image),
|
||||
"inLanguage": "fa",
|
||||
}
|
||||
return _shell(
|
||||
title=f"{project.title} | {brand}",
|
||||
description=project.summary,
|
||||
path=f"/projects/{project.slug}",
|
||||
og_image=project.image,
|
||||
jsonld=jsonld,
|
||||
)
|
||||
|
||||
|
||||
# ---------- صفحات انگلیسی (/en/...) ----------
|
||||
|
||||
|
||||
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
||||
brand = _brand_name(db, "en")
|
||||
title, excerpt = _article_pair(article, "en")
|
||||
body = article.body_en if isinstance(article.body_en, dict) else article.body
|
||||
first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2])
|
||||
jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": title,
|
||||
"description": excerpt,
|
||||
"image": _absolute(article.image),
|
||||
"inLanguage": "en",
|
||||
"author": {"@type": "Organization", "name": brand},
|
||||
"publisher": {"@type": "Organization", "name": brand},
|
||||
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
|
||||
}
|
||||
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||
return _shell(
|
||||
title=f"{title} | {brand}",
|
||||
description=excerpt,
|
||||
path=f"/en/blog/{article.slug}",
|
||||
lang="en",
|
||||
og_image=article.image,
|
||||
og_type="article",
|
||||
jsonld=jsonld,
|
||||
noscript=noscript,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/en", response_class=HTMLResponse)
|
||||
@router.get("/en/", response_class=HTMLResponse)
|
||||
def en_home(db: Session = Depends(get_db)):
|
||||
title, description = _meta_for(db, "home", "en")
|
||||
if not title:
|
||||
title = _brand_name(db, "en")
|
||||
return _shell(title=title, description=description, path="/en", lang="en")
|
||||
|
||||
|
||||
@router.get("/en/blog", response_class=HTMLResponse)
|
||||
def en_blog_list(db: Session = Depends(get_db)):
|
||||
meta = _page_meta(db, "blog")
|
||||
title = meta.title_en or meta.title if meta else ""
|
||||
description = meta.description_en or meta.description if meta else ""
|
||||
if not title:
|
||||
title = "Blog | " + _brand_name(db, "en")
|
||||
return _shell(title=title, description=description, path="/en/blog", lang="en")
|
||||
|
||||
|
||||
@router.get("/en/blog/{slug}", response_class=HTMLResponse)
|
||||
def en_blog_detail(slug: str, db: Session = Depends(get_db)):
|
||||
article = (
|
||||
db.query(models.Article)
|
||||
.filter(models.Article.slug == slug, models.Article.status == "published")
|
||||
.first()
|
||||
)
|
||||
if article is None:
|
||||
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en")
|
||||
return _en_blog_detail(article, db)
|
||||
|
||||
|
||||
@router.get("/en/projects", response_class=HTMLResponse)
|
||||
def en_projects_list(db: Session = Depends(get_db)):
|
||||
meta = _page_meta(db, "projects")
|
||||
title = meta.title_en or meta.title if meta else ""
|
||||
description = meta.description_en or meta.description if meta else ""
|
||||
if not title:
|
||||
title = "Projects | " + _brand_name(db, "en")
|
||||
return _shell(title=title, description=description, path="/en/projects", lang="en")
|
||||
|
||||
|
||||
@router.get("/en/projects/{slug}", response_class=HTMLResponse)
|
||||
def en_project_detail(slug: str, db: Session = Depends(get_db)):
|
||||
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
||||
if project is None:
|
||||
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/projects/{slug}", lang="en")
|
||||
|
||||
brand = _brand_name(db, "en")
|
||||
title = project.title_en or project.title
|
||||
summary = project.summary_en or project.summary
|
||||
jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": title,
|
||||
"description": summary,
|
||||
"image": _absolute(project.image),
|
||||
"inLanguage": "en",
|
||||
}
|
||||
return _shell(
|
||||
title=f"{title} | {brand}",
|
||||
description=summary,
|
||||
path=f"/en/projects/{project.slug}",
|
||||
lang="en",
|
||||
og_image=project.image,
|
||||
jsonld=jsonld,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/en/{path:path}", response_class=HTMLResponse)
|
||||
def en_page(path: str, db: Session = Depends(get_db)):
|
||||
"""بقیه مسیرهای انگلیسی (about، services، ...) — متا از seo_meta به انگلیسی."""
|
||||
page_key = path.split("/")[0] if path else ""
|
||||
title, description = _meta_for(db, page_key, "en") if page_key in META_KEYS else ("", "")
|
||||
brand = _brand_name(db, "en")
|
||||
if not title:
|
||||
# عنوان پیشفرض از برچسب ناوبری انگلیسی همان صفحه
|
||||
nav = db.query(models.NavItem).filter(models.NavItem.href == f"/{page_key}").first() if page_key else None
|
||||
label = (nav.label_en or nav.label) if nav else ""
|
||||
title = f"{label} | {brand}" if label else brand
|
||||
return _shell(title=title, description=description, path=f"/en/{path}".rstrip("/"), lang="en")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""محیط Alembic — متادیتا از مدلهای SQLAlchemy میآید و آدرس دیتابیس از تنظیمات."""
|
||||
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app import models # noqa: F401 — ثبت مدلها در متادیتا
|
||||
from app.config import settings
|
||||
from app.database import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
return os.environ.get("DATABASE_URL", settings.database_url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=get_url(),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = get_url()
|
||||
connectable = engine_from_config(configuration, prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""نقطه شروع زنجیره مهاجرتها — جداول پایه با create_all ساخته میشوند.
|
||||
|
||||
ساخت اولیه ۲۹ جدول بر عهده Base.metadata.create_all است (در CMD کانتینر پیش از
|
||||
این مهاجرت اجرا میشود و برای دیتابیسهای موجود بیاثر است). از این نسخه به بعد
|
||||
هر تغییر اسکیما باید یک revision واقعی Alembic باشد — نه create_all و نه
|
||||
تغییر دستی — تا مسیر ارتقا و بازگشت آزمایشپذیر بماند.
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-09-18
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,4 @@
|
||||
# وابستگیهای توسعه و تست — pip install -r requirements-dev.txt
|
||||
-r requirements.txt
|
||||
pytest==8.3.4
|
||||
httpx==0.27.2
|
||||
@@ -1,9 +1,21 @@
|
||||
fastapi>=0.115.8
|
||||
uvicorn[standard]>=0.34.0
|
||||
sqlalchemy>=2.0.36
|
||||
psycopg[binary]>=3.2.3
|
||||
pydantic>=2.10.4
|
||||
pydantic-settings>=2.6.1
|
||||
PyJWT>=2.10.1
|
||||
bcrypt>=4.2.1
|
||||
email-validator>=2.2.0
|
||||
# نسخهها دقیق پین شدهاند تا ارتقای خودکار یک وابستگی، استقرار را نشکند.
|
||||
# ارتقا: نسخه را دستی بالا ببرید و روی محیط آمادهسازی تست کنید.
|
||||
fastapi==0.115.8
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
psycopg[binary]==3.2.3
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.6.1
|
||||
PyJWT==2.10.1
|
||||
bcrypt==4.2.1
|
||||
email-validator==2.2.0
|
||||
boto3==1.35.0
|
||||
python-multipart==0.0.18
|
||||
# پاکسازی HTML سمت سرور (تیپتپ و فیلدهای غنی)
|
||||
nh3==0.2.20
|
||||
# بهینهسازی تصویر در زمان آپلود (تغییر اندازه/فشردهسازی مجدد)
|
||||
Pillow==11.1.0
|
||||
# متریکهای Prometheus برای مانیتورینگ (در /metrics داخلی)
|
||||
prometheus-fastapi-instrumentator==7.0.0
|
||||
# مهاجرتهای نسخهدار اسکیما
|
||||
alembic==1.14.0
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""پیکربندی تستها — دیتابیس 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()
|
||||
rate_limit.contact_limiter._events.clear()
|
||||
rate_limit.newsletter_limiter._events.clear()
|
||||
yield
|
||||
rate_limit._failed.clear()
|
||||
rate_limit.contact_limiter._events.clear()
|
||||
rate_limit.newsletter_limiter._events.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def auth_client(client):
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
|
||||
assert res.status_code == 200, res.text
|
||||
token = res.json()["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield client
|
||||
client.headers.pop("Authorization", None)
|
||||
@@ -0,0 +1,363 @@
|
||||
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
||||
|
||||
import re
|
||||
|
||||
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
|
||||
# ---------- عمومی ----------
|
||||
|
||||
|
||||
def test_live(client):
|
||||
"""liveness — پروسه زنده است، بدون وابستگی."""
|
||||
res = client.get("/api/live")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_health_deep(client):
|
||||
"""بررسی عمق — DB (SQLite در تست) سالم است؛ MinIO در محیط تست در دسترس نیست
|
||||
پس /api/health باید 503 و وضعیت degraded با جزئیات هر وابستگی بدهد."""
|
||||
res = client.get("/api/health")
|
||||
assert res.status_code == 503
|
||||
body = res.json()
|
||||
assert body["status"] == "degraded"
|
||||
assert body["checks"]["database"] == "ok"
|
||||
assert body["checks"]["storage"] == "error"
|
||||
|
||||
|
||||
def test_site_hides_draft_articles(client):
|
||||
res = client.get("/api/site")
|
||||
assert res.status_code == 200
|
||||
slugs = [a["slug"] for a in res.json()["articles"]]
|
||||
assert "published-post" in slugs
|
||||
assert "draft-post" not in slugs
|
||||
|
||||
|
||||
def test_public_articles_hide_drafts(client):
|
||||
res = client.get("/api/articles")
|
||||
assert all(a["status"] == "published" for a in res.json())
|
||||
assert client.get("/api/articles/draft-post").status_code == 404
|
||||
|
||||
|
||||
def test_admin_endpoints_reject_anonymous(client):
|
||||
assert client.get("/api/admin/articles").status_code == 401
|
||||
|
||||
|
||||
# ---------- احراز هویت ----------
|
||||
|
||||
|
||||
def test_login_success_and_failure(client):
|
||||
assert (
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}).status_code
|
||||
== 401
|
||||
)
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["access_token"]
|
||||
|
||||
|
||||
def test_change_password(auth_client, client):
|
||||
res = auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "wrong", "new_password": "brand-new-pass-9"},
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
res = auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "test-admin-pass-1", "new_password": "brand-new-pass-9"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
# رمز قدیمی رد و رمز جدید قبول میشود
|
||||
assert (
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"}).status_code
|
||||
== 401
|
||||
)
|
||||
assert (
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"}).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
# بازگرداندن رمز برای تستهای بعدی
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"})
|
||||
auth = {"Authorization": f"Bearer {res.json()['access_token']}"}
|
||||
res = client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "brand-new-pass-9", "new_password": "test-admin-pass-1"},
|
||||
headers=auth,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
def test_login_rate_limited(client):
|
||||
for _ in range(5):
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert res.status_code == 401
|
||||
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert res.status_code == 429
|
||||
assert "Retry-After" in res.headers
|
||||
|
||||
|
||||
# ---------- CRUD مدیریت ----------
|
||||
|
||||
|
||||
def test_article_crud_and_draft_flow(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={"slug": "flow-post", "title": "مقاله جریان", "body": DOC},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
article = created.json()
|
||||
# مقاله تازه پیشنویس است و در سایت عمومی دیده نمیشود
|
||||
assert article["status"] == "draft"
|
||||
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
|
||||
|
||||
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"seo_keyphrase": "ساختمان"})
|
||||
assert res.json()["seo_keyphrase"] == "ساختمان"
|
||||
|
||||
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"status": "published"})
|
||||
assert res.status_code == 200
|
||||
assert "flow-post" in [a["slug"] for a in client.get("/api/articles").json()]
|
||||
|
||||
assert auth_client.delete(f"/api/admin/articles/{article['id']}").status_code == 204
|
||||
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
|
||||
|
||||
|
||||
def test_service_roundtrip_and_reorder(auth_client):
|
||||
ids = []
|
||||
for name in ("خدمت الف", "خدمت ب"):
|
||||
res = auth_client.post("/api/admin/services", json={"title": name})
|
||||
assert res.status_code == 201
|
||||
ids.append(res.json()["id"])
|
||||
|
||||
res = auth_client.post("/api/admin/services/reorder", json={"ids": ids[::-1]})
|
||||
assert res.status_code == 200
|
||||
ordered = [s["id"] for s in res.json() if s["id"] in ids]
|
||||
assert ordered == ids[::-1]
|
||||
|
||||
res = auth_client.patch(f"/api/admin/services/{ids[0]}", json={"desc": "توضیح تازه"})
|
||||
assert res.json()["desc"] == "توضیح تازه"
|
||||
|
||||
for i in ids:
|
||||
assert auth_client.delete(f"/api/admin/services/{i}").status_code == 204
|
||||
|
||||
|
||||
def test_seo_meta_create_and_patch(auth_client):
|
||||
res = auth_client.post(
|
||||
"/api/admin/seo-meta", json={"page_key": "test-page", "title": "عنوان", "description": "توضیح"}
|
||||
)
|
||||
assert res.status_code == 201
|
||||
meta_id = res.json()["id"]
|
||||
res = auth_client.patch(f"/api/admin/seo-meta/{meta_id}", json={"title": "عنوان تازه"})
|
||||
assert res.json()["title"] == "عنوان تازه"
|
||||
assert auth_client.delete(f"/api/admin/seo-meta/{meta_id}").status_code == 204
|
||||
|
||||
|
||||
# ---------- سئو ----------
|
||||
|
||||
|
||||
def test_robots_txt(client):
|
||||
res = client.get("/robots.txt")
|
||||
assert res.status_code == 200
|
||||
assert "Disallow: /admin" in res.text
|
||||
assert "Sitemap: https://test.example/sitemap.xml" in res.text
|
||||
|
||||
|
||||
def test_sitemap_includes_published_only(client):
|
||||
res = client.get("/sitemap.xml")
|
||||
assert res.status_code == 200
|
||||
assert "/blog/published-post" in res.text
|
||||
assert "/blog/draft-post" not in res.text
|
||||
assert res.text.startswith("<?xml")
|
||||
|
||||
|
||||
def test_head_injection_for_article(client):
|
||||
res = client.get("/blog/published-post")
|
||||
assert res.status_code == 200
|
||||
assert "مقاله منتشرشده" in res.text
|
||||
assert 'property="og:title"' in res.text
|
||||
assert "application/ld+json" in res.text
|
||||
assert "https://test.example/blog/published-post" in res.text
|
||||
|
||||
|
||||
def test_head_not_injected_for_draft(client):
|
||||
res = client.get("/blog/draft-post")
|
||||
assert res.status_code == 200
|
||||
assert "پیشنویس" not in res.text # عنوان پیشنویس افشا نمیشود
|
||||
assert "application/ld+json" not in res.text
|
||||
|
||||
|
||||
def test_head_replaces_title_once(client):
|
||||
res = client.get("/blog/published-post")
|
||||
assert len(re.findall(r"<title>", res.text)) == 1
|
||||
|
||||
|
||||
# ---------- دوزبانه ----------
|
||||
|
||||
|
||||
def test_bilingual_project_and_en_seo_shell(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/projects",
|
||||
json={
|
||||
"slug": "bilingual-tower",
|
||||
"title": "برج دوزبانه",
|
||||
"title_en": "Bilingual Tower",
|
||||
"summary": "خلاصه فارسی",
|
||||
"summary_en": "English summary",
|
||||
"body": [],
|
||||
"body_en": ["English paragraph"],
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
pid = created.json()["id"]
|
||||
|
||||
# GET /api/site هر دو زبان را برمیگرداند
|
||||
project = next(p for p in client.get("/api/site").json()["projects"] if p["slug"] == "bilingual-tower")
|
||||
assert project["title"] == "برج دوزبانه"
|
||||
assert project["title_en"] == "Bilingual Tower"
|
||||
assert project["body_en"] == ["English paragraph"]
|
||||
|
||||
# پوسته سئوی انگلیسی — جهت و متای انگلیسی و hreflang متقابل
|
||||
res = client.get("/en/projects/bilingual-tower")
|
||||
assert res.status_code == 200
|
||||
assert 'lang="en" dir="ltr"' in res.text
|
||||
assert "Bilingual Tower" in res.text
|
||||
assert "English summary" in res.text
|
||||
assert 'hreflang="fa"' in res.text
|
||||
assert "https://test.example/projects/bilingual-tower" in res.text
|
||||
assert "https://test.example/en/projects/bilingual-tower" in res.text
|
||||
|
||||
# sitemap دو زبانه با alternates
|
||||
sm = client.get("/sitemap.xml").text
|
||||
assert "xmlns:xhtml" in sm
|
||||
assert 'hreflang="en"' in sm
|
||||
assert "/en/projects/bilingual-tower" in sm
|
||||
|
||||
# پوسته فارسی هم alternates دارد
|
||||
assert 'hreflang="en"' in client.get("/blog/published-post").text
|
||||
|
||||
# صفحه استاتیک انگلیسی از مسیر catch-all سرو میشود
|
||||
res = client.get("/en/about")
|
||||
assert res.status_code == 200
|
||||
assert 'lang="en"' in res.text
|
||||
|
||||
assert auth_client.delete(f"/api/admin/projects/{pid}").status_code == 204
|
||||
assert "bilingual-tower" not in client.get("/sitemap.xml").text
|
||||
|
||||
|
||||
def test_bilingual_article_en_shell(auth_client, client):
|
||||
articles = auth_client.get("/api/admin/articles").json()
|
||||
article = next(a for a in articles if a["slug"] == "published-post")
|
||||
res = auth_client.patch(
|
||||
f"/api/admin/articles/{article['id']}",
|
||||
json={
|
||||
"title_en": "Published EN",
|
||||
"excerpt_en": "English excerpt",
|
||||
"body_en": DOC,
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
res = client.get("/en/blog/published-post")
|
||||
assert res.status_code == 200
|
||||
assert "Published EN" in res.text
|
||||
assert "English excerpt" in res.text
|
||||
assert '"inLanguage": "en"' in res.text
|
||||
|
||||
|
||||
def test_en_backfill_fills_only_empty_fields(auth_client, client):
|
||||
"""backfill ترجمههای خالی را پر میکند و مقدار ثبتشده را بازنویسی نمیکند."""
|
||||
from app.migrations import backfill_en_content
|
||||
|
||||
backfill_en_content()
|
||||
brand = client.get("/api/site").json()["brand"]
|
||||
assert brand["name_en"] == "Aramesh Construction Group"
|
||||
|
||||
# مقدار دستیِ ادمین محترم شمرده میشود
|
||||
res = auth_client.patch(f"/api/admin/brand/{brand['id']}", json={"name_en": "Custom EN Name"})
|
||||
assert res.status_code == 200
|
||||
backfill_en_content()
|
||||
assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name"
|
||||
|
||||
|
||||
# ---------- محافظت فرمها و پاکسازی ----------
|
||||
|
||||
|
||||
def test_contact_honeypot_saves_nothing(client, auth_client):
|
||||
"""دامنهٔ پنهان پرشده یعنی ربات — پاسخ موفقِ قلابی، بدون ذخیره."""
|
||||
res = client.post(
|
||||
"/api/contact",
|
||||
json={"name": "ربات تستی", "phone": "09120000000", "website": "spam"},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["id"] == 0 # رکورد قلابی — در دیتابیس نیست
|
||||
assert auth_client.get("/api/admin/messages").json() == []
|
||||
|
||||
|
||||
def test_contact_rate_limited(client, auth_client):
|
||||
"""ارسال ششم در پنجرهٔ ۱۵ دقیقهای رد میشود (۵ مجاز برای هر IP)."""
|
||||
for _ in range(5):
|
||||
res = client.post(
|
||||
"/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"}
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
res = client.post("/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"})
|
||||
assert res.status_code == 429
|
||||
assert "Retry-After" in res.headers
|
||||
|
||||
|
||||
def test_newsletter_honeypot_saves_nothing(client, auth_client):
|
||||
res = client.post(
|
||||
"/api/newsletter", json={"email": "bot@example.com", "website": "spam"}
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["id"] == 0
|
||||
assert auth_client.get("/api/admin/subscribers").json() == []
|
||||
|
||||
|
||||
def test_message_and_subscriber_csv_export(auth_client):
|
||||
auth_client.post("/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"})
|
||||
res = auth_client.get("/api/admin/messages/export.csv")
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"].startswith("text/csv")
|
||||
assert "کاربر تماس" in res.text
|
||||
|
||||
auth_client.post("/api/newsletter", json={"email": "sale@example.com"})
|
||||
res = auth_client.get("/api/admin/subscribers/export.csv")
|
||||
assert res.status_code == 200
|
||||
assert "sale@example.com" in res.text
|
||||
|
||||
|
||||
def test_article_body_url_sanitization(auth_client):
|
||||
"""اسکیمهای خطرناک در href/src سند TipTap در زمان ذخیره حذف میشوند."""
|
||||
res = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={
|
||||
"slug": "sanitized-post",
|
||||
"title": "مقاله پاکسازیشده",
|
||||
"status": "published",
|
||||
"body": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{"type": "link", "attrs": {"href": "javascript:alert(1)"}},
|
||||
],
|
||||
"text": "لینک خطرناک",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
body = res.json()["body"]
|
||||
marks = body["content"][0]["content"][0].get("marks", [])
|
||||
assert all("href" not in m.get("attrs", {}) for m in marks)
|
||||
@@ -0,0 +1,12 @@
|
||||
# کانتکست بیلد باریک
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.env.*
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
test_pure.py
|
||||
@@ -0,0 +1,28 @@
|
||||
# سرویس چتبات — این مقادیر را در .env ریشه پروژه بگذارید (نه اینجا)
|
||||
# این فایل فقط مستندسازی متغیرهاست.
|
||||
|
||||
# --- اجباری ---
|
||||
# دیتابیس سایت (فقط خواندن) — در داکر از docker-compose.yml تزریق میشود
|
||||
# DATABASE_URL=postgresql+psycopg://aramesh:PASSWORD@db:5432/arameshplaza
|
||||
|
||||
# LLM سازگار با OpenAI — بدون این چتبات پاسخ نمیدهد
|
||||
# مثال سرور داخلی: LLM_BASE_URL=http://172.16.22.21:4460/v1
|
||||
# مثال سرویس ابری: LLM_BASE_URL=https://api.example.com/v1
|
||||
LLM_BASE_URL=
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=openai/gpt-oss-120b
|
||||
|
||||
# --- اختیاری ---
|
||||
# توکن محافظت از POST /chat/reindex — اگر خالی باشد مسیر ۴۰۳ میدهد
|
||||
CHAT_REINDEX_TOKEN=
|
||||
# ریرنکر را روی سرور کمرم خاموش کنید (RERANKER_ENABLED=false)
|
||||
RERANKER_ENABLED=true
|
||||
# وزن جستوجوی معنایی در ترکیب نمرهها (۰ تا ۱) — پیشفرض ۰.۵
|
||||
ALPHA_DENSE=0.5
|
||||
# تعداد سند نهایی به LLM — پیشفرض ۳
|
||||
BEST_OF_K=3
|
||||
# بازسازی خودکار ایندکس پس از تغییر محتوا (ثانیه، ۰ = خاموش)
|
||||
INDEX_REFRESH_SECONDS=600
|
||||
# محدودیت نرخ: حداکثر پیام در پنجره زمانی (پیشفرض ۲۰ پیام در ۳۰۰ ثانیه)
|
||||
RATE_LIMIT_MESSAGES=20
|
||||
RATE_LIMIT_WINDOW=300
|
||||
@@ -0,0 +1,29 @@
|
||||
# سرویس چتبات — پایتون + مدلهای بازیابی (CPU)
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
# همهٔ وابستگیهای سنگین (numpy/scikit-learn/torch/psycopg) ویل prebuilt دارند —
|
||||
# بدون کامپایلر؛ دسترسی apt در شبکهٔ بیلد قابلاعتماد نیست
|
||||
COPY requirements.txt /srv/chatbot/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /srv/chatbot/requirements.txt
|
||||
|
||||
COPY . /srv/chatbot/
|
||||
|
||||
# مدل امبدینگ/ریرنکر در /data کش میشود (والیوم داکر) — بردارها در pgvector میروند و فایل ایندکس جدا وجود ندارد
|
||||
ENV HF_HOME=/data/hf \
|
||||
HF_CACHE_DIR=/data/hf
|
||||
VOLUME /data
|
||||
|
||||
# کاربر بدون دسترسی root — فقط /data باید نوشتنی باشد (کش مدلها)
|
||||
RUN useradd --system --create-home appuser \
|
||||
&& mkdir -p /data \
|
||||
&& chown -R appuser:appuser /srv/chatbot /data
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=5 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/chat/healthz')" || exit 1
|
||||
|
||||
CMD ["uvicorn", "chatbot.app:api", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]
|
||||
@@ -0,0 +1,121 @@
|
||||
# دستیار هوشمند سایت (چتبات)
|
||||
|
||||
سرویس گفتگوی محتوای سایت «گروه ساختمانی آرامش» — بازدیدکننده به فارسی سؤال میپرسد و
|
||||
دستیار **فقط از روی محتوای خود سایت** (مقالهها، پروژهها، خدمات، سؤالات متداول، اطلاعات تماس و…)
|
||||
پاسخ میدهد؛ اگر پاسخ در محتوا نباشد، بهجای ساخت جواب، صادقانه اعلام میکند.
|
||||
|
||||
این سرویس پورت خط لولهٔ پروژهٔ `llm-srerver-chatbot` (نسخه v13) با چهار تغییر اصلی برای این سایت است:
|
||||
|
||||
1. **پایگاه sparse بهجای SPLADE از BM25 استفاده میکند** — SPLADE انگلیسیمحور است و روی
|
||||
فارسی عملاً بیفایده؛ BM25 با نرمالسازی فارسی (ی/ک عربی، اعراب، ارقام، نیمفاصله) جایگزین شده است.
|
||||
2. **ذخیرهساز برداری pgvector است، نه FAISS** — بردارها در یک دیتابیس جدا (`aramesh_chatbot`)
|
||||
روی همان نمونه Postgres نگهداری میشوند؛ همگامسازی افزایشی است (فقط سطرهای جدید/تغییریافته
|
||||
امبد میشوند)، بکاپگیری با خود دیتابیس است و فایل ایندکس جدا وجود ندارد.
|
||||
3. **تأخیر کمتر** — مسیریابی `alpha` با LLM و گسترش چندپرسشی (multi-query) بهصورت پیشفرض
|
||||
خاموشاند (با متغیر محیطی قابل فعالسازی)؛ بازنویسی پرسش فقط وقتی تاریخچه گفتگو وجود دارد اجرا میشود.
|
||||
4. **استریم واقعی توکنبهتوکن** با `astream_events` بهجای دیف پیامهای کامل.
|
||||
|
||||
## خط لوله هر پیام
|
||||
|
||||
```
|
||||
پرسش کاربر
|
||||
│
|
||||
├─[اگر تاریخچه هست] بازنویسی پرسش با LLM (حل ضمیرها و اشارهها: «اون دومی» → موضوع واقعی)
|
||||
│
|
||||
├─ بازیابی ترکیبی: pgvector (معنایی، کسینوسی روی HNSW — MiniLM چندزبانه)
|
||||
│ + BM25 (کلیدواژه، متن نرمالشده فارسی)
|
||||
│ نمرهها min-max → ترکیب α×معنایی + (1-α)×کلیدواژه
|
||||
│
|
||||
├─ برش خوشهای (K-Means K=2 + silhouette ≥ 0.55) — مرز طبیعی کیفیت
|
||||
│
|
||||
├─ ریرنک با BAAI/bge-reranker-v2-m3 → نگهداشتن ۳ سند برتر
|
||||
│
|
||||
└─ تولید پاسخ فارسی با LLM (قواعد تکیه بر داده + معرفی صفحه مرتبط + پیام جایگزین مشخص)
|
||||
```
|
||||
|
||||
ایندکس از محتوای PostgreSQL سایت ساخته میشود (`articles` منتشرشده، `projects`، `services`،
|
||||
`faqs`، `brand`، `offices`، `investment_models`، `process_steps`، `why_us`، `team`،
|
||||
`testimonials`، `timeline`، `story`). متنهای بلند به قطعات ~۷۰۰ نویسهای همپوشان برش میخورند.
|
||||
|
||||
- **ایندکس برداری در pgvector**: جدول `chatbot_chunks` در دیتابیس اختصاصی
|
||||
`aramesh_chatbot` (روی همان نمونه Postgres) با ستون `embedding vector(384)` و
|
||||
ایندکس HNSW کسینوسی؛ هر قطعه `doc_id` پایدار و `content_hash` دارد و همگامسازی
|
||||
فقط سطرهای جدید/تغییریافته را امبد میکند. دیتابیس و جدول در شروع سرویس خودکار ساخته میشوند.
|
||||
- **بازسازی خودکار**: یک حلقهٔ پسزمینه هر `INDEX_REFRESH_SECONDS` ثانیه محتوا را چک
|
||||
میکند و اگر ادمین چیزی را در پنل عوض کرده باشد، فقط تغییرات همگام میشود
|
||||
(امبد اجباری همه: `POST /chat/reindex`).
|
||||
- **ماندگاری گفتگو**: در حافظهٔ همان پروسه (MemorySaver لنگگراف) — با ریاستارت پاک میشود.
|
||||
|
||||
## متغیرهای محیطی (در `.env` ریشه پروژه)
|
||||
|
||||
| متغیر | پیشفرض | توضیح |
|
||||
|---|---|---|
|
||||
| `LLM_BASE_URL` | خالی | **اجباری** — نشانی سرویس LLM سازگار با OpenAI. بدون آن چتبات بالا میآید ولی پاسخ نمیدهد |
|
||||
| `LLM_API_KEY` | خالی | کلید API (برای سرور داخلی `not-needed` بگذارید) |
|
||||
| `LLM_MODEL` | `openai/gpt-oss-120b` | نام مدل |
|
||||
| `CHAT_REINDEX_TOKEN` | خالی | توکن `POST /chat/reindex` — اگر خالی باشد آن مسیر ۴۰۳ میدهد |
|
||||
| `CHATBOT_DB_NAME` | `aramesh_chatbot` | نام دیتابیس اختصاصی چتبات روی همان نمونه Postgres (بردارها اینجا میروند) |
|
||||
| `EMBEDDING_DIM` | `384` | بعد بردار مدل امبدینگ — با تعویض مدل امبدینگ باید هماهنگ شود |
|
||||
| `RERANKER_ENABLED` | `true` | روی سرور کمرم (زیر ~۴GB) `false` بگذارید |
|
||||
| `ALPHA_DENSE` | `0.5` | وزن جستوجوی معنایی در ترکیب نمرهها (۰ تا ۱) |
|
||||
| `BEST_OF_K` | `3` | تعداد سند نهایی که به LLM میرود |
|
||||
| `INDEX_REFRESH_SECONDS` | `600` | بازسازی خودکار ایندکس پس از تغییر محتوا (۰ = خاموش) |
|
||||
| `RATE_LIMIT_MESSAGES` / `RATE_LIMIT_WINDOW` | `20` / `300` | سقف پیام هر IP در پنجره زمانی (ثانیه) |
|
||||
|
||||
`DATABASE_URL` در داکر از `docker-compose.yml` تزریق میشود — دیتابیس سایت فقط خوانده
|
||||
میشود و نوشتن (بردارها) فقط در دیتابیس جدا `aramesh_chatbot` انجام میگیرد.
|
||||
|
||||
## پیشنیاز pgvector
|
||||
|
||||
سرویس `db` استک از ایمیج `pgvector/pgvector:pg17` استفاده میکند (همان postgres:17 +
|
||||
افزونهٔ vector). اگر Postgres سایت روی نمونهٔ دیگری است، باید افزونهٔ pgvector روی آن
|
||||
فعال باشد؛ در غیر این صورت شروع چتبات با خطای واضح متوقف میشود.
|
||||
|
||||
## مسیرها
|
||||
|
||||
| مسیر | توضیح |
|
||||
|---|---|
|
||||
| `GET /chat/stream?message=…&thread_id=…` | استریم SSE توکنبهتوکن پاسخ (`{"content": …}` و در پایان `{"done": true}`) |
|
||||
| `GET /chat/suggestions` | ۴ پیشنهاد پرسش (کش ۱۰ دقیقه؛ جایگزین: خود سؤالات متداول) |
|
||||
| `GET /chat/eval?message=…` | مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابیشده با thread تازه |
|
||||
| `GET /chat/healthz` | وضعیت ایندکس/LLM/تعداد اسناد |
|
||||
| `POST /chat/reindex` | بازسازی اجباری ایندکس (هدر `X-Reindex-Token`) |
|
||||
|
||||
مسیرهای `GET /chat/{scope}/stream|eval|suggestions` هم برای سازگاری با اسکریپت
|
||||
`eval_ragas.py` قدیمی موجود است — کافی است `--url` را به نشانی این سرویس بدهید.
|
||||
|
||||
## اجرا
|
||||
|
||||
با داکر (بخشی از استک اصلی است — `docker compose up -d --build`):
|
||||
|
||||
```bash
|
||||
# در .env ریشه:
|
||||
LLM_BASE_URL=http://172.16.22.21:4460/v1 # یا هر سرویس سازگار با OpenAI
|
||||
LLM_API_KEY=not-needed
|
||||
```
|
||||
|
||||
اولین بوت مدلها را دانلود میکند (~۱.۵GB: امبدینگ + ریرنکر) و در والیوم
|
||||
`chatbot_cache` کش میشود؛ بوتهای بعدی چند ثانیه طول میکشد.
|
||||
|
||||
اجرای محلی بدون داکر:
|
||||
|
||||
```bash
|
||||
cd chatbot
|
||||
python -m venv .venv && source .venv/bin/activate # ویندوز: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
export DATABASE_URL="postgresql+psycopg://aramesh:PASS@localhost:5432/arameshplaza"
|
||||
export LLM_BASE_URL="http://…" LLM_MODEL="openai/gpt-oss-120b"
|
||||
uvicorn chatbot.app:api --port 8000 # از ریشه پوشه والد
|
||||
```
|
||||
|
||||
## منابع مورد نیاز
|
||||
|
||||
- **رم**: با ریرنکر ~۴GB (مدل ۵۶۸M پارامتری روی CPU)؛ بدون ریرنکر ~۲GB
|
||||
- **CPU**: امبدینگ و ریرنک روی CPU اجرا میشوند؛ GPU لازم نیست
|
||||
- **شبکه**: دسترسی به PostgreSQL و به نشانی `LLM_BASE_URL`
|
||||
|
||||
## نکات امنیتی
|
||||
|
||||
- دیتابیس سایت فقط **خواندن**؛ نوشتن فقط در دیتابیس اختصاصی `aramesh_chatbot` انجام میشود
|
||||
- محدودسازی نرخ per-IP فعال است (پیشفرض ۲۰ پیام در ۵ دقیقه)
|
||||
- CORS باز است ولی کوکی/اعتبارنامهای رد و بدل نمیشود؛ در تولید همهچیز هممبدأ (پشت Traefik) است
|
||||
@@ -0,0 +1 @@
|
||||
"""سرویس چتبات سایت آرامش — بازیابی ترکیبی روی محتوای PostgreSQL."""
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
"""سرویس چتبات سایت آرامش — FastAPI با استریم SSE و بازیابی ترکیبی روی محتوای دیتابیس."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from . import content as content_mod
|
||||
from . import pipeline
|
||||
from . import vectorstore
|
||||
from .config import settings
|
||||
from .indexing import INDEX, sync_index
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
||||
)
|
||||
log = logging.getLogger("chatbot")
|
||||
|
||||
|
||||
# --- چرخه حیات: ساخت ایندکس در شروع + بازسازی خودکار وقتی محتوا عوض شود ---
|
||||
|
||||
_refresh_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
try:
|
||||
await asyncio.to_thread(sync_index)
|
||||
except Exception as e:
|
||||
log.critical("Initial index sync failed: %s — chat will be unavailable until reindex", e)
|
||||
global _refresh_task
|
||||
_refresh_task = asyncio.create_task(_refresh_loop())
|
||||
yield
|
||||
_refresh_task.cancel()
|
||||
|
||||
|
||||
async def _refresh_loop() -> None:
|
||||
interval = settings.index_refresh_seconds
|
||||
if interval <= 0:
|
||||
return
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
# همگامسازی افزایشی است — فقط سطرهای جدید/تغییریافته امبد میشوند
|
||||
await asyncio.to_thread(sync_index)
|
||||
except Exception as e:
|
||||
log.warning("Background index refresh failed: %s", e)
|
||||
|
||||
|
||||
api = FastAPI(
|
||||
title="Aramesh Site Chatbot",
|
||||
version="1.0.0",
|
||||
description="دستیار گفتگوی محتوای سایت — بازیابی ترکیبی (pgvector + BM25) با ریرنکر BGE",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
api.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# --- محدودسازی نرخ درخواست سبک در حافظه (پنجره لغزان per-IP) ---
|
||||
|
||||
_hits: dict[str, deque] = defaultdict(deque)
|
||||
_last_sweep = 0.0
|
||||
|
||||
|
||||
def _rate_limited(ip: str) -> bool:
|
||||
global _last_sweep
|
||||
now = time.time()
|
||||
# پاکسازی دورهای کلیدهای بیمصرف تا دیکشنری بینهایت بزرگ نشود
|
||||
if now - _last_sweep > settings.rate_limit_window:
|
||||
stale = [k for k, q in _hits.items() if not q or now - q[-1] > settings.rate_limit_window]
|
||||
for k in stale:
|
||||
_hits.pop(k, None)
|
||||
_last_sweep = now
|
||||
window = _hits[ip]
|
||||
while window and now - window[0] > settings.rate_limit_window:
|
||||
window.popleft()
|
||||
if len(window) >= settings.rate_limit_messages:
|
||||
return True
|
||||
window.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def _client_ip(request: Request | None) -> str:
|
||||
if request is None:
|
||||
return "?"
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
return forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "?")
|
||||
|
||||
|
||||
# --- کمکیهای SSE ---
|
||||
|
||||
def _sse(payload: dict) -> str:
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def _chunk_text(chunk) -> str:
|
||||
"""استخراج متن از قطعه استریم LLM (رشته یا لیست بخشها)."""
|
||||
content = getattr(chunk, "content", "")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||
return ""
|
||||
|
||||
|
||||
async def _stream_answer(message: str, thread_id: str):
|
||||
if not INDEX.ready:
|
||||
yield _sse({"error": "ایندکس محتوا هنوز آماده نیست؛ چند لحظه بعد دوباره تلاش کنید.", "done": True})
|
||||
return
|
||||
if pipeline.get_llm() is None:
|
||||
yield _sse({"error": pipeline.LLM_UNAVAILABLE, "done": True})
|
||||
return
|
||||
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
inputs = {"messages": [HumanMessage(content=message)]}
|
||||
try:
|
||||
async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
|
||||
# فقط توکنهای پاسخ نهایی — نه فراخوانیهای کمکی (بازنویسی/گسترش پرسش)
|
||||
if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
|
||||
text = _chunk_text(event["data"]["chunk"])
|
||||
if text:
|
||||
yield _sse({"content": text, "done": False})
|
||||
yield _sse({"content": "", "done": True})
|
||||
except Exception as e:
|
||||
log.exception("Streaming failed")
|
||||
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
|
||||
|
||||
|
||||
# --- مسیرها ---
|
||||
|
||||
@api.get("/chat/healthz")
|
||||
async def healthz():
|
||||
return {
|
||||
"status": "ok",
|
||||
"index_ready": INDEX.ready,
|
||||
"documents": len(INDEX.docs),
|
||||
"vectors": await asyncio.to_thread(vectorstore.vector_count),
|
||||
"llm_configured": bool(settings.llm_base_url),
|
||||
"reranker_enabled": settings.reranker_enabled,
|
||||
}
|
||||
|
||||
|
||||
@api.get("/chat/suggestions")
|
||||
async def suggestions():
|
||||
return {"questions": _get_suggestions()}
|
||||
|
||||
|
||||
@api.get("/chat/eval")
|
||||
async def eval_question(message: str = Query(...)):
|
||||
"""مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابیشده، با thread تازه."""
|
||||
if not INDEX.ready:
|
||||
return {"error": "index not ready", "question": message, "answer": "", "contexts": []}
|
||||
if pipeline.get_llm() is None:
|
||||
return {"error": "llm not configured", "question": message, "answer": "", "contexts": []}
|
||||
|
||||
thread_id = f"eval_{uuid.uuid4().hex[:10]}"
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
inputs = {"messages": [HumanMessage(content=message)]}
|
||||
final_state = await pipeline.graph.ainvoke(inputs, config=config)
|
||||
|
||||
answer = ""
|
||||
for msg in reversed(final_state.get("messages", [])):
|
||||
if isinstance(msg, AIMessage):
|
||||
answer = msg.content if isinstance(msg.content, str) else str(msg.content)
|
||||
break
|
||||
return {
|
||||
"question": message,
|
||||
"contextualized_query": final_state.get("contextualized_query") or message,
|
||||
"answer": answer,
|
||||
"contexts": final_state.get("documents", []),
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
|
||||
@api.post("/chat/reindex")
|
||||
async def reindex(request: Request):
|
||||
"""همگامسازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت میشود."""
|
||||
if settings.reindex_token and request.headers.get("x-reindex-token") != settings.reindex_token:
|
||||
raise HTTPException(status_code=403, detail="invalid reindex token")
|
||||
stats = await asyncio.to_thread(sync_index, True)
|
||||
return {"status": "ok", "documents": len(INDEX.docs), "stats": stats}
|
||||
|
||||
|
||||
# سازگاری با eval_ragas.py قدیمی که آدرس را /chat/{username}/eval میسازد
|
||||
|
||||
@api.get("/chat/{scope}/stream")
|
||||
async def stream_chat_scoped(scope: str, message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
|
||||
return await stream_chat(message=message, thread_id=thread_id, request=request)
|
||||
|
||||
|
||||
@api.get("/chat/{scope}/eval")
|
||||
async def eval_scoped(scope: str, message: str = Query(...)):
|
||||
return await eval_question(message=message)
|
||||
|
||||
|
||||
@api.get("/chat/{scope}/suggestions")
|
||||
async def suggestions_scoped(scope: str):
|
||||
return {"questions": _get_suggestions()}
|
||||
|
||||
|
||||
@api.get("/chat/stream")
|
||||
async def stream_chat(message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
|
||||
"""استریم توکنبهتوکن پاسخ با SSE."""
|
||||
if _rate_limited(_client_ip(request)):
|
||||
raise HTTPException(status_code=429, detail="تعداد پیامهای شما زیاد است؛ کمی بعد دوباره تلاش کنید.")
|
||||
|
||||
thread_id = thread_id or f"t_{uuid.uuid4().hex[:10]}"
|
||||
|
||||
async def generate():
|
||||
async for chunk in _stream_answer(message, thread_id):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# --- پیشنهاد پرسشها (کششده) ---
|
||||
|
||||
_suggestions_cache: tuple[float, list[str]] = (0.0, [])
|
||||
|
||||
|
||||
def _get_suggestions() -> list[str]:
|
||||
global _suggestions_cache
|
||||
now = time.time()
|
||||
if _suggestions_cache[1] and now - _suggestions_cache[0] < settings.suggestions_ttl:
|
||||
return _suggestions_cache[1]
|
||||
|
||||
questions: list[str] = []
|
||||
if INDEX.ready:
|
||||
faqs = [d for d in INDEX.docs if d["type"] == "faq"][:8]
|
||||
titles = [d["title"] for d in INDEX.docs if d["type"] == "article"][:10]
|
||||
model = pipeline.get_llm()
|
||||
if model and (faqs or titles):
|
||||
material = "\n".join([d["text"][:200] for d in faqs] + titles)
|
||||
try:
|
||||
prompt = (
|
||||
"بر اساس محتوای سایت «گروه ساختمانی آرامش» ۴ پرسش کوتاه و جذاب بنویس که بازدیدکننده "
|
||||
"احتمالاً میپرسد. هر پرسش حداکثر ۱۰ کلمه و به فارسی.\n"
|
||||
'فقط آرایه JSON رشتهای برگردان: ["پرسش ۱", "پرسش ۲", ...]\n\n'
|
||||
f"محتوا:\n{material[:2000]}"
|
||||
)
|
||||
resp = model.invoke([HumanMessage(content=prompt)])
|
||||
text = resp.content if isinstance(resp.content, str) else ""
|
||||
if text.startswith("```"):
|
||||
text = text.split("```")[1]
|
||||
text = text[4:] if text.startswith("json") else text
|
||||
import re
|
||||
match = re.search(r"\[.*\]", text, re.DOTALL)
|
||||
if match:
|
||||
questions = [q.strip() for q in json.loads(match.group()) if isinstance(q, str) and q.strip()][:4]
|
||||
except Exception as e:
|
||||
log.warning("Suggestion generation failed: %s", e)
|
||||
if not questions and faqs:
|
||||
# جایگزین: خود پرسشهای متداول
|
||||
questions = [d["text"].split("\n")[0].removeprefix("پرسش: ") for d in faqs[:4]]
|
||||
|
||||
_suggestions_cache = (now, questions)
|
||||
return questions
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(api, host="0.0.0.0", port=8000)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""تنظیمات سرویس چتبات — از متغیرهای محیطی یا فایل .env خوانده میشود."""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# دیتابیس سایت (فقط خواندن) — همان DATABASE_URL بکاند
|
||||
database_url: str = "postgresql+psycopg://aramesh:aramesh@localhost:5432/arameshplaza"
|
||||
# دیتابیس اختصاصی چتبات روی همان نمونه Postgres — بردارها اینجا نوشته میشوند
|
||||
# تا دیتابیس سایت فقطخواندنی بماند؛ در شروع سرویس خودکار ساخته میشود
|
||||
chatbot_db_name: str = "aramesh_chatbot"
|
||||
|
||||
# LLM سازگار با OpenAI — اجباری؛ بدون آن پاسخگویی غیرفعال است
|
||||
llm_base_url: str = ""
|
||||
llm_api_key: str = ""
|
||||
llm_model: str = "openai/gpt-oss-120b"
|
||||
|
||||
# مدلهای بازیابی
|
||||
embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
||||
# بعد بردار خروجی مدل امبدینگ (MiniLM-L12 = 384) — با تغییر مدل باید عوض شود
|
||||
embedding_dim: int = 384
|
||||
reranker_model: str = "BAAI/bge-reranker-v2-m3"
|
||||
reranker_enabled: bool = True
|
||||
reranker_device: str = "cpu"
|
||||
|
||||
# وزن جستوجوی معنایی در ترکیب نمرهها (۰ = فقط کلیدواژه، ۱ = فقط معنایی)
|
||||
alpha_dense: float = 0.5
|
||||
# تعداد سند نهایی که به LLM داده میشود
|
||||
best_of_k: int = 3
|
||||
# تعداد نتیجه هر پایگاه (معنایی و کلیدواژه) برای هر پرسوجو
|
||||
retrieve_k: int = 12
|
||||
# بازنویسی پرسوجو با تاریخچه گفتگو (فقط وقتی تاریخچه هست صدا زده میشود)
|
||||
contextualize_enabled: bool = True
|
||||
# گسترش پرسوجو به چند نوع — برای پیکره کوچک پیشفرض خاموش (تأخیر کمتر)
|
||||
query_expansion: bool = False
|
||||
|
||||
# مسیر کش مدلها (در داکر روی والیوم /data سوار میشود)
|
||||
hf_cache_dir: str = "/data/hf"
|
||||
|
||||
# محدودسازی نرخ درخواست هر IP (تعداد پیام در پنجره زمانی به ثانیه)
|
||||
rate_limit_messages: int = 20
|
||||
rate_limit_window: int = 300
|
||||
|
||||
# توکن بازسازی ایندکس — اگر خالی باشد مسیر reindex فقط از داخل شبکه کار میکند
|
||||
reindex_token: str = ""
|
||||
|
||||
# ماندگاری کش پیشنهادها و زمینه سایت (ثانیه)
|
||||
suggestions_ttl: int = 600
|
||||
site_context_ttl: int = 120
|
||||
|
||||
# بازسازی خودکار ایندکس وقتی محتوای دیتابیس عوض شود (۰ = خاموش)
|
||||
index_refresh_seconds: int = 600
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,315 @@
|
||||
"""خواندن محتوای سایت از PostgreSQL و تبدیل آن به سندهای قابل ایندکس.
|
||||
|
||||
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقالهها و
|
||||
پروژهها هم میتواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
|
||||
پشتیبانی میشوند. مقالههای پیشنویس ایندکس نمیشوند.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .persian import normalize
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# URL پایه صفحات — چتبات مسیر نسبی میدهد؛ فرانتاند روی همان دامنه است
|
||||
_ARTICLE_URL = "/blog/{slug}"
|
||||
_PROJECT_URL = "/projects/{slug}"
|
||||
|
||||
|
||||
def _psycopg_dsn(database_url: str) -> str:
|
||||
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل میکند."""
|
||||
return database_url.replace("+psycopg", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def _fetch_rows(dsn: str, sql: str) -> list[dict[str, Any]]:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
with psycopg.connect(dsn, row_factory=dict_row, connect_timeout=10) as conn:
|
||||
cur = conn.execute(sql)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def tiptap_blocks(node: Any) -> list[str]:
|
||||
"""متن بلوکبهبلوک از سند TipTap (یا هر JSON تو در تو) بیرون میکشد."""
|
||||
if not isinstance(node, dict):
|
||||
return []
|
||||
ntype = node.get("type")
|
||||
if ntype in ("paragraph", "heading", "codeBlock"):
|
||||
text = _inline_text(node).strip()
|
||||
return [text] if text else []
|
||||
blocks: list[str] = []
|
||||
for child in node.get("content") or []:
|
||||
blocks.extend(tiptap_blocks(child))
|
||||
return blocks
|
||||
|
||||
|
||||
def _inline_text(node: dict) -> str:
|
||||
parts: list[str] = []
|
||||
for child in node.get("content") or []:
|
||||
ctype = child.get("type")
|
||||
if ctype == "text":
|
||||
parts.append(child.get("text", ""))
|
||||
elif ctype == "hardBreak":
|
||||
parts.append(" ")
|
||||
elif ctype == "image":
|
||||
alt = (child.get("attrs") or {}).get("alt") or ""
|
||||
if alt:
|
||||
parts.append(f"[تصویر: {alt}]")
|
||||
else:
|
||||
parts.append(_inline_text(child))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def body_to_text(body: Any) -> str:
|
||||
"""بدنه مقاله/پروژه (آرایه پاراگراف یا سند TipTap) → متن یکپارچه."""
|
||||
if not body:
|
||||
return ""
|
||||
if isinstance(body, list):
|
||||
# فرمت قدیمی: آرایه رشتهای پاراگرافها
|
||||
return "\n".join(str(p).strip() for p in body if str(p).strip())
|
||||
blocks = tiptap_blocks(body)
|
||||
return "\n".join(b for b in blocks if b)
|
||||
|
||||
|
||||
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
return {"text": text, "type": dtype, "title": title, "url": url}
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
return normalize(str(value or "")).strip()
|
||||
|
||||
|
||||
def fetch_site_documents(database_url: str) -> list[dict]:
|
||||
"""همه محتوای عمومی سایت را به سند تبدیل میکند (بدون وابستگی به ORM بکاند)."""
|
||||
dsn = _psycopg_dsn(database_url)
|
||||
docs: list[dict] = []
|
||||
|
||||
# --- برند و اطلاعات تماس (رکورد یگانه) ---
|
||||
brand_rows = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||
if brand_rows:
|
||||
b = brand_rows[0]
|
||||
contact = "\n".join(
|
||||
filter(None, [
|
||||
f"نام مجموعه: {_clean(b['name'])}",
|
||||
f"تلفن تماس: {_clean(b['phone'])}",
|
||||
f"ایمیل: {_clean(b['email'])}",
|
||||
f"آدرس: {_clean(b['address'])}",
|
||||
])
|
||||
)
|
||||
d = _doc(contact, "contact", "اطلاعات تماس", "/contact")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- دفاتر ---
|
||||
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
|
||||
d = _doc(
|
||||
f"دفتر {_clean(row['city'])} — آدرس: {_clean(row['address'])} — ساعت کاری: {_clean(row['hours'])}",
|
||||
"office", f"دفتر {_clean(row['city'])}", "/contact",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- خدمات ---
|
||||
for row in _fetch_rows(dsn, "SELECT title, desc FROM services ORDER BY position"):
|
||||
d = _doc(
|
||||
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||
"service", _clean(row["title"]), "/services",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- پرسشهای متداول ---
|
||||
for row in _fetch_rows(dsn, "SELECT question, answer FROM faqs ORDER BY position"):
|
||||
d = _doc(
|
||||
f"پرسش: {_clean(row['question'])}\nپاسخ: {_clean(row['answer'])}",
|
||||
"faq", _clean(row["question"]), "/faq",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- پروژهها ---
|
||||
for row in _fetch_rows(
|
||||
dsn,
|
||||
"SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position",
|
||||
):
|
||||
meta = "\n".join(filter(None, [
|
||||
f"مکان: {_clean(row['location'])}" if row["location"] else "",
|
||||
f"سال: {_clean(row['year'])}" if row["year"] else "",
|
||||
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیلشده'}",
|
||||
f"متراژ: {_clean(row['area'])}" if row["area"] else "",
|
||||
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
|
||||
f"واحدها: {_clean(row['units'])}" if row["units"] else "",
|
||||
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
|
||||
]))
|
||||
text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])]))
|
||||
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- مقالهها (فقط منتشرشده) ---
|
||||
for row in _fetch_rows(
|
||||
dsn,
|
||||
"SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position",
|
||||
):
|
||||
text = "\n".join(filter(None, [
|
||||
f"دستهبندی: {_clean(row['category'])}" if row["category"] else "",
|
||||
_clean(row["excerpt"]),
|
||||
body_to_text(row["body"]),
|
||||
]))
|
||||
d = _doc(text, "article", _clean(row["title"]), _ARTICLE_URL.format(slug=row["slug"]))
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- مدلهای سرمایهگذاری ---
|
||||
for row in _fetch_rows(dsn, "SELECT title, desc, points FROM investment_models ORDER BY position"):
|
||||
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
|
||||
d = _doc(
|
||||
"\n".join(filter(None, [
|
||||
f"«{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||
f"نکات: {points}" if points else "",
|
||||
])),
|
||||
"investment", _clean(row["title"]), "/investment",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- مراحل همکاری ---
|
||||
for row in _fetch_rows(dsn, "SELECT step, title, desc FROM process_steps ORDER BY position"):
|
||||
d = _doc(
|
||||
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||
"process", _clean(row["title"]), "/investment",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- چرا ما / تیم / نظرسنجی / تایملاین / داستان ما ---
|
||||
for row in _fetch_rows(dsn, "SELECT title, desc FROM why_us ORDER BY position"):
|
||||
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
for row in _fetch_rows(dsn, "SELECT name, role, bio FROM team ORDER BY position"):
|
||||
d = _doc(
|
||||
f"{_clean(row['name'])} — {_clean(row['role'])}. {_clean(row['bio'])}",
|
||||
"team", _clean(row["name"]), "/about",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"):
|
||||
d = _doc(
|
||||
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
|
||||
"testimonial", _clean(row["name"]), "/",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
for row in _fetch_rows(dsn, "SELECT year, title, desc FROM timeline ORDER BY position"):
|
||||
d = _doc(
|
||||
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||
"timeline", _clean(row["title"]), "/about",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
story_rows = _fetch_rows(dsn, "SELECT title, paragraphs, quote FROM story LIMIT 1")
|
||||
if story_rows and story_rows[0]:
|
||||
s = story_rows[0]
|
||||
paragraphs = "\n".join(_clean(p) for p in (s["paragraphs"] or []) if _clean(p))
|
||||
text = "\n".join(filter(None, [_clean(s["title"]), paragraphs, _clean(s["quote"])]))
|
||||
d = _doc(text, "about", _clean(s["title"]) or "درباره ما", "/about")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
log.info("Fetched %d documents from site database", len(docs))
|
||||
return docs
|
||||
|
||||
|
||||
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
|
||||
"""متنهای بلند (مقاله/پروژه) را به قطعات همپوشان تقسیم میکند.
|
||||
|
||||
هر قطعه یک doc_id پایدار میگیرد («type:ترتیب در همان url:شماره قطعه») تا
|
||||
همگامسازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
|
||||
"""
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
from .vectorstore import chunk_content_hash
|
||||
|
||||
splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=overlap,
|
||||
separators=["\n", "۔", ".", "،", " ", ""],
|
||||
)
|
||||
|
||||
# شماره ردیف هر سند در میان سندهای همtype و هم-url (برای FAQs که url یکسان دارند)
|
||||
occurrence: dict[str, int] = {}
|
||||
|
||||
out: list[dict] = []
|
||||
for doc in docs:
|
||||
key = f"{doc['type']}|{doc['url']}"
|
||||
occ = occurrence.get(key, 0)
|
||||
occurrence[key] = occ + 1
|
||||
|
||||
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
|
||||
for i, piece in enumerate(pieces, 1):
|
||||
chunk = {
|
||||
"doc_id": f"{doc['type']}:{occ}:{i}",
|
||||
"text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}",
|
||||
"type": doc["type"],
|
||||
"title": doc["title"],
|
||||
"url": doc["url"],
|
||||
}
|
||||
chunk["content_hash"] = chunk_content_hash(chunk)
|
||||
out.append(chunk)
|
||||
|
||||
log.info("Chunked %d documents into %d pieces", len(docs), len(out))
|
||||
return out
|
||||
|
||||
|
||||
def content_fingerprint(docs: list[dict]) -> str:
|
||||
"""هش کل محتوا — فقط برای لاگ؛ تشخیص تغییر ردیفی در vectorstore انجام میشود."""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
payload = json.dumps(docs, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
# --- زمینه همیشهتازه سایت (بلوک کوتاه تماس/خدمات برای پرامپت) ---
|
||||
_site_context_cache: tuple[float, str] | None = None
|
||||
|
||||
|
||||
def fetch_site_context(database_url: str, ttl: int = 120) -> str:
|
||||
"""نام برند، تماس و فهرست خدمات — با کش کوتاهمدت تا هر پیام یک کوئری سبک بزند."""
|
||||
global _site_context_cache
|
||||
import time as _time
|
||||
|
||||
now = _time.time()
|
||||
if _site_context_cache and now - _site_context_cache[0] < ttl:
|
||||
return _site_context_cache[1]
|
||||
try:
|
||||
dsn = _psycopg_dsn(database_url)
|
||||
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
|
||||
lines: list[str] = []
|
||||
if brand:
|
||||
b = brand[0]
|
||||
lines += [
|
||||
f"نام مجموعه: {_clean(b['name'])}",
|
||||
f"تلفن تماس: {_clean(b['phone'])}",
|
||||
f"ایمیل: {_clean(b['email'])}",
|
||||
f"آدرس: {_clean(b['address'])}",
|
||||
]
|
||||
if services:
|
||||
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
|
||||
ctx = "\n".join(lines)
|
||||
_site_context_cache = (now, ctx)
|
||||
return ctx
|
||||
except Exception as e:
|
||||
log.warning("Site context fetch failed: %s", e)
|
||||
return _site_context_cache[1] if _site_context_cache else ""
|
||||
@@ -0,0 +1,20 @@
|
||||
"""مدل امبدینگ — با لود تنبل تا اولین استفاده (برای تستهای سبک، torch لود نمیشود)."""
|
||||
|
||||
import os
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_embeddings():
|
||||
from langchain_huggingface import HuggingFaceEmbeddings
|
||||
|
||||
os.makedirs(settings.hf_cache_dir, exist_ok=True)
|
||||
return HuggingFaceEmbeddings(
|
||||
model_name=settings.embedding_model,
|
||||
model_kwargs={"device": "cpu"},
|
||||
encode_kwargs={"normalize_embeddings": False},
|
||||
cache_folder=settings.hf_cache_dir,
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""ایندکس ترکیبی روی pgvector + BM25 در حافظه.
|
||||
|
||||
بردارها در pgvector (دیتابیس اختصاصی چتبات) بهصورت افزایشی همگام میشوند —
|
||||
فقط سطرهای جدید یا تغییر یافته امبد میشوند. BM25 چون سبک است در هر شروع
|
||||
از روی همان سطرها ساخته میشود و نیازی به کش دیسکی ندارد.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from rank_bm25 import BM25Okapi
|
||||
|
||||
from . import content as content_mod
|
||||
from .config import settings
|
||||
from .persian import tokenize
|
||||
from . import vectorstore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SiteIndex:
|
||||
"""حالت در حافظهٔ بازیابی — سندها (برای BM25) و وضعیت آماده بودن."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.docs: list[dict] = []
|
||||
self.bm25: BM25Okapi | None = None
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return self.bm25 is not None and bool(self.docs)
|
||||
|
||||
|
||||
INDEX = SiteIndex()
|
||||
|
||||
|
||||
def sync_index(force_rebuild: bool = False) -> dict:
|
||||
"""همگامسازی کامل: محتوا → قطعه → pgvector (افزایشی) → ساخت BM25.
|
||||
|
||||
خروجی: آمار همگامسازی. خطای اتصال به دیتابیس بالا فرستاده میشود.
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
vectorstore.ensure_schema()
|
||||
|
||||
docs = content_mod.fetch_site_documents(settings.database_url)
|
||||
chunks = content_mod.chunk_documents(docs)
|
||||
stats = vectorstore.sync_chunks(chunks, force=force_rebuild)
|
||||
|
||||
INDEX.docs = vectorstore.fetch_all_chunks()
|
||||
corpus_tokens = [tokenize(d["text"]) for d in INDEX.docs]
|
||||
INDEX.bm25 = BM25Okapi(corpus_tokens) if corpus_tokens else None
|
||||
|
||||
log.info("Index ready in %.1fs (%d vectors, %d docs, %s)",
|
||||
time.time() - start, len(INDEX.docs), len(chunks), stats)
|
||||
return stats
|
||||
@@ -0,0 +1,79 @@
|
||||
"""نرمالسازی متن فارسی و توکنسازی برای جستوجوی کلیدواژه (BM25).
|
||||
|
||||
بدون وابستگی سنگین — فقط استانداردسازی نویسهها، حذف اعراب و نیمفاصلههای
|
||||
مشکلی و شکستن کلمههای مرکب روی نیمفاصله برای بهبود بازیابی.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# نویسههای عربی که در متن فارسی اشتباهاً استفاده میشوند
|
||||
_CHAR_MAP = {
|
||||
"\u064a": "\u06cc", # ي → ی
|
||||
"\u0643": "\u06a9", # ك → ک
|
||||
"\u0623": "\u0627", # أ → ا
|
||||
"\u0625": "\u0627", # إ → ا
|
||||
"\u0671": "\u0627", # ٱ → ا
|
||||
"\u0629": "\u0647", # ة → ه
|
||||
"\u0624": "\u0648", # ؤ → و
|
||||
"\u0626": "\u06cc", # ئ → ی
|
||||
"\u0640": "", # ـ (کشیده) حذف
|
||||
}
|
||||
|
||||
# اعراب و علامتهای اضافی
|
||||
_DIACRITICS = re.compile(r"[\u064b-\u0652\u0670\u0653-\u0655\u0640]")
|
||||
|
||||
# ارقام فارسی و عربی → ASCII
|
||||
_DIGITS = str.maketrans({
|
||||
"\u06f0": "0", "\u06f1": "1", "\u06f2": "2", "\u06f3": "3", "\u06f4": "4",
|
||||
"\u06f5": "5", "\u06f6": "6", "\u06f7": "7", "\u06f8": "8", "\u06f9": "9",
|
||||
"\u0660": "0", "\u0661": "1", "\u0662": "2", "\u0663": "3", "\u0664": "4",
|
||||
"\u0665": "5", "\u0666": "6", "\u0667": "7", "\u0668": "8", "\u0669": "9",
|
||||
})
|
||||
|
||||
# واژههای دستوری پرتکرار — در جستوجو بیمعنا هستند
|
||||
STOPWORDS = frozenset("""
|
||||
و در به از که با این آن است هست بود بودن شد شدن شود کرد کردن کرده کند کنی
|
||||
برای را های ها هایی هم نیز اما یا اگر تا بر روی زیر بین پس قبل بعد همه هر چه
|
||||
چیزی کس کسی کجا چرا چگونه چطور چند یکی دو سه ما شما آنها ایشان من تو او
|
||||
کدام کی چی چهقدر چقدر
|
||||
می نخواهد خواهد باشد باشند هستیم هستند بودم بودیم دارد دارند داشته داد دهد
|
||||
بله خیر آیا فقط نیز خیلی بسیار کمی دیگر دیگری همان همین وقتی زمانی وقتیکه
|
||||
اساساً لطفاً میشه میکنه هستن هستم
|
||||
""".split())
|
||||
|
||||
# علائم نگارشی فارسی/لاتین — قبل از توکنسازی حذف میشوند (نیمفاصله جزو اینها نیست)
|
||||
_PUNCT_RE = re.compile(r"[،؛؟!,;:\?\"'()\[\]{}«»….*#@&%+=/\\|~^$\u066d\u066c\u066b]+")
|
||||
|
||||
# توکن: حروف/ارقام فارسی و لاتین + نیمفاصله در میان کلمه
|
||||
_TOKEN_RE = re.compile(r"[\w\u0600-\u06ff\u200c]+")
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""نویسههای غیراستاندارد عربی/فارسی را به شکل یکدست فارسی میبرد."""
|
||||
if not text:
|
||||
return ""
|
||||
out = []
|
||||
for ch in text:
|
||||
out.append(_CHAR_MAP.get(ch, ch))
|
||||
text = "".join(out)
|
||||
text = _DIACRITICS.sub("", text)
|
||||
text = text.translate(_DIGITS)
|
||||
# فاصلههای تکراری را یکی کن — نیمفاصله عمداً حفظ میشود (میشود)
|
||||
text = re.sub(r"[ \t\r\n]+", " ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
"""توکنهای نرمالشده برای BM25 — کلمههای مرکب روی نیمفاصله هم جدا ایندکس میشوند."""
|
||||
text = _PUNCT_RE.sub(" ", normalize(text).lower())
|
||||
tokens = []
|
||||
for raw in _TOKEN_RE.findall(text):
|
||||
if raw in STOPWORDS or len(raw) < 2:
|
||||
continue
|
||||
tokens.append(raw)
|
||||
# «میشود» → می + شود (فراخوانی بیشتر برای بازیابی)
|
||||
if "\u200c" in raw:
|
||||
for part in raw.split("\u200c"):
|
||||
if len(part) >= 2 and part not in STOPWORDS:
|
||||
tokens.append(part)
|
||||
return tokens
|
||||
@@ -0,0 +1,191 @@
|
||||
"""گراف گفتگو (LangGraph) — پورت خط لوله v13 در سه گام:
|
||||
|
||||
۱) بازنویسی پرسش با تاریخچه (فقط وقتی تاریخچه هست — صرفهجویی در تأخیر)
|
||||
۲) بازیابی ترکیبی با چند نوعواژه (اختیاری)
|
||||
۳) تولید پاسخ فارسی با قواعد تکیه بر داده
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import json as _json
|
||||
from typing import Annotated, List, Optional
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.graph import END, StateGraph
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
from . import content as content_mod
|
||||
from .config import settings
|
||||
from .retrieval import retrieve_final
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
FALLBACK_MESSAGE = (
|
||||
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
|
||||
"برای راهنمایی بیشتر میتوانید از صفحه «تماس با ما» با ما در ارتباط باشید."
|
||||
)
|
||||
LLM_UNAVAILABLE = (
|
||||
"سرویس هوش مصنوعی هنوز پیکربندی نشده است. "
|
||||
"متغیرهای LLM_BASE_URL و LLM_MODEL را در تنظیمات سرویس چتبات تنظیم کنید."
|
||||
)
|
||||
|
||||
_llm = None
|
||||
|
||||
|
||||
def get_llm() -> ChatOpenAI | None:
|
||||
"""LLM تولید — اگر پیکربندی نشده باشد None برمیگرداند."""
|
||||
global _llm
|
||||
if _llm is None and settings.llm_base_url:
|
||||
_llm = ChatOpenAI(
|
||||
model=settings.llm_model,
|
||||
base_url=settings.llm_base_url,
|
||||
api_key=settings.llm_api_key or "not-needed",
|
||||
temperature=0,
|
||||
streaming=True,
|
||||
)
|
||||
return _llm
|
||||
|
||||
|
||||
class GraphState(TypedDict):
|
||||
messages: Annotated[List, add_messages]
|
||||
documents: List[str]
|
||||
contextualized_query: Optional[str]
|
||||
|
||||
|
||||
# --- گام ۱: بازنویسی پرسش با تاریخچه ---
|
||||
|
||||
def contextualize_query(question: str, history: list) -> str:
|
||||
"""ضمیرها و اشارههای پرسش را با تاریخچه گفتگو مشخص میکند («اون دومی» → موضوع واقعی)."""
|
||||
model = get_llm()
|
||||
if model is None or not history:
|
||||
return question
|
||||
try:
|
||||
history_text = "\n".join(
|
||||
f"{'کاربر' if isinstance(m, HumanMessage) else 'دستیار'}: {str(m.content)[:300]}"
|
||||
for m in history[-6:]
|
||||
)
|
||||
prompt = (
|
||||
"با توجه به تاریخچه گفتگو، پرسش کاربر را به یک پرسش مستقل و کامل فارسی بازنویسی کن.\n"
|
||||
"- ضمیرها و اشارهها (او، آن، همین، دومی، این یکی) را به موضوع واقعیِ ذکرشده در تاریخچه تبدیل کن.\n"
|
||||
"- اگر پرسش از قبل مستقل است، عیناً همان را برگردان.\n"
|
||||
"- فقط متن پرسش بازنویسیشده را برگردان؛ هیچ توضیحی ننویس.\n\n"
|
||||
f"تاریخچه گفتگو:\n{history_text}\n\n"
|
||||
f"پرسش کاربر: {question}\n\n"
|
||||
"پرسش مستقل:"
|
||||
)
|
||||
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
|
||||
rewritten = (resp.content or "").strip() if isinstance(resp.content, str) else ""
|
||||
if not rewritten or len(rewritten) > len(question) * 4:
|
||||
return question
|
||||
if rewritten != question:
|
||||
log.info("Contextualized: '%s' → '%s'", question[:60], rewritten[:60])
|
||||
return rewritten
|
||||
except Exception as e:
|
||||
log.warning("Contextualization failed: %s", e)
|
||||
return question
|
||||
|
||||
|
||||
def expand_query(query: str) -> list[str]:
|
||||
"""گسترش پرسش به چند نوعواژه (اختیاری — پیشفرض خاموش برای پیکره کوچک)."""
|
||||
model = get_llm()
|
||||
if model is None:
|
||||
return [query]
|
||||
try:
|
||||
prompt = (
|
||||
"برای جستوجو در محتوای سایت یک گروه ساختمانی، ۳ نوعواژه جایگزین برای این پرسش بساز: "
|
||||
"بازنویسی معنایی، استخراج کلیدواژه، گسترش با مفاهیم مرتبط.\n"
|
||||
'فقط JSON برگردان: {"variants": ["۱", "۲", "۳"]}\n\n'
|
||||
f"پرسش: {query}"
|
||||
)
|
||||
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
|
||||
text = resp.content if isinstance(resp.content, str) else ""
|
||||
match = re.search(r'\{.*"variants".*\}', text, re.DOTALL)
|
||||
if match:
|
||||
variants = [v for v in _json.loads(match.group()).get("variants", []) if v and v != query][:3]
|
||||
if variants:
|
||||
log.info("Query variants: %s", [query] + variants)
|
||||
return [query] + variants
|
||||
except Exception as e:
|
||||
log.warning("Query expansion failed: %s", e)
|
||||
return [query]
|
||||
|
||||
|
||||
# --- گام ۲: بازیابی ---
|
||||
|
||||
def retrieve_node(state: GraphState) -> dict:
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return {"documents": []}
|
||||
question = messages[-1].content
|
||||
history = messages[:-1]
|
||||
|
||||
query = contextualize_query(question, history) if settings.contextualize_enabled else question
|
||||
variants = expand_query(query) if settings.query_expansion else [query]
|
||||
|
||||
docs = retrieve_final(query, variants)
|
||||
doc_texts = [f"{d['text']}\n(منبع: {d['title']} — {d['url']})" for d in docs]
|
||||
log.info("Retrieved %d documents for '%s'", len(doc_texts), question[:60])
|
||||
return {
|
||||
"documents": doc_texts,
|
||||
"contextualized_query": query if query != question else None,
|
||||
}
|
||||
|
||||
|
||||
# --- گام ۳: تولید پاسخ ---
|
||||
|
||||
def build_system_prompt(documents: list[str], contextualized_query: str | None) -> str:
|
||||
site_ctx = content_mod.fetch_site_context(settings.database_url, settings.site_context_ttl)
|
||||
docs_block = "\n\n———\n\n".join(documents) if documents else "(سند مرتبطی یافت نشد)"
|
||||
reminder = (
|
||||
f"\n--- یادآوری ---\nپرسش کاربر پس از بازنویسی این است: «{contextualized_query}»\n\n"
|
||||
if contextualized_query else ""
|
||||
)
|
||||
return (
|
||||
"تو دستیار گفتگوی هوشمند سایت «گروه ساختمانی آرامش» هستی و فقط به فارسی پاسخ میدهی.\n"
|
||||
"دانش تو اکیداً محدود به اطلاعات زیر است.\n\n"
|
||||
f"--- اطلاعات پایه سایت (همیشه معتبر) ---\n{site_ctx}\n\n"
|
||||
f"--- اسناد بازیابیشده مرتبط با پرسش ---\n{docs_block}\n"
|
||||
f"{reminder}"
|
||||
"--- قوانین پاسخ ---\n"
|
||||
"۱. فقط بر اساس اطلاعات بالا پاسخ بده؛ بههیچوجه از دانش بیرونی استفاده نکن و چیزی نساز.\n"
|
||||
"۲. پاسخ کوتاه، دقیق و مفید باشد (معمولاً ۲ تا ۵ جمله) و لحن حرفهای و محترمانه داشته باشد.\n"
|
||||
f"۳. اگر اطلاعات کافی در اسناد نیست، فقط همین جمله را بنویس: «{FALLBACK_MESSAGE}»\n"
|
||||
"۴. اگر سند مسیر صفحه مرتبط دارد، در پایان پاسخ آن را معرفی کن (مثلاً: جزئیات کامل در صفحه /blog/... ).\n"
|
||||
"۵. درباره قیمت، زمان دقیق یا هر تعهدی فقط چیزی را بگو که در دادهها آمده است."
|
||||
)
|
||||
|
||||
|
||||
def generate_node(state: GraphState) -> dict:
|
||||
messages = state.get("messages", [])
|
||||
question = messages[-1].content if messages else ""
|
||||
|
||||
model = get_llm()
|
||||
if model is None:
|
||||
return {"messages": [AIMessage(content=LLM_UNAVAILABLE)]}
|
||||
|
||||
documents = state.get("documents") or []
|
||||
system = build_system_prompt(documents, state.get("contextualized_query"))
|
||||
log.info("Generating answer with %d docs (system prompt %d chars)", len(documents), len(system))
|
||||
|
||||
history = messages[:-1]
|
||||
reply = model.invoke(
|
||||
[SystemMessage(content=system), *history, HumanMessage(content=question)],
|
||||
config={"tags": ["generate"]},
|
||||
)
|
||||
return {"messages": [reply]}
|
||||
|
||||
|
||||
# --- گراف ---
|
||||
|
||||
memory = MemorySaver()
|
||||
workflow = StateGraph(GraphState)
|
||||
workflow.add_node("retrieve", retrieve_node)
|
||||
workflow.add_node("generate", generate_node)
|
||||
workflow.set_entry_point("retrieve")
|
||||
workflow.add_edge("retrieve", "generate")
|
||||
workflow.add_edge("generate", END)
|
||||
graph = workflow.compile(checkpointer=memory)
|
||||
@@ -0,0 +1,20 @@
|
||||
# سرویس چتبات — بازیابی ترکیبی روی محتوای سایت
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic-settings>=2.3
|
||||
|
||||
# گراف گفتگو و LLM
|
||||
langgraph>=0.2.60
|
||||
langchain>=0.3
|
||||
langchain-openai>=0.2
|
||||
langchain-text-splitters>=0.3
|
||||
|
||||
# بازیابی — بردارها در pgvector میروند؛ BM25 و خوشهبندی در حافظه
|
||||
sentence-transformers>=3.0
|
||||
langchain-huggingface>=0.1
|
||||
rank-bm25>=0.2.2
|
||||
scikit-learn>=1.4
|
||||
numpy>=1.26
|
||||
|
||||
# دیتابیس (فقط خواندن)
|
||||
psycopg[binary]>=3.1
|
||||
@@ -0,0 +1,50 @@
|
||||
"""مدل ریرنکر BGE — با لود تنبل و امتیازدهی دستهای."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from .config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _CrossEncoder:
|
||||
"""غلاف سبک روی AutoModelForSequenceClassification — API شبیه rank_bm25."""
|
||||
def __init__(self) -> None:
|
||||
import torch
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
|
||||
os.makedirs(settings.hf_cache_dir, exist_ok=True)
|
||||
self._torch = torch
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(settings.reranker_model, cache_dir=settings.hf_cache_dir)
|
||||
self.model = AutoModelForSequenceClassification.from_pretrained(
|
||||
settings.reranker_model, cache_dir=settings.hf_cache_dir
|
||||
)
|
||||
device = settings.reranker_device if settings.reranker_device == "cuda" and torch.cuda.is_available() else "cpu"
|
||||
self.model.to(device)
|
||||
self.model.eval()
|
||||
self.device = device
|
||||
log.info("Reranker loaded on %s", device)
|
||||
|
||||
def score(self, pairs: list[list[str]], batch_size: int = 16) -> list[float]:
|
||||
torch = self._torch
|
||||
out: list[float] = []
|
||||
with torch.no_grad():
|
||||
for i in range(0, len(pairs), batch_size):
|
||||
inputs = self.tokenizer(
|
||||
pairs[i:i + batch_size],
|
||||
padding=True,
|
||||
truncation=True,
|
||||
max_length=512,
|
||||
return_tensors="pt",
|
||||
).to(self.device)
|
||||
logits = self.model(**inputs).logits.squeeze(-1)
|
||||
out.extend(logits.cpu().tolist())
|
||||
return out
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_reranker() -> _CrossEncoder:
|
||||
return _CrossEncoder()
|
||||
@@ -0,0 +1,165 @@
|
||||
"""بازیابی ترکیبی — پورت خط لوله v13 با سه تغییر برای این سایت:
|
||||
|
||||
۱) پایگاه sparse بهجای SPLADE (انگلیسیمحور و بیفایده روی فارسی) از BM25
|
||||
با نرمالسازی فارسی استفاده میکند.
|
||||
۲) جستوجوی معنایی بهجای FAISS با pgvector و فاصله کسینوسی در SQL انجام
|
||||
میشود (همگامسازی افزایشی، فیلترپذیر، بدون فایل ایندکس جدا).
|
||||
۳) مسیریابی alpha با LLM حذف شده و وزن ثابت تنظیمپذیر است — برای پیکرهٔ
|
||||
کوچک سایت، هزینهٔ یک فراخوانی LLM اضافی بهازای هر پیام نمیارزد.
|
||||
بقیهٔ منطق (نرمالسازی نمره، فیوژن وزندار، برش خوشهبندی، ریرنک) عین v13 است.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from . import vectorstore
|
||||
from .config import settings
|
||||
from .indexing import INDEX
|
||||
from .persian import tokenize
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def dense_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||
"""جستوجوی معنایی pgvector — خروجی: (سند، شباهت کسینوسی)."""
|
||||
return vectorstore.dense_search(query, top_k)
|
||||
|
||||
|
||||
def keyword_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||
"""جستوجوی کلیدواژه BM25 روی متن نرمالشده فارسی."""
|
||||
if INDEX.bm25 is None:
|
||||
return []
|
||||
try:
|
||||
tokens = tokenize(query)
|
||||
if not tokens:
|
||||
return []
|
||||
scores = INDEX.bm25.get_scores(tokens)
|
||||
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
|
||||
return [(INDEX.docs[i], float(scores[i])) for i in ranked if scores[i] > 0]
|
||||
except Exception as e:
|
||||
log.error("BM25 retrieval failed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def _minmax(scores: list[float]) -> list[float]:
|
||||
if not scores:
|
||||
return []
|
||||
lo, hi = min(scores), max(scores)
|
||||
if hi == lo:
|
||||
return [0.5] * len(scores)
|
||||
return [(s - lo) / (hi - lo) for s in scores]
|
||||
|
||||
|
||||
def hybrid_retrieve(query: str, alpha: float | None = None, top_k: int | None = None) -> list[tuple[dict, float]]:
|
||||
"""ترکیب وزندار نمره معنایی و کلیدواژه؛ خروجی مرتبشده بر اساس نمره ترکیبی."""
|
||||
alpha = settings.alpha_dense if alpha is None else alpha
|
||||
top_k = top_k or settings.retrieve_k
|
||||
|
||||
dense = dense_retrieve(query, top_k)
|
||||
keyword = keyword_retrieve(query, top_k)
|
||||
log.info("Retrieval '%s': dense=%d, keyword=%d", query[:60], len(dense), len(keyword))
|
||||
if not dense and not keyword:
|
||||
return []
|
||||
|
||||
dense_norm = _minmax([s for _, s in dense])
|
||||
keyword_norm = _minmax([s for _, s in keyword])
|
||||
|
||||
pool: dict = {} # doc_id → {doc, dense_max, kw_max}
|
||||
for (doc, _), norm in zip(dense, dense_norm):
|
||||
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
|
||||
entry["dense_max"] = max(entry["dense_max"], norm)
|
||||
for (doc, _), norm in zip(keyword, keyword_norm):
|
||||
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
|
||||
entry["kw_max"] = max(entry["kw_max"], norm)
|
||||
|
||||
combined = [
|
||||
(entry["doc"], alpha * entry["dense_max"] + (1 - alpha) * entry["kw_max"])
|
||||
for entry in pool.values()
|
||||
]
|
||||
combined.sort(key=lambda x: x[1], reverse=True)
|
||||
return combined
|
||||
|
||||
|
||||
def cluster_cutoff(scored: list[tuple[dict, float]], silhouette_threshold: float = 0.55) -> list[tuple[dict, float]]:
|
||||
"""برش خوشهای v13 — مرز طبیعی کیفیت بهجای top-k دلخواه.
|
||||
|
||||
روی نمرههای ۱بعدی K-Means با K=2 میزند؛ اگر جدایی خوشهها معنادار
|
||||
باشد (silhouette ≥ 0.55) فقط خوشهٔ بالا را نگه میدارد.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
n = len(scored)
|
||||
if n < 4:
|
||||
return scored
|
||||
try:
|
||||
from sklearn.cluster import KMeans
|
||||
from sklearn.metrics import silhouette_score
|
||||
|
||||
scores = np.array([s for _, s in scored], dtype=float).reshape(-1, 1)
|
||||
kmeans = KMeans(n_clusters=2, random_state=42, n_init=10)
|
||||
labels = kmeans.fit_predict(scores)
|
||||
sil = silhouette_score(scores, labels)
|
||||
if sil < silhouette_threshold:
|
||||
log.info("Clustering: silhouette=%.3f < %.2f — keeping all %d docs", sil, silhouette_threshold, n)
|
||||
return scored
|
||||
centers = kmeans.cluster_centers_.flatten()
|
||||
good_label = int(np.argmax(centers))
|
||||
cutoff = int(np.sum(labels == good_label))
|
||||
cutoff = max(cutoff, 1)
|
||||
log.info("Clustering: silhouette=%.3f — cutting at %d/%d docs", sil, cutoff, n)
|
||||
return scored[:cutoff]
|
||||
except Exception as e:
|
||||
log.warning("Clustering failed (%s) — keeping all docs", e)
|
||||
return scored
|
||||
|
||||
|
||||
def rerank(query: str, scored: list[tuple[dict, float]], top_k: int) -> list[tuple[dict, float]]:
|
||||
"""ریرنک کراسانکودر BGE — مرتبسازی نهایی بر اساس شباهت واقعی پرسش/سند."""
|
||||
if not scored:
|
||||
return []
|
||||
if not settings.reranker_enabled:
|
||||
return scored[:top_k]
|
||||
|
||||
try:
|
||||
from .reranker import get_reranker
|
||||
|
||||
start = time.time()
|
||||
pairs = [[query, doc["text"]] for doc, _ in scored]
|
||||
scores = get_reranker().score(pairs)
|
||||
rescored = sorted(
|
||||
((doc, float(s)) for (doc, _), s in zip(scored, scores)),
|
||||
key=lambda x: x[1],
|
||||
reverse=True,
|
||||
)
|
||||
log.info("Reranked %d docs in %.2fs — top scores: %s",
|
||||
len(scored), time.time() - start,
|
||||
[f"{s:.2f}" for _, s in rescored[:3]])
|
||||
return rescored[:top_k]
|
||||
except Exception as e:
|
||||
log.error("Reranking failed (%s) — falling back to fusion order", e)
|
||||
return scored[:top_k]
|
||||
|
||||
|
||||
def retrieve_final(query: str, query_variants: list[str] | None = None) -> list[dict]:
|
||||
"""خط کامل بازیابی برای یک پرسش: هر نوع → فیوژن → برش خوشه → ریرنک → K نهایی."""
|
||||
variants = query_variants or [query]
|
||||
|
||||
pool: dict = {}
|
||||
for variant in variants:
|
||||
for doc, score in hybrid_retrieve(variant):
|
||||
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "score": 0.0})
|
||||
entry["score"] = max(entry["score"], score)
|
||||
|
||||
combined = sorted(((e["doc"], e["score"]) for e in pool.values()),
|
||||
key=lambda x: x[1], reverse=True)
|
||||
if not combined:
|
||||
return []
|
||||
|
||||
filtered = cluster_cutoff(combined)
|
||||
# ریرنک روی نتیجههای برشخورده (سقف retrieve_k تا تأخیر کنترل شود)
|
||||
rerank_candidates = filtered[: settings.retrieve_k]
|
||||
final = rerank(query, rerank_candidates, settings.best_of_k)
|
||||
|
||||
log.info("Final retrieval: %d docs (from %d candidates, %d variants)",
|
||||
len(final), len(combined), len(variants))
|
||||
return [doc for doc, _ in final]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""تستهای خالص (بدون مدل/دیتابیس) برای ماژولهای چتبات — اجرا: python test_pure.py"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from chatbot.content import chunk_documents, tiptap_blocks, body_to_text
|
||||
from chatbot.vectorstore import chunk_content_hash, chatbot_dsn
|
||||
from chatbot.persian import normalize, tokenize
|
||||
|
||||
docs = [
|
||||
{"text": "پاراگراف کوتاه خدمت الف", "type": "service", "title": "الف", "url": "/services"},
|
||||
{"text": "سؤال اول؟", "type": "faq", "title": "س۱", "url": "/faq"},
|
||||
{"text": "سؤال دوم؟", "type": "faq", "title": "س۲", "url": "/faq"},
|
||||
{"text": "آ" * 2000, "type": "article", "title": "مقاله بلند", "url": "/blog/long"},
|
||||
]
|
||||
chunks = chunk_documents(docs)
|
||||
ids = [c["doc_id"] for c in chunks]
|
||||
assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
|
||||
|
||||
faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
|
||||
assert faq_ids == ["faq:0:1", "faq:1:1"], faq_ids
|
||||
|
||||
art = [c for c in chunks if c["type"] == "article"]
|
||||
assert len(art) >= 2 and art[1]["doc_id"] == "article:0:2", [x["doc_id"] for x in art]
|
||||
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
|
||||
|
||||
c = chunks[0]
|
||||
assert c["content_hash"] == chunk_content_hash(c)
|
||||
c2 = dict(c, title="عنوان عوض شد")
|
||||
# hash فیلد کپیشده است — باید با محاسبه دوباره مقایسه شود، نه با فیلد قدیمی
|
||||
assert chunk_content_hash(c2) != c["content_hash"]
|
||||
|
||||
chunks2 = chunk_documents(docs)
|
||||
assert [x["doc_id"] for x in chunks2] == ids
|
||||
assert [x["content_hash"] for x in chunks2] == [x["content_hash"] for x in chunks]
|
||||
|
||||
settings_dsn = chatbot_dsn()
|
||||
assert settings_dsn.endswith("/aramesh_chatbot"), settings_dsn
|
||||
assert "+psycopg" not in settings_dsn
|
||||
|
||||
# نرمالسازی و توکنسازی فارسی
|
||||
assert normalize("كامل يك ۱۲۳") == "کامل یک 123"
|
||||
t = tokenize("کدام شرکت ساختمانی خوب در تهران؟")
|
||||
assert "شرکت" in t and "تهران" in t and "کدام" not in t, t
|
||||
assert "می\u200cسازیم" in tokenize("می\u200cسازیم") and "سازیم" in tokenize("می\u200cسازیم")
|
||||
|
||||
# استخراج متن TipTap و فرمت قدیمی
|
||||
tiptap_doc = {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "معرفی پروژه"}]},
|
||||
{"type": "paragraph", "content": [
|
||||
{"type": "text", "text": "این برج در تهران "},
|
||||
{"type": "text", "marks": [{"type": "bold"}], "text": "ساخته شد"},
|
||||
{"type": "hardBreak"},
|
||||
{"type": "image", "attrs": {"alt": "نمای بیرونی"}},
|
||||
]},
|
||||
{"type": "bulletList", "content": [
|
||||
{"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "۲۴ طبقه"}]}]},
|
||||
]},
|
||||
{"type": "paragraph", "content": [{"type": "image", "attrs": {"alt": ""}}]},
|
||||
],
|
||||
}
|
||||
blocks = tiptap_blocks(tiptap_doc)
|
||||
assert blocks == ["معرفی پروژه", "این برج در تهران ساخته شد [تصویر: نمای بیرونی]", "۲۴ طبقه"], blocks
|
||||
assert body_to_text(["الف", "ب"]) == "الف\nب"
|
||||
assert body_to_text(None) == ""
|
||||
|
||||
print(f"ALL PURE TESTS OK — {len(chunks)} chunks from {len(docs)} docs")
|
||||
@@ -0,0 +1,211 @@
|
||||
"""ذخیرهساز برداری pgvector — جایگزین FAISS در مهاجرت به pgvector.
|
||||
|
||||
بردارها در یک دیتابیس جدا (aramesh_chatbot) روی همان نمونه Postgres سایت
|
||||
نگهداری میشوند تا دیتابیس سایت فقطخواندنی بماند. همگامسازی افزایشی است:
|
||||
فقط سطرهای جدید/تغییریافته امبد میشوند (کلید: doc_id پایدار + hash محتوا).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from .config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _strip_driver(dsn: str) -> str:
|
||||
return dsn.replace("+psycopg", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def chatbot_dsn() -> str:
|
||||
"""DSN دیتابیس اختصاصی چتبات — از DATABASE_URL با تعویض نام دیتابیس ساخته میشود."""
|
||||
parts = urlsplit(_strip_driver(settings.database_url))
|
||||
return urlunsplit(parts._replace(path=f"/{settings.chatbot_db_name}"))
|
||||
|
||||
|
||||
def _admin_dsn() -> str:
|
||||
"""DSN دیتابیس postgres برای ساخت دیتابیس چتبات (فقط یکبار)."""
|
||||
parts = urlsplit(_strip_driver(settings.database_url))
|
||||
return urlunsplit(parts._replace(path="/postgres"))
|
||||
|
||||
|
||||
def ensure_schema() -> None:
|
||||
"""ساخت دیتابیس و جدول بردارها — idempotent و در هر شروع اجرا میشود."""
|
||||
import psycopg
|
||||
from psycopg import errors as pg_errors
|
||||
|
||||
try:
|
||||
with psycopg.connect(_admin_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||
try:
|
||||
conn.execute(f'CREATE DATABASE "{settings.chatbot_db_name}"')
|
||||
log.info("Created chatbot database '%s'", settings.chatbot_db_name)
|
||||
except pg_errors.DuplicateDatabase:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Cannot reach Postgres to ensure chatbot database: {e}") from e
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS chatbot_chunks (
|
||||
doc_id TEXT PRIMARY KEY,
|
||||
content_hash TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
embedding vector({settings.embedding_dim}) NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS chatbot_chunks_embedding_hnsw
|
||||
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
||||
"""همگامسازی افزایشی قطعات با جدول pgvector.
|
||||
|
||||
- سطرهایی که دیگر وجود ندارند حذف میشوند
|
||||
- سطرهای جدید یا تغییر یافته (hash یا مدل امبدینگ عوض شده) امبد و upsert میشوند
|
||||
- force=True همه سطرها را از نو امبد میکند
|
||||
خروجی: آمار عملیات برای لاگ.
|
||||
"""
|
||||
import psycopg
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .embeddings import get_embeddings
|
||||
|
||||
model = settings.embedding_model
|
||||
wanted = {c["doc_id"]: c for c in chunks}
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||
existing: dict[str, str] = {}
|
||||
if not force:
|
||||
for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
|
||||
if row["model"] == model:
|
||||
existing[row["doc_id"]] = row["content_hash"]
|
||||
else:
|
||||
conn.execute("DELETE FROM chatbot_chunks")
|
||||
|
||||
to_delete = [doc_id for doc_id in existing if doc_id not in wanted]
|
||||
if to_delete:
|
||||
conn.execute(
|
||||
"DELETE FROM chatbot_chunks WHERE doc_id = ANY(%s)",
|
||||
[to_delete],
|
||||
)
|
||||
|
||||
to_upsert = [
|
||||
c for c in chunks
|
||||
if existing.get(c["doc_id"]) != c["content_hash"]
|
||||
]
|
||||
inserted = updated = 0
|
||||
|
||||
if to_upsert:
|
||||
vectors = get_embeddings().embed_documents([c["text"] for c in to_upsert])
|
||||
for chunk, vec in zip(to_upsert, vectors):
|
||||
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO chatbot_chunks (doc_id, content_hash, model, content, embedding)
|
||||
VALUES (%s, %s, %s, %s, %s::vector)
|
||||
ON CONFLICT (doc_id) DO UPDATE SET
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
model = EXCLUDED.model,
|
||||
content = EXCLUDED.content,
|
||||
embedding = EXCLUDED.embedding
|
||||
""",
|
||||
(chunk["doc_id"], chunk["content_hash"], model, Jsonb(chunk), literal),
|
||||
)
|
||||
if chunk["doc_id"] in existing:
|
||||
updated += 1
|
||||
else:
|
||||
inserted += 1
|
||||
|
||||
stats = {"inserted": inserted, "updated": updated, "deleted": len(to_delete),
|
||||
"unchanged": len(wanted) - len(to_upsert)}
|
||||
log.info("Vector sync: %s", stats)
|
||||
return stats
|
||||
|
||||
|
||||
def fetch_all_chunks() -> list[dict]:
|
||||
"""همه قطعات ذخیرهشده (برای ساخت BM25 در حافظه)."""
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"doc_id": r["doc_id"],
|
||||
"content_hash": r["content_hash"],
|
||||
**(r["content"] if isinstance(r["content"], dict) else json.loads(r["content"])),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def vector_count() -> int:
|
||||
"""تعداد بردارهای ذخیرهشده — برای healthz (خطا → ۰)."""
|
||||
try:
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
|
||||
return int(conn.execute("SELECT count(*) FROM chatbot_chunks").fetchone()[0])
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||
"""جستوجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
|
||||
|
||||
خطا (مثلاً دیتابیس هنوز آماده نیست) → فهرست خالی تا بازیابی فقط با BM25 ادامه یابد.
|
||||
"""
|
||||
try:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row as _dict_row
|
||||
|
||||
from .embeddings import get_embeddings
|
||||
|
||||
vec = get_embeddings().embed_query(query)
|
||||
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
|
||||
with psycopg.connect(chatbot_dsn(), row_factory=_dict_row, connect_timeout=10) as conn:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT doc_id, content, 1 - (embedding <=> %s::vector) AS sim
|
||||
FROM chatbot_chunks
|
||||
ORDER BY embedding <=> %s::vector
|
||||
LIMIT %s
|
||||
""",
|
||||
(literal, literal, top_k),
|
||||
).fetchall()
|
||||
return [(_row_to_doc(r), float(r["sim"])) for r in rows]
|
||||
except Exception as e:
|
||||
log.warning("Dense search failed (%s) — falling back to keyword only", e)
|
||||
return []
|
||||
|
||||
|
||||
def _row_to_doc(row: Any) -> dict:
|
||||
content = row["content"] if isinstance(row["content"], dict) else json.loads(row["content"])
|
||||
return {
|
||||
"doc_id": row["doc_id"],
|
||||
"text": content.get("text", ""),
|
||||
"type": content.get("type", ""),
|
||||
"title": content.get("title", ""),
|
||||
"url": content.get("url", ""),
|
||||
}
|
||||
|
||||
|
||||
def chunk_content_hash(chunk: dict) -> str:
|
||||
"""هش محتوای قطعه — تغییر متن یا متادیتا یعنی سطر باید بهروز شود."""
|
||||
payload = json.dumps(
|
||||
{k: chunk[k] for k in ("text", "type", "title", "url")},
|
||||
ensure_ascii=False, sort_keys=True,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
@@ -0,0 +1,189 @@
|
||||
# لایه مانیتورینگ — Prometheus، Grafana، Loki، Alertmanager و صادرکنندهها
|
||||
#
|
||||
# توسعه: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
|
||||
# تولید: سه فایل با هم: base + monitoring + prod (بخش مانیتورینگ README)
|
||||
#
|
||||
# داشبورد: grafana.localhost (توسعه) — رمز از GRAFANA_ADMIN_PASSWORD در .env
|
||||
# هشدارها: Prometheus → Alertmanager → تلگرام (ALERT_TELEGRAM_* در .env)
|
||||
# منابع: مجموعاً حدود ۱ تا ۱.۵ گیگابایت RAM — با حذف هر سرویس، بقیه سرپا میمانند.
|
||||
|
||||
x-default-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.53.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command:
|
||||
- --config.file=/etc/prometheus/prometheus.yml
|
||||
- --storage.tsdb.path=/prometheus
|
||||
- --storage.tsdb.retention.time=15d
|
||||
- --storage.tsdb.retention.size=2GB
|
||||
volumes:
|
||||
- ./monitoring/prometheus:/etc/prometheus:ro
|
||||
- prometheus_data:/prometheus
|
||||
networks: [monitoring, edge]
|
||||
environment:
|
||||
# پروب عمومی — در تولید SITE_URL در .env تنظیم میشود (مثل https://aramesh.example)
|
||||
SITE_URL: ${SITE_URL:-http://frontend/}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
|
||||
alertmanager:
|
||||
image: prom/alertmanager:v0.27.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command:
|
||||
- --config.file=/etc/alertmanager/alertmanager.rendered.yml
|
||||
- --storage.path=/alertmanager
|
||||
# فایل رندرشدهٔ alertmanager.rendered.yml را scripts/stack.sh از قالب
|
||||
# alertmanager.yml با مقادیر .env (ALERT_BOT_TOKEN/ALERT_CHAT_ID) میسازد —
|
||||
# Alertmanager خودش متغیر محیطی را در کانفیگ جایگزین نمیکند
|
||||
volumes:
|
||||
- ./monitoring/alertmanager:/etc/alertmanager:ro
|
||||
- alertmanager_data:/alertmanager
|
||||
networks: [monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
# منابع هر کانتینر — روی Linux سرور کامل کار میکند؛ روی Docker Desktop ویندوز
|
||||
# ممکن است محدود باشد (حذفش بقیه استک را نمیشکند)
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:v0.49.1
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
privileged: true
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
networks: [monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 192M
|
||||
|
||||
# منابع میزبان + سنجهٔ تازگی پشتیبان (backups/*.prom نوشتهٔ scripts/backup.sh)
|
||||
node-exporter:
|
||||
image: quay.io/prometheus/node-exporter:v1.8.2
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command:
|
||||
- --path.rootfs=/host
|
||||
- --collector.textfile.directory=/textfile
|
||||
volumes:
|
||||
- /:/host:ro
|
||||
- ./backups:/textfile:ro
|
||||
networks: [monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
postgres-exporter:
|
||||
image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATA_SOURCE_NAME: postgresql://aramesh:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}@db:5432/arameshplaza?sslmode=disable
|
||||
networks: [internal, monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
blackbox-exporter:
|
||||
image: prom/blackbox-exporter:v0.25.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
# edge برای رسیدن به سرویسهایی که پروب میشوند (frontend/minio/umami/chatbot/backend)
|
||||
networks: [monitoring, edge]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
# لاگ همهٔ کانتینرها — جستوجو در Grafana؛ خطاهای بکاند از همینجا دیده میشوند
|
||||
loki:
|
||||
image: grafana/loki:3.1.1
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command: -config.file=/etc/loki/loki-config.yml
|
||||
volumes:
|
||||
- ./monitoring/loki:/etc/loki:ro
|
||||
- loki_data:/loki
|
||||
networks: [monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
promtail:
|
||||
image: grafana/promtail:3.1.1
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command: -config.file=/etc/promtail/promtail-config.yml
|
||||
volumes:
|
||||
- ./monitoring/promtail:/etc/promtail:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- promtail_positions:/positions
|
||||
networks: [monitoring]
|
||||
depends_on: [loki]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.1.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD را در .env تنظیم کنید}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
networks: [monitoring, edge]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || wget -q --spider http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.grafana.rule=Host(`grafana.localhost`)
|
||||
- traefik.http.routers.grafana.entrypoints=web
|
||||
- traefik.http.services.grafana.loadbalancer.server.port=3000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384M
|
||||
|
||||
networks:
|
||||
monitoring:
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
alertmanager_data:
|
||||
loki_data:
|
||||
grafana_data:
|
||||
promtail_positions:
|
||||
@@ -0,0 +1,111 @@
|
||||
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورتهای استاندارد ۸۰/۴۳.
|
||||
# پیشنیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ بهعلاوه رکورد DNS دامنه به این سرور.
|
||||
#
|
||||
# bash scripts/stack.sh deploy-prod
|
||||
# (معادل: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build)
|
||||
#
|
||||
# همه درخواستهای HTTP به HTTPS هدایت میشوند و مسیریابیها فقط روی دامنه DOMAIN پاسخ میدهند.
|
||||
# برای داشبوردها: analytics.DOMAIN (آنالیتیکس) و grafana.DOMAIN (مانیتورینگ، با فایل monitoring).
|
||||
|
||||
services:
|
||||
traefik:
|
||||
command: !override
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
|
||||
- --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
|
||||
- --ping=true
|
||||
- --accesslog=true
|
||||
- --metrics.prometheus=true
|
||||
ports: !override
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes: !override
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- letsencrypt_data:/letsencrypt
|
||||
|
||||
# MinIO کنسول (رمز root) و API مستقیمش فقط از localhost سرور در دسترس باشند —
|
||||
# از راه دور با تونل SSH: ssh -L 9001:127.0.0.1:9001 server
|
||||
minio:
|
||||
ports: !override
|
||||
- "127.0.0.1:9001:9001"
|
||||
- "127.0.0.1:19000:9000"
|
||||
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
|
||||
- traefik.http.routers.media.priority=100
|
||||
- traefik.http.routers.media.middlewares=media-rewrite,sec-headers
|
||||
- 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
|
||||
|
||||
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.api.middlewares=api-flood,sec-headers
|
||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
||||
- traefik.http.routers.seo.entrypoints=websecure
|
||||
- traefik.http.routers.seo.tls.certresolver=le
|
||||
- traefik.http.routers.seo.middlewares=sec-headers
|
||||
|
||||
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
|
||||
- traefik.http.routers.web.middlewares=sec-headers
|
||||
# تعریف یکبارهٔ میانافزار هدرهای امنیتی — روی همهٔ روترهای websecure استفاده میشود
|
||||
- traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000
|
||||
- traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true
|
||||
- traefik.http.middlewares.sec-headers.headers.stsPreload=true
|
||||
- traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true
|
||||
|
||||
chatbot:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.chat.rule=Host(`${DOMAIN}`) && PathPrefix(`/chat`)
|
||||
- traefik.http.routers.chat.entrypoints=websecure
|
||||
- traefik.http.routers.chat.tls.certresolver=le
|
||||
- traefik.http.routers.chat.priority=100
|
||||
- traefik.http.routers.chat.middlewares=api-flood,sec-headers
|
||||
|
||||
umami:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.analytics.rule=Host(`analytics.${DOMAIN}`)
|
||||
- traefik.http.routers.analytics.entrypoints=websecure
|
||||
- traefik.http.routers.analytics.tls.certresolver=le
|
||||
- traefik.http.routers.analytics.middlewares=sec-headers
|
||||
- traefik.http.services.analytics.loadbalancer.server.port=3000
|
||||
|
||||
# داشبورد Grafana (docker-compose.monitoring.yml) در تولید با TLS نیاز به روتر
|
||||
# Host(`grafana.DOMAIN`) دارد — چون آن سرویس فقط با فایل monitoring بالا میآید،
|
||||
# برچسبهایش را در override سرور بگذارید (README، بخش مانیتورینگ):
|
||||
# docker-compose.override.yml روی سرور:
|
||||
# services:
|
||||
# grafana:
|
||||
# labels:
|
||||
# - traefik.http.routers.grafana.rule=Host(`grafana.YOURDOMAIN`)
|
||||
# - traefik.http.routers.grafana.entrypoints=websecure
|
||||
# - traefik.http.routers.grafana.tls.certresolver=le
|
||||
# - traefik.http.services.grafana.loadbalancer.server.port=3000
|
||||
|
||||
volumes:
|
||||
letsencrypt_data:
|
||||
+348
-17
@@ -1,44 +1,375 @@
|
||||
# مقادیر محرمانه از فایل .env ریشه خوانده میشوند — از .env.example کپی کنید:
|
||||
# cp .env.example .env (یا: bash scripts/gen-env.sh)
|
||||
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml هم سوار شود (README):
|
||||
# bash scripts/stack.sh deploy-prod
|
||||
#
|
||||
# لایههای اختیاری:
|
||||
# docker-compose.monitoring.yml — Prometheus/Grafana/Loki (پروفایل زیرساخت)
|
||||
# پروفایل ops — پشتیبان ساعتی دیتابیس
|
||||
# پروفایل tools — ابزار mc برای پشتیبانگیری دستی
|
||||
|
||||
x-default-logging: &default-logging
|
||||
# بدون این، لاگ json-file بیمرز رشد میکند و دیسک سرور را پر میکند
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند)، /media (MinIO)،
|
||||
# /chat (چتبات)، / (آنالیتیکس روی analytics.localhost — پایین همین فایل)
|
||||
traefik:
|
||||
# v3.3+ قدیمیترین کلاینت داکر را دارد که با Engineهای جدید (API 1.5x) مشکل دارد —
|
||||
# با v3.6 پین شده است
|
||||
image: traefik:v3.6
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command:
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
# فقط کانتینرهای همین استک — با استکهای دیگر هممیزبان تداخل نام روتر پیش نمیآید
|
||||
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
|
||||
- --entrypoints.web.address=:80
|
||||
# healthcheck داخلی + متریکها برای Prometheus + لاگ دسترسی
|
||||
- --ping=true
|
||||
- --accesslog=true
|
||||
- --metrics.prometheus=true
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-5173}:80"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks: [edge]
|
||||
healthcheck:
|
||||
test: ["CMD", "traefik", "healthcheck", "--ping"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
|
||||
db:
|
||||
# pgvector/pgvector:pg17 = همان PostgreSQL 17 + افزونهٔ vector برای چتبات
|
||||
image: pgvector/pgvector:pg17
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
POSTGRES_USER: aramesh
|
||||
POSTGRES_PASSWORD: aramesh
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||
POSTGRES_DB: arameshplaza
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
shm_size: 256m
|
||||
networks: [internal]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U aramesh -d arameshplaza"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
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
|
||||
networks: [edge, internal]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
ports:
|
||||
# در تولید، docker-compose.prod.yml اینها را فقط روی 127.0.0.1 میبندد —
|
||||
# کنسول رمز root دارد و نباید به اینترنت باز باشد
|
||||
- "9001:9001"
|
||||
- "19000:9000"
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||
- traefik.docker.network=aramesh-backend_edge
|
||||
- traefik.http.routers.media.rule=PathPrefix(`/media`)
|
||||
- traefik.http.routers.media.entrypoints=web
|
||||
- traefik.http.routers.media.priority=100
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
||||
- traefik.http.routers.media.middlewares=media-rewrite
|
||||
- traefik.http.services.media.loadbalancer.server.port=9000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
# یکبار اجرا میشود: ساخت باکت، بازگذاشتن خواندن عمومی و پیشبارگذاری تصویرهای seed
|
||||
# فقط وقتی باکت خالی است — تا هر deploy، کپی تکراری تصویرها را تکرار نکند.
|
||||
minio-setup:
|
||||
image: minio/mc:RELEASE.2025-04-08T15-39-49Z
|
||||
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"
|
||||
networks: [internal]
|
||||
logging: *default-logging
|
||||
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 &&
|
||||
COUNT=$$(mc ls --recursive local/$$MINIO_BUCKET | wc -l) &&
|
||||
if [ \"$$COUNT\" -eq 0 ]; then
|
||||
echo 'minio-setup: bucket ready — mirroring seed media';
|
||||
mc mirror --overwrite /seed-media local/$$MINIO_BUCKET/;
|
||||
else
|
||||
echo \"minio-setup: bucket ready — $$COUNT object(s) already present, seed mirror skipped\";
|
||||
fi
|
||||
"
|
||||
|
||||
# یکبار اجرا میشود: دیتابیسهای جانبی روی همان نمونهٔ Postgres
|
||||
# (umami برای آنالیتیکس، aramesh_chatbot برای بردارهای چتبات)
|
||||
db-init:
|
||||
image: postgres:17-alpine
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGHOST: db
|
||||
PGUSER: aramesh
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||
networks: [internal]
|
||||
logging: *default-logging
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
set -e;
|
||||
for DBN in umami aramesh_chatbot; do
|
||||
if ! psql -d arameshplaza -tAc \"SELECT 1 FROM pg_database WHERE datname='$$DBN'\" | grep -q 1; then
|
||||
psql -d arameshplaza -c \"CREATE DATABASE $$DBN\";
|
||||
fi;
|
||||
echo \"db-init: $$DBN ready\";
|
||||
done
|
||||
"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:aramesh@db:5432/arameshplaza
|
||||
SECRET_KEY: change-me-to-a-long-random-string
|
||||
CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]'
|
||||
ADMIN_USERNAME: admin
|
||||
ADMIN_PASSWORD: aramesh123
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost:5173","http://127.0.0.1:5173"]}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD را در .env تنظیم کنید}
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-aramesh}
|
||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
S3_BUCKET: aramesh-media
|
||||
MEDIA_PUBLIC_BASE: /media
|
||||
# نشانی عمومی سایت — برای sitemap و آدرس مطلق OG (مثل https://aramesh.example)
|
||||
SITE_URL: ${SITE_URL:-}
|
||||
# بیلد فرانتاند برای تزریق متای سئو در صفحات /blog و /projects
|
||||
FRONTEND_DIST: /srv/frontend
|
||||
volumes:
|
||||
- ./frontend/dist:/srv/frontend:ro
|
||||
networks: [edge, internal]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8000:8000"
|
||||
minio:
|
||||
condition: service_healthy
|
||||
db-init:
|
||||
condition: service_completed_successfully
|
||||
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 باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||
- traefik.docker.network=aramesh-backend_edge
|
||||
- traefik.http.routers.api.rule=PathPrefix(`/api`)
|
||||
- traefik.http.routers.api.entrypoints=web
|
||||
- traefik.http.routers.api.priority=100
|
||||
# لایه دوم محافظت از فرمها — سیلاب درخواست را پیش از رسیدن به بکاند میبُرد؛
|
||||
# محدودسازی دقیق هر فرم در خود بکاند است (rate_limit.py)
|
||||
- traefik.http.routers.api.middlewares=api-flood
|
||||
- traefik.http.middlewares.api-flood.ratelimit.average=50
|
||||
- traefik.http.middlewares.api-flood.ratelimit.period=1s
|
||||
- traefik.http.middlewares.api-flood.ratelimit.burst=150
|
||||
- traefik.http.services.api.loadbalancer.server.port=8000
|
||||
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
|
||||
# مسیرهای /en/... هم همه به بکاند میروند تا متای سئوی انگلیسی و html انگلیسی تزریق شود
|
||||
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
|
||||
- traefik.http.routers.seo.entrypoints=web
|
||||
- traefik.http.routers.seo.priority=50
|
||||
- traefik.http.routers.seo.service=api
|
||||
# /metrics فقط برای Prometheus — روتری تعریف نمیشود، از بیرون در دسترس نیست
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
|
||||
# فقط سرو فایلهای استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_API_URL: ""
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "5173:80"
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks: [edge]
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
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
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
# دستیار هوشمند — RAG روی محتوای سایت با pgvector
|
||||
# اولین اجرا مدلها را دانلود میکند (~۵۰۰MB روی حجم chatbot_hf) — کند است، طبیعی است
|
||||
chatbot:
|
||||
build: ./chatbot
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-openai/gpt-oss-120b}
|
||||
CHAT_REINDEX_TOKEN: ${CHAT_REINDEX_TOKEN:-}
|
||||
volumes:
|
||||
- chatbot_hf:/data
|
||||
networks: [edge, internal]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db-init:
|
||||
condition: service_completed_successfully
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||
- traefik.docker.network=aramesh-backend_edge
|
||||
- traefik.http.routers.chat.rule=PathPrefix(`/chat`)
|
||||
- traefik.http.routers.chat.entrypoints=web
|
||||
- traefik.http.routers.chat.priority=100
|
||||
- traefik.http.routers.chat.middlewares=api-flood
|
||||
- traefik.http.services.chat.loadbalancer.server.port=8000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
|
||||
# آنالیتیکس محصول (Umami) — بازدید، ارجاع، رخدادهای سفارشی (فرم/تلفن/چتبات)
|
||||
# داشبورد: analytics.localhost (توسعه) یا analytics.DOMAIN (تولید)
|
||||
umami:
|
||||
image: ghcr.io/umami-software/umami:postgresql-v2.17.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATABASE_URL: postgresql://aramesh:${POSTGRES_PASSWORD}@db:5432/umami
|
||||
# اسکریپت ردیاب به ارجاعدهندهٔ واقعی نیاز دارد — پشت Traefik
|
||||
CLIENT_IP_HEADER: x-forwarded-for
|
||||
DISABLE_TELEMETRY: 1
|
||||
networks: [edge, internal]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
# 127.0.0.1 نه localhost — «localhost» به ::1 حل میشود و umami فقط IPv4 گوش میدهد
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/heartbeat || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||
- traefik.docker.network=aramesh-backend_edge
|
||||
- traefik.http.routers.analytics.rule=Host(`analytics.localhost`)
|
||||
- traefik.http.routers.analytics.entrypoints=web
|
||||
- traefik.http.services.analytics.loadbalancer.server.port=3000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
# ابزار پشتیبانگیری — فقط با پروفایل tools اجرا میشود (scripts/backup.sh این کار را میکند)
|
||||
mc:
|
||||
image: minio/mc:RELEASE.2025-04-08T15-39-49Z
|
||||
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 تنظیم کنید}
|
||||
networks: [internal]
|
||||
|
||||
# پشتیبان ساعتی خودکار دیتابیس — بدون نیاز به cron میزبان؛ فقط با پروفایل ops
|
||||
# docker compose --profile ops up -d db-backup
|
||||
# RPO را از ۲۴ ساعت (cron شبانه) به ۱ ساعت میرساند؛ scripts/backup.sh برای
|
||||
# پشتیبان کامل (دیتابیس + تصاویر MinIO) سر جایش است.
|
||||
db-backup:
|
||||
image: prodrigestivill/postgres-backup-local:17
|
||||
profiles: ["ops"]
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_DB: arameshplaza
|
||||
POSTGRES_USER: aramesh
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||
SCHEDULE: "@hourly"
|
||||
BACKUP_NUM_KEEP: "48"
|
||||
BACKUP_DIR: /backups
|
||||
volumes:
|
||||
- backups:/backups
|
||||
networks: [internal]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
networks:
|
||||
edge: # سرویسهایی که Traefik به آنها مسیریابی میکند
|
||||
internal: # دیتابیس و ذخیرهسازی — فرانتاند راهی به این شبکه ندارد
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
minio_data:
|
||||
chatbot_hf:
|
||||
backups:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# فقط Caddyfile و dist/ به کانتکست بیلد میروند — node_modules و سورس عظیماند و لازم نیستند
|
||||
*
|
||||
!Caddyfile
|
||||
!dist
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
# پنل مدیریت Caddy لازم نیست — سطح حمله کمتر
|
||||
admin off
|
||||
}
|
||||
|
||||
:80 {
|
||||
root * /srv
|
||||
encode zstd gzip
|
||||
|
||||
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمیگردند
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
|
||||
# فایلهای هشدار بیلد: کش بلندمدت بیرحم
|
||||
@assets path /assets/*
|
||||
header @assets Cache-Control "public, immutable, max-age=31536000"
|
||||
|
||||
# بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
|
||||
@notassets not path /assets/*
|
||||
header @notassets Cache-Control "no-cache"
|
||||
}
|
||||
+14
-17
@@ -1,23 +1,20 @@
|
||||
# ---------- مرحله ساخت ----------
|
||||
FROM node:22-alpine AS build
|
||||
# ---------- فرانتاند فقط سرو میکند ----------
|
||||
# بیلد روی هاست انجام میشود (npm install && npm run build) و خروجی dist/
|
||||
# داخل ایمیج کپی میشود — شبکه کانتینر بیلد برای npm قابلاعتماد نیست.
|
||||
|
||||
WORKDIR /app
|
||||
FROM caddy:2-alpine
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
# نسخهٔ alpine کاربر caddy ندارد — اینجا ساخته میشود؛ /config و /data مسیرهای
|
||||
# storage سیدی هستند و باید برای کاربر نوشتنی باشند
|
||||
RUN addgroup -S caddy \
|
||||
&& adduser -S -G caddy -s /sbin/nologin caddy \
|
||||
&& mkdir -p /config /data \
|
||||
&& chown -R caddy:caddy /config /data
|
||||
|
||||
COPY . .
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --chown=caddy:caddy dist /srv
|
||||
|
||||
# آدرس 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
|
||||
# بدون root — فقط فایلهای استاتیک سرو میشوند و TLS را Traefik تمام میکند
|
||||
USER caddy
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
+59
-5
@@ -1,8 +1,62 @@
|
||||
# Magic Patterns - Vite Template
|
||||
|
||||
This code was generated by [Magic Patterns](https://magicpatterns.com) for this design: [Source Design](https://www.magicpatterns.com/c/ka4i8ikygtakmdobhws6nu)
|
||||
# Aramesh Construction Group — Frontend
|
||||
|
||||
## Getting Started
|
||||
A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
|
||||
|
||||
1. Run `npm install`
|
||||
2. Run `npm run dev`
|
||||
## Typography & Smart Bidi Text
|
||||
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
|
||||
|
||||
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
|
||||
|
||||
## Recent UI/UX Improvements
|
||||
* **Tiptap Editor Fixes**:
|
||||
* Resolved the initialization bug where the editor body remained empty during edit mode.
|
||||
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
|
||||
* **Dynamic TOC & Layout**:
|
||||
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
|
||||
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
|
||||
|
||||
## CMS, SEO & Editor Features
|
||||
|
||||
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
|
||||
|
||||
* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting.
|
||||
* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema.
|
||||
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
|
||||
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
|
||||
* **Custom Tiptap WYSIWYG Editor:**
|
||||
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
|
||||
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls.
|
||||
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
|
||||
* **Full Jalali (Shamsi) Calendar Integration:**
|
||||
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
|
||||
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
```
|
||||
|
||||
In development mode, Vite proxies `/api` and `/media` requests to Traefik (port 5173). Therefore, the Docker stack must be running.
|
||||
The backend API URL is read from the `VITE_API_URL` environment variable in `src/config.ts`; an empty default implies same-origin requests.
|
||||
|
||||
## Build & Production
|
||||
|
||||
The build process runs on the host machine. The Docker container only serves the generated static files in the `dist/` directory using Caddy:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
|
||||
```
|
||||
|
||||
> **Note:** The backend reads the `dist/` directory (mounted at `/srv/frontend` in Docker Compose) to inject SEO metadata into the `/blog` and `/projects` pages. After making any frontend changes, running `npm run build` and restarting the backend is sufficient.
|
||||
|
||||
## Key Modules & Architecture
|
||||
|
||||
* `src/api.tsx` — Handles site data loading and draft article merging for the admin view.
|
||||
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
|
||||
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
|
||||
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
|
||||
|
||||
+28
-2
@@ -1,13 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Aramesh Building Group Luxury Website</title>
|
||||
<title>گروه ساختمانی آرامش</title>
|
||||
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژههای ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایهگذاری ساختوساز." />
|
||||
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"Organization","name":"گروه ساختمانی آرامش","inLanguage":"fa"}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// زبان و جهت سند پیش از اولین رندر، از خود مسیر — /en/... انگلیسی و LTR است
|
||||
// تا جهت صفحه از نخستین فریم درست باشد (نسخه انگلیسی سمت سرور هم همین را تزریق میکند)
|
||||
if (location.pathname === '/en' || location.pathname.startsWith('/en/')) {
|
||||
document.documentElement.setAttribute('lang', 'en');
|
||||
document.documentElement.setAttribute('dir', 'ltr');
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
// تحلیل رفتار (Umami) — در زمان بیلد تزریق میشود؛ VITE_UMAMI_SRC خالی یعنی خاموش
|
||||
if (import.meta.env.VITE_UMAMI_SRC) {
|
||||
const s = document.createElement('script');
|
||||
s.defer = true;
|
||||
s.src = import.meta.env.VITE_UMAMI_SRC;
|
||||
s.setAttribute('data-website-id', import.meta.env.VITE_UMAMI_WEBSITE_ID ?? '');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# مسیریابی SPA — همه مسیرها به index.html برمیگردند
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# پروکسی درخواستهای API به سرویس بکاند
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# کش استاتیک
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
Generated
+823
-4
@@ -9,11 +9,21 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@tiptap/extension-image": "^3.31.3",
|
||||
"@tiptap/extension-link": "^3.31.3",
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
"slugify": "^1.6.9",
|
||||
"sweetalert2": "^11.26.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -922,6 +932,31 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -1427,6 +1462,465 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz",
|
||||
"integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz",
|
||||
"integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz",
|
||||
"integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz",
|
||||
"integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz",
|
||||
"integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz",
|
||||
"integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz",
|
||||
"integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz",
|
||||
"integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz",
|
||||
"integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz",
|
||||
"integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz",
|
||||
"integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz",
|
||||
"integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz",
|
||||
"integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz",
|
||||
"integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-image": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.31.3.tgz",
|
||||
"integrity": "sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz",
|
||||
"integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz",
|
||||
"integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz",
|
||||
"integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz",
|
||||
"integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz",
|
||||
"integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz",
|
||||
"integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz",
|
||||
"integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz",
|
||||
"integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
|
||||
"integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz",
|
||||
"integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz",
|
||||
"integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz",
|
||||
"integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.4.1",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-dropcursor": "^1.8.2",
|
||||
"prosemirror-gapcursor": "^1.4.1",
|
||||
"prosemirror-history": "^1.5.0",
|
||||
"prosemirror-inputrules": "^1.5.1",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.11",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.5",
|
||||
"prosemirror-transform": "^1.12.0",
|
||||
"prosemirror-view": "^1.42.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz",
|
||||
"integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-equals": "^5.3.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.31.3",
|
||||
"@tiptap/extension-floating-menu": "^3.31.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz",
|
||||
"integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-blockquote": "3.31.3",
|
||||
"@tiptap/extension-bold": "3.31.3",
|
||||
"@tiptap/extension-bullet-list": "3.31.3",
|
||||
"@tiptap/extension-code": "3.31.3",
|
||||
"@tiptap/extension-code-block": "3.31.3",
|
||||
"@tiptap/extension-document": "3.31.3",
|
||||
"@tiptap/extension-dropcursor": "3.31.3",
|
||||
"@tiptap/extension-gapcursor": "3.31.3",
|
||||
"@tiptap/extension-hard-break": "3.31.3",
|
||||
"@tiptap/extension-heading": "3.31.3",
|
||||
"@tiptap/extension-horizontal-rule": "3.31.3",
|
||||
"@tiptap/extension-italic": "3.31.3",
|
||||
"@tiptap/extension-link": "3.31.3",
|
||||
"@tiptap/extension-list": "3.31.3",
|
||||
"@tiptap/extension-list-item": "3.31.3",
|
||||
"@tiptap/extension-list-keymap": "3.31.3",
|
||||
"@tiptap/extension-ordered-list": "3.31.3",
|
||||
"@tiptap/extension-paragraph": "3.31.3",
|
||||
"@tiptap/extension-strike": "3.31.3",
|
||||
"@tiptap/extension-text": "3.31.3",
|
||||
"@tiptap/extension-underline": "3.31.3",
|
||||
"@tiptap/extensions": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/static-renderer": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/static-renderer/-/static-renderer-3.31.3.tgz",
|
||||
"integrity": "sha512-TzbN/aSTQBhGZL94aaEnfbwZw/gIiQd8/q3TucTYuzdbsrsZNfBMg7CCA9W9dOg4vlKxCTLRVas21ivsrUm7og==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1506,14 +2000,12 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -1524,12 +2016,20 @@
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
|
||||
@@ -1537,6 +2037,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
||||
@@ -2267,6 +2773,16 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.407",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
||||
@@ -2578,6 +3094,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz",
|
||||
"integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -3168,6 +3693,12 @@
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -3222,6 +3753,12 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -3349,7 +3886,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -3393,6 +3929,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -3721,6 +4263,156 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
|
||||
"integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
|
||||
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz",
|
||||
"integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.11",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz",
|
||||
"integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz",
|
||||
"integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.42.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz",
|
||||
"integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.8",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -3764,6 +4456,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-date-object": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmmirror.com/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
@@ -3777,12 +4475,52 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-element-popper": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/react-element-popper/-/react-element-popper-2.1.7.tgz",
|
||||
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.88.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz",
|
||||
"integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-multi-date-picker": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
|
||||
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-date-object": "^2.1.8",
|
||||
"react-element-popper": "^2.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -3825,6 +4563,43 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-select": {
|
||||
"version": "5.10.2",
|
||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
|
||||
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.0",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.8.1",
|
||||
"@floating-ui/dom": "^1.0.1",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"prop-types": "^15.6.0",
|
||||
"react-transition-group": "^4.3.0",
|
||||
"use-isomorphic-layout-effect": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -3952,6 +4727,12 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -4031,6 +4812,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/slugify": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
|
||||
"integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
@@ -4393,6 +5183,29 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-isomorphic-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -4460,6 +5273,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -11,11 +11,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@tiptap/extension-image": "^3.31.3",
|
||||
"@tiptap/extension-link": "^3.31.3",
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
"slugify": "^1.6.9",
|
||||
"sweetalert2": "^11.26.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+90
-30
@@ -5,6 +5,7 @@ import { Preloader } from './components/Preloader';
|
||||
import { Header } from './components/Header';
|
||||
import { Footer } from './components/Footer';
|
||||
import { FloatingContact } from './components/FloatingContact';
|
||||
import { ChatWidget } from './components/ChatWidget';
|
||||
import { Home } from './pages/Home';
|
||||
import { About } from './pages/About';
|
||||
import { ServicesPage } from './pages/ServicesPage';
|
||||
@@ -15,7 +16,35 @@ import { Blog } from './pages/Blog';
|
||||
import { ArticleDetail } from './pages/ArticleDetail';
|
||||
import { FaqPage } from './pages/FaqPage';
|
||||
import { ContactPage } from './pages/ContactPage';
|
||||
import { SiteProvider, useSiteData } from './api';
|
||||
import { SiteProvider, useSiteData, useSite } from './api';
|
||||
import { applySeo, seoKeyForPath } from './seo';
|
||||
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
||||
import { AdminBar } from './admin/AdminBar';
|
||||
import { AdminLoginPage } from './admin/AdminLoginPage';
|
||||
import { ArticleWritePage } from './admin/ArticleWritePage';
|
||||
import { LangProvider, useLang } from './i18n/lang';
|
||||
|
||||
// مسیرهای عمومی یک بار تعریف میشوند و دو بار سرو میشوند: ریشه فارسی و /en انگلیسی
|
||||
const PUBLIC_ROUTES: { path: string; element: React.ReactElement }[] = [
|
||||
{ path: '/', element: <Home /> },
|
||||
{ path: '/about', element: <About /> },
|
||||
{ path: '/services', element: <ServicesPage /> },
|
||||
{ path: '/projects', element: <Projects /> },
|
||||
{ path: '/projects/:slug', element: <ProjectDetail /> },
|
||||
{ path: '/investment', element: <Investment /> },
|
||||
{ path: '/blog', element: <Blog /> },
|
||||
{ path: '/blog/:slug', element: <ArticleDetail /> },
|
||||
{ path: '/faq', element: <FaqPage /> },
|
||||
{ path: '/contact', element: <ContactPage /> },
|
||||
];
|
||||
|
||||
// فرزند مستقیم <Routes> باید <Route> باشد — آرایههای Route بهجای کامپوننت واسط
|
||||
const FA_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
|
||||
<Route key={path} path={path} element={element} />
|
||||
));
|
||||
const EN_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
|
||||
<Route key={`en-${path}`} path={path === '/' ? '/en' : `/en${path}`} element={element} />
|
||||
));
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
@@ -35,67 +64,98 @@ function AnimatedRoutes() {
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
||||
|
||||
|
||||
<Routes location={location}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/projects/:slug" element={<ProjectDetail />} />
|
||||
<Route path="/investment" element={<Investment />} />
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
<Route path="/blog/:slug" element={<ArticleDetail />} />
|
||||
<Route path="/faq" element={<FaqPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
<Route path="/admin/write" element={<ArticleWritePage />} />
|
||||
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
|
||||
{FA_ROUTES}
|
||||
<Route path="*" element={<Home />} />
|
||||
{EN_ROUTES}
|
||||
</Routes>
|
||||
</motion.main>
|
||||
</AnimatePresence>);
|
||||
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
function AppChrome() {
|
||||
const { pathname } = useLocation();
|
||||
const { isAuthenticated } = useAdmin();
|
||||
const data = useSite();
|
||||
const { lang } = useLang();
|
||||
const isAdminPage = pathname.startsWith('/admin');
|
||||
|
||||
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحههای جزئیات که خودشان ست میکنند
|
||||
useEffect(() => {
|
||||
if (isAdminPage) return;
|
||||
const key = seoKeyForPath(pathname);
|
||||
if (!key) return;
|
||||
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
||||
const brand = data.brand.name;
|
||||
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
|
||||
applySeo({
|
||||
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
||||
description: meta?.description ?? '',
|
||||
path: pathname,
|
||||
lang,
|
||||
});
|
||||
}, [pathname, data, isAdminPage, lang]);
|
||||
|
||||
return (
|
||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
|
||||
<>
|
||||
{!isAdminPage && <Header />}
|
||||
<AnimatedRoutes />
|
||||
{!isAdminPage && <Footer />}
|
||||
{!isAdminPage && <FloatingContact />}
|
||||
{!isAdminPage && <ChatWidget />}
|
||||
{isAuthenticated && !isAdminPage && <AdminBar />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
const { t, dir, lang } = useLang();
|
||||
|
||||
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
||||
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
||||
return (
|
||||
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
||||
<Preloader />
|
||||
{error ? (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-2xl font-bold text-ivory">اتصال به سرور برقرار نشد</h1>
|
||||
<h1 className="text-2xl font-bold text-ivory">{t('errorTitle')}</h1>
|
||||
<p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70">
|
||||
محتوای سایت از سرور بارگذاری میشود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.
|
||||
{t('errorText')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105"
|
||||
>
|
||||
تلاش دوباره
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : data ? (
|
||||
<BrowserRouter>
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<Header />
|
||||
<AnimatedRoutes />
|
||||
<Footer />
|
||||
<FloatingContact />
|
||||
</BrowserRouter>
|
||||
<AppChrome />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('dir', 'rtl');
|
||||
document.documentElement.setAttribute('lang', 'fa');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SiteProvider>
|
||||
<AppShell />
|
||||
<AdminProvider>
|
||||
<BrowserRouter>
|
||||
<LangProvider>
|
||||
<AppShell />
|
||||
</LangProvider>
|
||||
</BrowserRouter>
|
||||
</AdminProvider>
|
||||
</SiteProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState } from 'react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { KeyRoundIcon, XIcon } from 'lucide-react'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
idle: 'همگامسازی شد',
|
||||
saving: 'در حال ذخیره…',
|
||||
error: 'خطا در ذخیره',
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
idle: 'bg-emerald-400',
|
||||
saving: 'bg-copper animate-pulse',
|
||||
error: 'bg-red-400',
|
||||
}
|
||||
|
||||
const SWAL_BASE = {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10 rounded-none',
|
||||
title: 'font-pinar font-bold text-lg text-ivory',
|
||||
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
|
||||
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
|
||||
},
|
||||
}
|
||||
|
||||
function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
if (next.length < 8) {
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'رمز جدید کوتاه است', text: 'رمز جدید باید دستکم ۸ نویسه باشد.', confirmButtonText: 'باشه' })
|
||||
return
|
||||
}
|
||||
if (next !== confirm) {
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'تکرار رمز مطابق نیست', text: 'رمز جدید و تکرار آن یکسان نیستند.', confirmButtonText: 'باشه' })
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await adminApi.changePassword(current, next)
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'success', title: 'رمز عبور عوض شد', text: 'از این به بعد با رمز جدید وارد شوید.', iconColor: '#34d399', confirmButtonText: 'باشه' })
|
||||
onClose()
|
||||
} catch (error) {
|
||||
void Swal.fire({
|
||||
...SWAL_BASE,
|
||||
icon: 'error',
|
||||
title: 'تغییر رمز ناموفق بود',
|
||||
text: error instanceof Error ? error.message : 'دوباره تلاش کنید',
|
||||
confirmButtonText: 'باشه',
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const field =
|
||||
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[95] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="تغییر رمز عبور">
|
||||
<div dir="rtl" className="w-full max-w-sm border border-ivory/15 bg-ink p-8 shadow-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||
<KeyRoundIcon className="h-4 w-4" />
|
||||
تغییر رمز عبور
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="بستن"
|
||||
className="text-ivory/50 transition-colors hover:text-ivory"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4">
|
||||
<input
|
||||
type="password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
placeholder="رمز عبور فعلی"
|
||||
aria-label="رمز عبور فعلی"
|
||||
autoComplete="current-password"
|
||||
className={field}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
placeholder="رمز جدید (دستکم ۸ نویسه)"
|
||||
aria-label="رمز جدید"
|
||||
autoComplete="new-password"
|
||||
className={field}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="تکرار رمز جدید"
|
||||
aria-label="تکرار رمز جدید"
|
||||
autoComplete="new-password"
|
||||
className={field}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={busy || !current || !next}
|
||||
className="mt-6 w-full bg-copper py-2.5 text-sm font-bold text-char transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'در حال ذخیره…' : 'ذخیره رمز جدید'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||
export function AdminBar() {
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
|
||||
const [pwOpen, setPwOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
dir="rtl"
|
||||
className="fixed bottom-4 right-1/2 z-[90] flex translate-x-1/2 items-center gap-5 border border-ivory/15 bg-ink/95 py-2.5 pl-6 pr-5 shadow-2xl backdrop-blur-md"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-bold text-copper">
|
||||
<span className="flex h-2 w-2 rounded-full bg-copper" />
|
||||
حالت مدیریت
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isEditMode}
|
||||
onClick={toggleEditMode}
|
||||
className="group flex items-center gap-3 text-sm font-light text-ivory/80"
|
||||
>
|
||||
<span
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${
|
||||
isEditMode ? 'border-copper bg-copper/30' : 'border-ivory/25 bg-char'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute h-4 w-4 rounded-full transition-all duration-200 ${
|
||||
isEditMode ? 'right-1 bg-copper' : 'right-6 bg-ivory/50'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleEditLang}
|
||||
title="تعیین میکند ویرایشها روی کدام ستون زبان ذخیره شوند"
|
||||
className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${
|
||||
editLang === 'en'
|
||||
? 'border-copper bg-copper/10 text-copper'
|
||||
: 'border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
زبان ویرایش: {editLang === 'fa' ? 'فارسی' : 'English'}
|
||||
</button>
|
||||
|
||||
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPwOpen(true)}
|
||||
className="flex items-center gap-1.5 border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||
>
|
||||
<KeyRoundIcon className="h-3.5 w-3.5" />
|
||||
رمز
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'error', title: 'خروجی پیامها', text: e instanceof Error ? e.message : 'خطا', confirmButtonText: 'باشه' })
|
||||
})}
|
||||
title="دانلود پیامهای فرم تماس بهصورت CSV"
|
||||
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"
|
||||
>
|
||||
پیامها (CSV)
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void adminApi.downloadCsv('subscribers').catch((e) => {
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'error', title: 'خروجی خبرنامه', text: e instanceof Error ? e.message : 'خطا', confirmButtonText: 'باشه' })
|
||||
})}
|
||||
title="دانلود مشترکان خبرنامه بهصورت CSV"
|
||||
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"
|
||||
>
|
||||
خبرنامه (CSV)
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||
>
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pwOpen && <ChangePasswordModal onClose={() => setPwOpen(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import Swal from 'sweetalert2'
|
||||
import type { SiteData } from '../api'
|
||||
import { useSiteData } from '../api'
|
||||
import { adminApi, getToken, HttpError, setToken } from './adminClient'
|
||||
import { ENTITIES, isTranslatable } from './entities'
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
||||
export type EditLang = 'fa' | 'en'
|
||||
|
||||
interface AdminContextValue {
|
||||
isAuthenticated: boolean
|
||||
isEditMode: boolean
|
||||
toggleEditMode: () => void
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
saveStatus: SaveStatus
|
||||
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||
runSave: (action: () => Promise<void>) => Promise<boolean>
|
||||
/** زبان محتوایی که در حالت ویرایش ذخیره میشود (فارسی یا انگلیسی) */
|
||||
editLang: EditLang
|
||||
toggleEditLang: () => void
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextValue | null>(null)
|
||||
|
||||
export function useAdmin(): AdminContextValue {
|
||||
const ctx = useContext(AdminContext)
|
||||
if (!ctx) throw new Error('useAdmin باید داخل AdminProvider استفاده شود')
|
||||
return ctx
|
||||
}
|
||||
|
||||
function showSaveError(error: unknown): void {
|
||||
const message =
|
||||
error instanceof HttpError ? error.message : 'ارتباط با سرور برقرار نشد — اتصال اینترنت را بررسی کنید'
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'ذخیره نشد',
|
||||
text: message,
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'باشه',
|
||||
timer: 4000,
|
||||
timerProgressBar: true,
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10 rounded-none',
|
||||
title: 'font-pinar font-bold text-lg text-ivory',
|
||||
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
|
||||
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
|
||||
const [isEditMode, setIsEditMode] = useState<boolean>(
|
||||
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
|
||||
)
|
||||
const [editLang, setEditLang] = useState<EditLang>(
|
||||
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
|
||||
)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const { reload } = useSiteData()
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setIsEditMode((v) => {
|
||||
sessionStorage.setItem('aramesh_edit_mode', v ? '0' : '1')
|
||||
return !v
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleEditLang = useCallback(() => {
|
||||
setEditLang((v) => {
|
||||
const next: EditLang = v === 'fa' ? 'en' : 'fa'
|
||||
sessionStorage.setItem('aramesh_edit_lang', next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
await adminApi.login(username, password)
|
||||
setIsAuthenticated(true)
|
||||
// داده سایت دوباره بارگذاری میشود تا پیشنویس مقالهها هم ادغام شوند
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setToken(null)
|
||||
setIsAuthenticated(false)
|
||||
setIsEditMode(false)
|
||||
sessionStorage.removeItem('aramesh_edit_mode')
|
||||
// داده سایت دوباره بارگذاری میشود تا پیشنویسها از درخت داده حذف شوند
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
const runSave = useCallback(
|
||||
async (action: () => Promise<void>): Promise<boolean> => {
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
await action()
|
||||
setSaveStatus('idle')
|
||||
return true
|
||||
} catch (error) {
|
||||
setSaveStatus('error')
|
||||
showSaveError(error)
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
logout()
|
||||
reload()
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
[logout, reload],
|
||||
)
|
||||
|
||||
// در حالت ویرایش، کلیک روی لینکها مسیریابی نمیکند تا ویرایش متن ممکن باشد
|
||||
useEffect(() => {
|
||||
if (!isEditMode) return
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const anchor = (e.target as HTMLElement | null)?.closest('a')
|
||||
// لینکهای خود لایه مدیریت (مثل «نوشتن مقاله جدید») کار میکنند
|
||||
if (anchor && !anchor.dataset.adminLink) e.preventDefault()
|
||||
}
|
||||
document.addEventListener('click', onClick, true)
|
||||
return () => document.removeEventListener('click', onClick, true)
|
||||
}, [isEditMode])
|
||||
|
||||
// در حین ذخیرهسازی، از بستهشدن تصادفی صفحه جلوگیری میشود
|
||||
useEffect(() => {
|
||||
if (saveStatus !== 'saving') return
|
||||
const guard = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
window.addEventListener('beforeunload', guard)
|
||||
return () => window.removeEventListener('beforeunload', guard)
|
||||
}, [saveStatus])
|
||||
|
||||
const value = useMemo<AdminContextValue>(
|
||||
() => ({
|
||||
isAuthenticated,
|
||||
isEditMode,
|
||||
toggleEditMode,
|
||||
login,
|
||||
logout,
|
||||
saveStatus,
|
||||
runSave,
|
||||
editLang,
|
||||
toggleEditLang,
|
||||
}),
|
||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
|
||||
)
|
||||
|
||||
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* پرکاربردترین کمکساز ویرایش: یک فیلد واحد را PATCH میکند و پس از موفقیت
|
||||
* همان تغییر را بهصورت خوشبینانه در درخت داده سایت اعمال میکند.
|
||||
*
|
||||
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت میشوند؛ چون
|
||||
* برخی mutatorهای کامپوننتها نام فیلد را جداگانه مینویسند، مقدار قبلی فیلدهای
|
||||
* فارسی اسنپشات میشود و پس از اجرای mutator بازگردانده میشود تا متن انگلیسی
|
||||
* هرگز جای متن فارسی در درخت خام ننشیند.
|
||||
*/
|
||||
export function useFieldSaver() {
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { updateSite } = useSiteData()
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
resource: string,
|
||||
id: number,
|
||||
changes: Record<string, unknown>,
|
||||
apply: (data: SiteData) => void,
|
||||
): Promise<boolean> =>
|
||||
runSave(async () => {
|
||||
const originalKeys = Object.keys(changes)
|
||||
if (editLang === 'en') {
|
||||
const mapped: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
mapped[isTranslatable(resource, key) ? `${key}_en` : key] = value
|
||||
}
|
||||
for (const key of Object.keys(changes)) delete changes[key]
|
||||
Object.assign(changes, mapped)
|
||||
}
|
||||
await adminApi.patch(resource, id, changes)
|
||||
updateSite((d) => {
|
||||
const config = ENTITIES[resource]
|
||||
let node: Record<string, unknown> | undefined
|
||||
if (config) {
|
||||
const target = d[config.dataKey as keyof SiteData] as unknown
|
||||
node =
|
||||
config.kind === 'singleton'
|
||||
? (target as Record<string, unknown> | undefined)
|
||||
: (target as { id: number }[] | undefined)?.find((x) => x.id === id)
|
||||
}
|
||||
const snapshot =
|
||||
editLang === 'en' && node
|
||||
? Object.fromEntries(originalKeys.filter((k) => k !== 'id').map((k) => [k, node![k]]))
|
||||
: null
|
||||
apply(d)
|
||||
if (snapshot && node) {
|
||||
Object.assign(node, snapshot)
|
||||
Object.assign(node, changes)
|
||||
}
|
||||
})
|
||||
}),
|
||||
[runSave, updateSite, editLang],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* نام فیلد متناظر با زبان ویرایش جاری — در حالت انگلیسی فیلدهای قابل ترجمه
|
||||
* پسوند _en میگیرند. برای جاهایی که نام فیلد جدا از شیء تغییرات ذخیره میشود.
|
||||
*/
|
||||
export function useEditField() {
|
||||
const { editLang } = useAdmin()
|
||||
return useCallback(
|
||||
(resource: string, field: string) =>
|
||||
editLang === 'en' && isTranslatable(resource, field) ? `${field}_en` : field,
|
||||
[editLang],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useAdmin } from './AdminContext'
|
||||
|
||||
/** صفحه ورود ادمین — مسیر پنهان /admin/login */
|
||||
export function AdminLoginPage() {
|
||||
const { isAuthenticated, login } = useAdmin()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
if (isAuthenticated) return <Navigate to="/" replace />
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (submitting) return
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(username.trim(), password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex min-h-screen flex-col items-center justify-center bg-ink px-6" dir="rtl">
|
||||
<div className="w-full max-w-sm border border-ivory/10 bg-char p-10">
|
||||
<h1 className="text-xl font-bold text-ivory">ورود مدیریت</h1>
|
||||
<p className="mt-3 text-sm font-light leading-7 text-ivory/55">
|
||||
برای فعالسازی حالت ویرایش زنده سایت وارد شوید.
|
||||
</p>
|
||||
|
||||
<form onSubmit={submit} className="mt-8 grid gap-5" noValidate>
|
||||
<div>
|
||||
<label htmlFor="admin-username" className="block text-sm font-bold text-ivory/90">
|
||||
نام کاربری
|
||||
</label>
|
||||
<input
|
||||
id="admin-username"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="admin-password" className="block text-sm font-bold text-ivory/90">
|
||||
رمز عبور
|
||||
</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
dir="ltr"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="border-r-2 border-red-400/70 pr-3 text-xs font-light leading-6 text-red-300">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="mt-2 bg-copper px-8 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-[1.02] disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'در حال ورود…' : 'ورود'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Controller, useController, useForm } from 'react-hook-form'
|
||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import type { CategoryOption } from '../components/admin/LuxeSelect'
|
||||
import { ARTICLE_CATEGORIES } from './entities'
|
||||
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||
import { formatReadingTime } from '../utils/readingTime'
|
||||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||
import type { Article, ArticleBodyDoc } from '../data/site'
|
||||
|
||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||||
|
||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
|
||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
|
||||
|
||||
/**
|
||||
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت میشوند:
|
||||
* عنوان (مبنای Alt پویا)، دستهبندی و متادیتای تصویر شاخص.
|
||||
* `category` در فرم یک شیء تکمقداری {value,label} یا null است
|
||||
* که هنگام ذخیره به رشته (ستون category در بکاند) نگاشت میشود.
|
||||
*/
|
||||
type ArticleMetaForm = {
|
||||
title: string
|
||||
category: CategoryOption | null
|
||||
image_alt: string
|
||||
image_title: string
|
||||
}
|
||||
|
||||
/** مقدار رشتهای ذخیرهشده → گزینه react-select (یا null وقتی خالی است) */
|
||||
function toCategoryOption(value: string): CategoryOption | null {
|
||||
const category = (value ?? '').trim()
|
||||
return category ? { label: category, value: category } : null
|
||||
}
|
||||
|
||||
/** گزینه react-select → مقدار رشتهای ذخیرهسازی در بکاند */
|
||||
function categoryValue(option: CategoryOption | null): string {
|
||||
return option?.value.trim() ?? ''
|
||||
}
|
||||
|
||||
/** دادههای قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
|
||||
if (typeof value === 'string') return value.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
return []
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
idle: 'ذخیره شد',
|
||||
saving: 'در حال ذخیره…',
|
||||
error: 'خطا در ذخیره',
|
||||
}
|
||||
|
||||
/**
|
||||
* صفحه تمامعرض نوشتن/ویرایش مقاله — محیط نوشتن متمرکز بدون چرخه سایت.
|
||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||
*/
|
||||
export function ArticleWritePage() {
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { data, updateSite } = useSiteData()
|
||||
|
||||
const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined
|
||||
const [fetched, setFetched] = useState<Draft | null>(null)
|
||||
|
||||
// مقاله تازه همیشه پیشنویس شروع میشود؛ با دکمه «انتشار در سایت» عمومی میشود
|
||||
const [draft, setDraft] = useState<Draft>({
|
||||
id: 0,
|
||||
slug: `article-${Date.now()}`,
|
||||
title: '',
|
||||
title_en: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
date_en: '',
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
category_en: '',
|
||||
tags: [],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
read_en: '',
|
||||
excerpt: '',
|
||||
excerpt_en: '',
|
||||
body: EMPTY_DOC,
|
||||
body_en: EMPTY_DOC,
|
||||
status: 'draft',
|
||||
seo_keyphrase: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* فرم متادیتا با React Hook Form — منبع نمایش فیلدها.
|
||||
* هر تغییر، هم فرم و هم draft (مبنای ذخیرهسازی خودکار) را بهروز میکند.
|
||||
*/
|
||||
const { control, watch, reset } = useForm<ArticleMetaForm>({
|
||||
defaultValues: {
|
||||
title: '',
|
||||
category: toCategoryOption(ARTICLE_CATEGORIES[0]),
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
/** عنوان زنده مقاله — مبنای متن جایگزین (Alt) تصویر شاخص */
|
||||
const watchedTitle = watch('title')
|
||||
const { field: imageAltField } = useController({ name: 'image_alt', control })
|
||||
const { field: imageTitleField } = useController({ name: 'image_title', control })
|
||||
|
||||
const [createdId, setCreatedId] = useState<number | null>(null)
|
||||
/**
|
||||
* شناسه محتوای بارگذاریشده — با رسیدن مقاله از سرور عوض میشود تا
|
||||
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
|
||||
*/
|
||||
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const draftRef = useRef(draft)
|
||||
draftRef.current = draft
|
||||
const createdRef = useRef(createdId)
|
||||
createdRef.current = createdId
|
||||
const pendingRef = useRef<Record<string, unknown> | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// اگر مقاله در داده سایت نبود (مثلاً پیش از ادغام پیشنویسها) مستقیم از انتهای مدیریتی خوانده میشود
|
||||
useEffect(() => {
|
||||
if (!id || existing) return
|
||||
let cancelled = false
|
||||
adminApi
|
||||
.get<Draft>('articles', Number(id))
|
||||
.then((a) => {
|
||||
if (!cancelled) setFetched(a)
|
||||
})
|
||||
.catch(() => {
|
||||
/* مقاله وجود ندارد — فرم «مقاله جدید» میماند */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, existing])
|
||||
|
||||
const article = existing ?? fetched
|
||||
|
||||
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساختهشده یکبار مقداردهی میشود
|
||||
useEffect(() => {
|
||||
if (!article || createdRef.current) return
|
||||
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
|
||||
setDraft({
|
||||
...(article as unknown as Draft),
|
||||
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
|
||||
// بدنه ذخیرهشده (سند جدید یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
body: toArticleBodyDoc(article.body),
|
||||
body_en:
|
||||
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
|
||||
? (rawEnBody as ArticleBodyDoc)
|
||||
: EMPTY_DOC,
|
||||
})
|
||||
setCreatedId(article.id)
|
||||
// بدنه ویرایشگر با محتوای بارگذاریشده همگام میشود
|
||||
setLoadedContentKey(article.id)
|
||||
// فرم متادیتا هم با مقادیر مقاله بارگذاریشده همگام میشود
|
||||
reset({
|
||||
title: article.title ?? '',
|
||||
category: toCategoryOption(article.category ?? ''),
|
||||
image_alt: article.image_alt ?? '',
|
||||
image_title: article.image_title ?? '',
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [article?.id])
|
||||
|
||||
const doSave = async (changes: Record<string, unknown>) => {
|
||||
if (createdRef.current) {
|
||||
await adminApi.patch('articles', createdRef.current, changes)
|
||||
updateSite((d) => {
|
||||
const a = d.articles.find((x) => x.id === createdRef.current)
|
||||
if (a) Object.assign(a, changes)
|
||||
})
|
||||
} else {
|
||||
// اولین ذخیره = ساخت پیشنویس: مقاله ساخته میشود اما تا «انتشار» در سایت عمومی دیده نمیشود
|
||||
const payload = { ...draftRef.current, ...changes }
|
||||
if (!payload.title) payload.title = 'بدون عنوان'
|
||||
const created = await adminApi.create<Draft & { id: number }>('articles', payload)
|
||||
setCreatedId(created.id)
|
||||
updateSite((d) => {
|
||||
d.articles.unshift(created as never)
|
||||
})
|
||||
navigate(`/admin/write/${created.id}`, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
const changes = pendingRef.current
|
||||
if (!changes) return
|
||||
pendingRef.current = null
|
||||
await doSave(changes)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const queueSave = (changes: Record<string, unknown>) => {
|
||||
pendingRef.current = { ...(pendingRef.current ?? {}), ...changes }
|
||||
setDraft((d) => ({ ...d, ...changes }) as Draft)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => void flush(), 800)
|
||||
}
|
||||
|
||||
// ذخیره تغییرات در انتظار هنگام ترک صفحه — فقط برای مقاله ساختهشده
|
||||
// (create در unmount ممنوع: با draft قدیمی، مقاله سرگردانی میسازد)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
const changes = pendingRef.current
|
||||
if (!changes || !createdRef.current) return
|
||||
void adminApi.patch('articles', createdRef.current, changes).catch(() => {
|
||||
/* صفحه در حال بستن است */
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const forceSave = () => void runSave(flush)
|
||||
|
||||
// محافظ مسیر — پس از همه هوکها تا ترتیب فراخوانی هوکها همیشه یکسان بماند
|
||||
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
|
||||
|
||||
const remove = async () => {
|
||||
if (!createdId) return
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'حذف مقاله',
|
||||
text: 'این مقاله برای همیشه حذف شود؟',
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'حذف',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||
},
|
||||
})
|
||||
if (!result.isConfirmed) return
|
||||
setDeleting(true)
|
||||
await runSave(async () => {
|
||||
await adminApi.remove('articles', createdId)
|
||||
navigate('/blog')
|
||||
})
|
||||
setDeleting(false)
|
||||
}
|
||||
|
||||
const textField = (name: keyof Draft) => ({
|
||||
value: String(draft[name] ?? ''),
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
queueSave({ [name]: e.target.value }),
|
||||
})
|
||||
|
||||
const metaLength = (draft.meta_description ?? '').trim().length
|
||||
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
||||
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
||||
|
||||
// متن جایگزین تصویر شاخص — با watch('title') بهصورت زنده ساخته میشود
|
||||
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||||
{/* نوار بالا */}
|
||||
<header className="sticky top-0 z-40 border-b border-ivory/10 bg-ink/95 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-3">
|
||||
<Link
|
||||
to="/blog"
|
||||
data-admin-link
|
||||
className="flex items-center gap-2 text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
بازگشت به وبلاگ
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleEditLang}
|
||||
title="تعیین میکند متن مقاله روی کدام زبان ذخیره شود"
|
||||
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
||||
editLang === 'en'
|
||||
? 'bg-copper text-char'
|
||||
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{editLang === 'fa' ? 'ویرایش: فارسی' : 'Edit: English'}
|
||||
</button>
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
{saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />}
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={forceSave}
|
||||
className="border border-copper/50 px-5 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||
>
|
||||
ذخیره
|
||||
</button>
|
||||
<span
|
||||
className={`border px-3 py-2 text-xs font-light ${
|
||||
draft.status === 'draft'
|
||||
? 'border-amber-400/50 text-amber-300'
|
||||
: 'border-emerald-400/50 text-emerald-300'
|
||||
}`}
|
||||
>
|
||||
{draft.status === 'draft' ? 'پیشنویس' : 'منتشر شده'}
|
||||
</span>
|
||||
{createdId && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
|
||||
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
||||
draft.status === 'draft'
|
||||
? 'bg-copper text-char hover:scale-105'
|
||||
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||
</button>
|
||||
<Link
|
||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
||||
data-admin-link
|
||||
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
دیدن در سایت
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
disabled={deleting}
|
||||
aria-label="حذف مقاله"
|
||||
className="flex h-8 w-8 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-4xl px-6 pb-32 pt-10">
|
||||
<p className="text-xs font-light text-copper">
|
||||
{!createdId
|
||||
? 'نوشتن مقاله جدید — بهصورت پیشنویس ذخیره میشود؛ با دکمه «انتشار در سایت» عمومی میشود'
|
||||
: draft.status === 'draft'
|
||||
? 'پیشنویس — تا انتشار در سایت عمومی دیده نمیشود؛ تغییرها ذخیره میشوند'
|
||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||
</p>
|
||||
|
||||
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
|
||||
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره میشود */}
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
type="text"
|
||||
{...field}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value)
|
||||
queueSave({ [suf('title')]: event.target.value })
|
||||
}}
|
||||
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
|
||||
aria-label="عنوان مقاله"
|
||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-4 h-px w-28 bg-copper" />
|
||||
|
||||
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصلاند) */}
|
||||
<div className="mt-8">
|
||||
<ImageField
|
||||
label="تصویر شاخص مقاله"
|
||||
value={draft.image}
|
||||
alt={imageAltField.value}
|
||||
altFallback={imageAltFallback}
|
||||
title={imageTitleField.value}
|
||||
onSave={async (url) => {
|
||||
queueSave({ image: url })
|
||||
return true
|
||||
}}
|
||||
onSaveAlt={async (alt) => {
|
||||
imageAltField.onChange(alt)
|
||||
queueSave({ image_alt: alt })
|
||||
return true
|
||||
}}
|
||||
onSaveTitle={async (imageTitle) => {
|
||||
imageTitleField.onChange(imageTitle)
|
||||
queueSave({ image_title: imageTitle })
|
||||
return true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
contentKey={loadedContentKey}
|
||||
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
||||
onChange={(json) => {
|
||||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود — به زبان جاری
|
||||
const minutes = formatReadingTime(json)
|
||||
if (editLang === 'en') {
|
||||
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
|
||||
} else {
|
||||
queueSave({ body: json, read: `${minutes} دقیقه` })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* توضیحات متای سئو */}
|
||||
<section className="mt-8 border border-ivory/15 bg-char p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-bold text-copper">توضیحات سئو (Meta Description)</h2>
|
||||
<span
|
||||
className={`text-[11px] font-light ${
|
||||
metaLength >= 150 && metaLength <= 160 ? 'text-emerald-400' : 'text-ivory/40'
|
||||
}`}
|
||||
>
|
||||
{metaLength.toLocaleString('fa-IR')} از ۱۶۰ نویسه
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-light text-ivory/50">
|
||||
این همان متنی است که در صفحه نتایج گوگل زیر لینک مقاله به کاربران نمایش داده میشود (۱۵۰ تا ۱۶۰ نویسه).
|
||||
</p>
|
||||
<textarea
|
||||
id="meta-description"
|
||||
rows={3}
|
||||
value={draft.meta_description ?? ''}
|
||||
onChange={(e) => queueSave({ meta_description: e.target.value })}
|
||||
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
|
||||
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* چکیده و متادیتا */}
|
||||
<SeoChecklist
|
||||
input={{
|
||||
title: draft.title,
|
||||
slug: draft.slug,
|
||||
excerpt: draft.meta_description || draft.excerpt,
|
||||
body: draft.body,
|
||||
keyphrase: draft.seo_keyphrase,
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
|
||||
<h2 className="text-sm font-bold text-copper">جزئیات انتشار، دستهبندی و هشتگها</h2>
|
||||
<div className="mt-5 grid gap-5">
|
||||
<div>
|
||||
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمیشود)</label>
|
||||
<input
|
||||
id="seo-keyphrase"
|
||||
type="text"
|
||||
value={draft.seo_keyphrase}
|
||||
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
{...textField(suf('excerpt'))}
|
||||
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارتهای وبلاگ…'}
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="article-author" className="text-xs font-light text-ivory/50">نویسنده</label>
|
||||
<input
|
||||
id="article-author"
|
||||
type="text"
|
||||
{...textField('author')}
|
||||
placeholder="نام و نام خانوادگی نویسنده"
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">دستهبندی مقاله (Category)</label>
|
||||
<div className="mt-2">
|
||||
{/* تکانتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
|
||||
<Controller
|
||||
name="category"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CategorySelect
|
||||
value={field.value}
|
||||
options={ARTICLE_CATEGORIES}
|
||||
onChange={(option) => {
|
||||
field.onChange(option)
|
||||
queueSave({ category: categoryValue(option) })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">هشتگها / برچسبها</label>
|
||||
<div className="mt-2">
|
||||
<LuxeTagsSelect
|
||||
value={normalizeTags(draft.tags)}
|
||||
onChange={(tags) => queueSave({ tags })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="article-date" className="text-xs font-light text-ivory/50">تاریخ انتشار (شمسی)</label>
|
||||
<div className="mt-2">
|
||||
<JalaliDatePicker
|
||||
id="article-date"
|
||||
value={draft.date ?? ''}
|
||||
onChange={(isoDate) => queueSave({ date: isoDate })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled={editLang !== 'en'}
|
||||
{...(editLang === 'en' ? textField('read_en') : { value: String(draft.read ?? '') })}
|
||||
className={`mt-2 w-full text-sm font-light ${
|
||||
editLang === 'en'
|
||||
? 'border border-ivory/15 bg-char px-3 py-2 text-ivory focus:border-copper focus:outline-none'
|
||||
: 'cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-ivory/50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">نشانی (slug)</label>
|
||||
<input
|
||||
type="text"
|
||||
dir="ltr"
|
||||
{...textField('slug')}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import type { SiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { ENTITIES } from './entities'
|
||||
import { EntityDrawer } from './EntityDrawer'
|
||||
|
||||
type ItemWithId = Record<string, unknown> & { id: number }
|
||||
|
||||
function useEntityList(resource: string): ItemWithId[] {
|
||||
const { data } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
|
||||
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
onClick,
|
||||
label,
|
||||
children,
|
||||
danger = false,
|
||||
}: {
|
||||
onClick: () => void
|
||||
label: string
|
||||
children: ReactNode
|
||||
danger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
className={`flex h-7 w-7 items-center justify-center border backdrop-blur-sm transition-colors duration-150 ${
|
||||
danger
|
||||
? 'border-red-400/40 bg-ink/90 text-red-300 hover:bg-red-500/20'
|
||||
: 'border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* بستهبندی آیتمهای مجموعهها — فقط در حالت ویرایش ادمین، دکمههای شناور
|
||||
* (ویرایش / بالا / پایین / حذف) روی آیتم نشان میدهد؛ در حالت عادی خروجی یکسان است.
|
||||
*/
|
||||
export function EditableItem({
|
||||
resource,
|
||||
item,
|
||||
children,
|
||||
className = '',
|
||||
editHref,
|
||||
}: {
|
||||
resource: string
|
||||
item: ItemWithId
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت میکند (مثل صفحه نوشتن مقاله) */
|
||||
editHref?: string
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
const list = useEntityList(resource)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
if (!config) return <>{children}</>
|
||||
if (!isAuthenticated || !isEditMode) return <>{children}</>
|
||||
|
||||
const index = list.findIndex((x) => x.id === item.id)
|
||||
|
||||
const move = (delta: number) => {
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= list.length) return
|
||||
const ids = list.map((x) => x.id)
|
||||
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
||||
void runSave(async () => {
|
||||
await adminApi.reorder(config.resource, ids)
|
||||
reload()
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'حذف رکورد',
|
||||
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'حذف',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||
},
|
||||
})
|
||||
if (!result.isConfirmed) return
|
||||
void runSave(async () => {
|
||||
await adminApi.remove(config.resource, item.id)
|
||||
reload()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative group/editable ${className}`}>
|
||||
<div className="absolute left-2 top-2 z-30 flex flex-col gap-1.5 opacity-0 transition-opacity duration-150 group-hover/editable:opacity-100">
|
||||
{editHref ? (
|
||||
<Link
|
||||
to={editHref}
|
||||
data-admin-link
|
||||
title="ویرایش"
|
||||
aria-label="ویرایش"
|
||||
className="flex h-7 w-7 items-center justify-center border border-copper/50 bg-ink/90 text-copper backdrop-blur-sm transition-colors duration-150 hover:bg-copper hover:text-char"
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
) : (
|
||||
<PillButton onClick={() => setDrawerOpen(true)} label="ویرایش">
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
)}
|
||||
{index > 0 && (
|
||||
<PillButton onClick={() => move(-1)} label="انتقال به قبل">
|
||||
<ArrowUpIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
)}
|
||||
{index < list.length - 1 && (
|
||||
<PillButton onClick={() => move(1)} label="انتقال به بعد">
|
||||
<ArrowDownIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
)}
|
||||
<PillButton onClick={remove} label="حذف" danger>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
{drawerOpen && (
|
||||
<EntityDrawer config={config} item={item} deletable onClose={() => setDrawerOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** دکمه افزودن آیتم جدید به یک مجموعه — فقط در حالت ویرایش */
|
||||
export function AddItemButton({
|
||||
resource,
|
||||
label,
|
||||
variant = 'cell',
|
||||
}: {
|
||||
resource: string
|
||||
label?: string
|
||||
variant?: 'cell' | 'button'
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const { updateSite } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
|
||||
|
||||
if (!config || !isAuthenticated || !isEditMode) return null
|
||||
|
||||
const create = () => {
|
||||
void runSave(async () => {
|
||||
const created = await adminApi.create<ItemWithId>(config.resource, config.defaults)
|
||||
// درج خوشبینانه — reload کل درخت را بازسازی میکند و کشوی تازهساختهشده میمیرد
|
||||
updateSite((d) => {
|
||||
const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[]
|
||||
if (Array.isArray(list)) list.push(created)
|
||||
})
|
||||
setNewItem(created)
|
||||
})
|
||||
}
|
||||
|
||||
const drawer = newItem ? (
|
||||
<EntityDrawer config={config} item={newItem} deletable onClose={() => setNewItem(null)} />
|
||||
) : null
|
||||
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={create}
|
||||
className="flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{label ?? `افزودن ${config.title}`}
|
||||
</button>
|
||||
{drawer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={create}
|
||||
className="flex min-h-40 w-full flex-col items-center justify-center gap-2 border border-dashed border-copper/40 bg-copper/5 text-copper transition-colors duration-200 hover:bg-copper/10"
|
||||
>
|
||||
<PlusIcon className="h-6 w-6" />
|
||||
<span className="text-sm font-light">افزودن {config.title}</span>
|
||||
</button>
|
||||
|
||||
{drawer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش.
|
||||
* اگر رکورد هنوز در دیتابیس ساخته نشده باشد و createDefaults داده شود، دکمه + آن را میسازد. */
|
||||
export function SingletonPill({
|
||||
resource,
|
||||
label,
|
||||
className = '',
|
||||
select,
|
||||
createDefaults,
|
||||
}: {
|
||||
resource: string
|
||||
label: string
|
||||
className?: string
|
||||
/** انتخاب رکورد از درخت داده — برای مجموعههایی که یک رکورد خاص اینجاست (مثل سئو) */
|
||||
select?: (data: NonNullable<ReturnType<typeof useSiteData>['data']>) => ItemWithId | undefined
|
||||
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
|
||||
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const config = ENTITIES[resource]
|
||||
const { data, updateSite } = useSiteData()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [created, setCreated] = useState<ItemWithId | null>(null)
|
||||
|
||||
if (!config || !isAuthenticated || !isEditMode) return null
|
||||
const item =
|
||||
created ??
|
||||
(select
|
||||
? select(data!)
|
||||
: (data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined))
|
||||
if (!item && !createDefaults) return null
|
||||
|
||||
const create = () => {
|
||||
const defaults =
|
||||
typeof createDefaults === 'function' ? createDefaults() : (createDefaults ?? {})
|
||||
void runSave(async () => {
|
||||
const rec = await adminApi.create<ItemWithId>(config.resource, defaults)
|
||||
// درج خوشبینانه در درخت داده — دفعه بعد از داده انتخاب میشود
|
||||
updateSite((d) => {
|
||||
const list = d[config.dataKey as keyof NonNullable<typeof d>] as unknown as ItemWithId[]
|
||||
if (Array.isArray(list)) list.push(rec)
|
||||
})
|
||||
setCreated(rec)
|
||||
setDrawerOpen(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={className}>
|
||||
{item ? (
|
||||
<PillButton onClick={() => setDrawerOpen(true)} label={label}>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
) : (
|
||||
<PillButton onClick={create} label={`ساخت — ${label}`}>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
</PillButton>
|
||||
)}
|
||||
</span>
|
||||
{drawerOpen && item && (
|
||||
<EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { editableFields } from './entities'
|
||||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||
import type { EntityConfig, FieldConfig } from './entities'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
|
||||
type EntityDrawerProps = {
|
||||
config: EntityConfig
|
||||
/** رکورد یگانه یا آیتم مجموعه — باید id داشته باشد */
|
||||
item: Record<string, unknown> & { id: number }
|
||||
onClose: () => void
|
||||
/** فقط برای مجموعهها: حذف آیتم */
|
||||
deletable?: boolean
|
||||
}
|
||||
|
||||
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
|
||||
const save = useFieldSaver()
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const flushRef = useRef<() => void>(() => {})
|
||||
|
||||
// در ویرایش انگلیسی فقط فیلدهای قابل ترجمه با نام انگلیسی رندر میشوند؛
|
||||
// متن فارسی همان فیلد بهعنوان جاینگهدار نشان داده میشود تا مترجم زمینه را ببیند
|
||||
const fields = editableFields(config, editLang)
|
||||
const faBase = (name: string) => name.replace(/_en$/, '')
|
||||
|
||||
const closeWithFlush = () => {
|
||||
flushRef.current()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const saveField = (name: string, value: unknown) =>
|
||||
save(config.resource, item.id, { [name]: value }, (d) => {
|
||||
const target = d[config.dataKey as keyof typeof d] as unknown
|
||||
const node =
|
||||
config.kind === 'singleton'
|
||||
? (target as Record<string, unknown>)
|
||||
: (target as { id: number }[]).find((x) => x.id === item.id)
|
||||
if (node) (node as Record<string, unknown>)[name] = value
|
||||
})
|
||||
|
||||
const remove = async () => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'حذف رکورد',
|
||||
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'حذف',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||
},
|
||||
})
|
||||
if (!result.isConfirmed) return
|
||||
setDeleting(true)
|
||||
await runSave(async () => {
|
||||
await adminApi.remove(config.resource, item.id)
|
||||
reload()
|
||||
onClose()
|
||||
})
|
||||
setDeleting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="backdrop"
|
||||
className="fixed inset-0 z-[95] bg-char/70 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={closeWithFlush}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<motion.aside
|
||||
key="drawer"
|
||||
dir="rtl"
|
||||
role="dialog"
|
||||
aria-label={`ویرایش ${config.title}`}
|
||||
className={`fixed inset-y-0 left-0 z-[96] flex w-full flex-col border-e border-ivory/10 bg-ink shadow-2xl ${
|
||||
config.resource === 'articles' ? 'sm:max-w-3xl' : 'sm:max-w-md'
|
||||
}`}
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-ivory/10 px-6 py-4">
|
||||
<div>
|
||||
<p className="text-xs font-light text-copper">
|
||||
{config.kind === 'singleton' ? 'تنظیمات بخش' : 'ویرایش رکورد'}
|
||||
</p>
|
||||
<h2 className="mt-1 text-lg font-bold text-ivory">{config.title}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeWithFlush}
|
||||
aria-label="بستن"
|
||||
className="flex h-9 w-9 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="grid gap-6">
|
||||
{fields.map((field) => {
|
||||
const isImage = field.type === 'image'
|
||||
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
||||
const altKey = isImage
|
||||
? field.name === 'src' && 'alt' in item
|
||||
? 'alt'
|
||||
: `${field.name}_alt`
|
||||
: ''
|
||||
const imageTitleKey = isImage ? `${field.name}_title` : ''
|
||||
// پیشنهاد Alt از عنوان رکورد ساخته میشود (هرگز از URL)
|
||||
const imageAltFallback = [
|
||||
String(item[config.titleKey] ?? '').trim(),
|
||||
field.altFallbackSuffix,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
return (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
|
||||
altValue={altKey ? String(item[altKey] ?? '') : undefined}
|
||||
altFallback={imageAltFallback}
|
||||
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
|
||||
onSaveImageTitle={
|
||||
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
|
||||
}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
? (fn) => {
|
||||
flushRef.current = fn
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deletable && (
|
||||
<footer className="border-t border-ivory/10 px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-2 border border-red-400/40 px-4 py-2 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
حذف این رکورد
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</motion.aside>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
altValue,
|
||||
altFallback,
|
||||
placeholder,
|
||||
imageTitleValue,
|
||||
onSave,
|
||||
onSaveAlt,
|
||||
onSaveImageTitle,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
/** مقدار Alt تصویر — فقط برای فیلدهای image */
|
||||
altValue?: string
|
||||
altFallback?: string
|
||||
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی بهعنوان جاینگهدار نمایش داده میشود */
|
||||
placeholder?: unknown
|
||||
/** مقدار Title تصویر — فقط برای فیلدهای image */
|
||||
imageTitleValue?: string
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||
onSaveImageTitle?: (title: string) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [text, setText] = useState<string>(String(value ?? ''))
|
||||
|
||||
// همگامسازی مقدار محلی با دادهای که از بیرون میآید (بارگذاری/بهروزرسانی خوشبینانه)
|
||||
useEffect(() => {
|
||||
setText(String(value ?? ''))
|
||||
}, [value])
|
||||
|
||||
const commitText = async () => {
|
||||
const next = field.type === 'number' ? Number(text) || 0 : text
|
||||
if (String(value ?? '') === String(next)) return
|
||||
await onSave(next)
|
||||
}
|
||||
|
||||
if (field.type === 'image') {
|
||||
return (
|
||||
<ImageField
|
||||
label={field.label}
|
||||
value={String(value ?? '')}
|
||||
alt={altValue}
|
||||
altFallback={altFallback}
|
||||
title={imageTitleValue}
|
||||
onSave={(url) => onSave(url)}
|
||||
onSaveAlt={onSaveAlt}
|
||||
onSaveTitle={onSaveImageTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
// مقدار نمایشی از وضعیت محلی میآید تا افزودن/حذف گزینه فوراً دیده شود
|
||||
const selectProps = {
|
||||
value: text,
|
||||
options: field.options ?? [],
|
||||
onChange: (next: string) => {
|
||||
setText(next)
|
||||
void onSave(next)
|
||||
},
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
{field.creatable ? (
|
||||
<LuxeCreatableSelect {...selectProps} />
|
||||
) : (
|
||||
<LuxeSelect {...selectProps} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'date') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
<JalaliDatePicker
|
||||
value={String(value ?? '')}
|
||||
onChange={(isoDate) => {
|
||||
setText(isoDate)
|
||||
void onSave(isoDate)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'wysiwyg') {
|
||||
return (
|
||||
<WysiwygField
|
||||
field={field}
|
||||
value={value}
|
||||
onSave={onSave}
|
||||
registerFlush={registerFlush}
|
||||
hint={placeholder ? 'خالی = بازدیدکننده متن فارسی را میبیند' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
return field.ui === 'tags' ? (
|
||||
<TagsField field={field} value={value} onSave={onSave} />
|
||||
) : (
|
||||
<ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را میبیند' : undefined} />
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
<textarea
|
||||
dir="auto"
|
||||
rows={4}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<input
|
||||
dir={field.type === 'number' ? 'ltr' : 'auto'}
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** فیلد هشتگها — react-select قابل ساخت (Creatable) با ذخیره فوری */
|
||||
function TagsField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
}) {
|
||||
const [tags, setTags] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||
|
||||
const update = (next: string[]) => {
|
||||
setTags(next)
|
||||
void onSave(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
<LuxeTagsSelect value={tags} onChange={update} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
||||
function ListField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
hint,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
hint?: string
|
||||
}) {
|
||||
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||
|
||||
const update = (next: string[]) => {
|
||||
setLines(next)
|
||||
void onSave(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
|
||||
<div className="mt-2 grid gap-2">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<textarea
|
||||
dir="auto"
|
||||
rows={line.length > 60 ? 3 : 1}
|
||||
value={line}
|
||||
onChange={(e) => {
|
||||
const next = [...lines]
|
||||
next[i] = e.target.value
|
||||
setLines(next)
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (line !== (value as string[])[i]) update(lines)
|
||||
}}
|
||||
className="flex-1 resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف خط"
|
||||
onClick={() => update(lines.filter((_, j) => j !== i))}
|
||||
className="mt-1 text-ivory/40 transition-colors hover:text-red-300"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update([...lines, ''])}
|
||||
className="flex w-fit items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1.5 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
افزودن خط
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/** فیلد WYSIWYG مقاله — ذخیره خودکار با تاخیر کوتاه و flush هنگام بستن کشو */
|
||||
function WysiwygField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
registerFlush,
|
||||
hint,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
hint?: string
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(
|
||||
// بدنه ذخیرهشده (سند جدید، رشته JSON یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
() => toArticleBodyDoc(value),
|
||||
)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pending = useRef<Record<string, unknown> | null>(null)
|
||||
|
||||
const flush = () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
if (pending.current) {
|
||||
const next = pending.current
|
||||
pending.current = null
|
||||
void onSave(next)
|
||||
}
|
||||
}
|
||||
|
||||
// ثبت آخرین flush در کشو + ذخیره تغییرات در انتظار هنگام unmount
|
||||
useEffect(() => {
|
||||
registerFlush?.(() => flush())
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
if (pending.current) {
|
||||
const next = pending.current
|
||||
pending.current = null
|
||||
void onSave(next)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
})
|
||||
|
||||
const handleChange = (json: Record<string, unknown>) => {
|
||||
setDoc(json)
|
||||
pending.current = json
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(flush, 800)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
|
||||
<p className="mt-1 text-[10px] font-light text-ivory/40">
|
||||
ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<ArticleEditor doc={doc} onChange={handleChange} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// لایه تماس ادمین — همه درخواستها با توکن 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 }),
|
||||
}),
|
||||
|
||||
// خروجی CSV — فایل تولیدی بکاند (با BOM برای Excel) مستقیم دانلود میشود
|
||||
downloadCsv: async (kind: 'messages' | 'subscribers'): Promise<void> => {
|
||||
const path = kind === 'messages' ? 'messages/export.csv' : 'subscribers/export.csv'
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_URL}/api/admin/${path}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new HttpError(res.status, 'دریافت خروجی CSV ناموفق بود')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const disposition = res.headers.get('Content-Disposition') ?? ''
|
||||
a.download = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `${kind}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { SiteData } from '../api'
|
||||
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
|
||||
|
||||
export type FieldConfig = {
|
||||
name: string
|
||||
label: string
|
||||
type: FieldType
|
||||
hint?: string
|
||||
/** فقط برای type === 'select' — گزینههای ثابت react-select */
|
||||
options?: string[]
|
||||
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
|
||||
creatable?: boolean
|
||||
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
||||
ui?: 'lines' | 'tags'
|
||||
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمیشوند */
|
||||
shared?: boolean
|
||||
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
|
||||
altFallbackSuffix?: string
|
||||
}
|
||||
|
||||
export type EntityConfig = {
|
||||
resource: string
|
||||
dataKey: string
|
||||
title: string
|
||||
kind: 'collection' | 'singleton'
|
||||
titleKey: string
|
||||
fields: FieldConfig[]
|
||||
defaults: Record<string, unknown>
|
||||
}
|
||||
|
||||
const F = (
|
||||
name: string,
|
||||
label: string,
|
||||
type: FieldType = 'text',
|
||||
hint?: string,
|
||||
sharedOrExtra?: boolean | Partial<FieldConfig>,
|
||||
): FieldConfig => ({
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
hint,
|
||||
...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null
|
||||
? sharedOrExtra
|
||||
: { shared: !!sharedOrExtra }),
|
||||
})
|
||||
|
||||
/** دستهبندیهای استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده میشوند */
|
||||
export const ARTICLE_CATEGORIES = [
|
||||
'معماری و ساخت',
|
||||
'سرمایهگذاری ملکی',
|
||||
'طراحی داخلی',
|
||||
'اخبار و اطلاعیهها',
|
||||
'یادداشت مدیریت',
|
||||
]
|
||||
|
||||
export const ENTITIES: Record<string, EntityConfig> = {
|
||||
services: {
|
||||
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape', true)],
|
||||
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
|
||||
},
|
||||
projects: {
|
||||
resource: 'projects', dataKey: 'projects', title: 'پروژهها', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
|
||||
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
|
||||
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
|
||||
],
|
||||
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
|
||||
},
|
||||
articles: {
|
||||
resource: 'articles', dataKey: 'articles', title: 'مقالات', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('title', 'عنوان'),
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود', true),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
|
||||
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
|
||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
|
||||
F('author', 'نویسنده', 'text', undefined, true),
|
||||
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
|
||||
F('read', 'زمان مطالعه (دقیقه)'),
|
||||
F('slug', 'نشانی (slug)', 'text', undefined, true),
|
||||
],
|
||||
defaults: {
|
||||
title: 'مقاله جدید',
|
||||
slug: `article-${Date.now()}`,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
tags: ['مدیریت پروژه', 'ساختوساز'],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
seo_keyphrase: '',
|
||||
body: { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||
},
|
||||
},
|
||||
testimonials: {
|
||||
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام'), F('role', 'سمت'), F('quote', 'متن نظر', 'textarea')],
|
||||
defaults: { name: 'نظر جدید', role: '', quote: '' },
|
||||
},
|
||||
faqs: {
|
||||
resource: 'faqs', dataKey: 'faqs', title: 'سوالات متداول', kind: 'collection',
|
||||
titleKey: 'question',
|
||||
fields: [F('question', 'سوال'), F('answer', 'پاسخ', 'textarea')],
|
||||
defaults: { question: 'سوال جدید', answer: '' },
|
||||
},
|
||||
team: {
|
||||
resource: 'team', dataKey: 'team', title: 'تیم', kind: 'collection',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام'), F('role', 'سمت'), F('bio', 'معرفی', 'textarea')],
|
||||
defaults: { name: 'عضو جدید', role: '', bio: '' },
|
||||
},
|
||||
timeline: {
|
||||
resource: 'timeline', dataKey: 'timeline', title: 'تایملاین', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image', undefined, true)],
|
||||
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
|
||||
},
|
||||
stats: {
|
||||
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number', undefined, true), F('suffix', 'پسوند (مثل + یا هزار)')],
|
||||
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
|
||||
},
|
||||
why_us: {
|
||||
resource: 'why-us', dataKey: 'why_us', title: 'چرا آرامش', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
|
||||
defaults: { title: 'مورد جدید', desc: '' },
|
||||
},
|
||||
investment_models: {
|
||||
resource: 'investment-models', dataKey: 'investment_models', title: 'مدلهای سرمایهگذاری', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('points', 'نکات (هر خط یک مورد)', 'list')],
|
||||
defaults: { title: 'مدل جدید', desc: '', points: [] },
|
||||
},
|
||||
process_steps: {
|
||||
resource: 'process-steps', dataKey: 'process_steps', title: 'مراحل فرآیند کار', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('step', 'شماره مرحله'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
|
||||
defaults: { step: '۰۱', title: 'مرحله جدید', desc: '' },
|
||||
},
|
||||
offices: {
|
||||
resource: 'offices', dataKey: 'offices', title: 'دفاتر', kind: 'collection',
|
||||
titleKey: 'city',
|
||||
fields: [F('city', 'شهر'), F('address', 'نشانی', 'textarea'), F('hours', 'ساعات کاری')],
|
||||
defaults: { city: 'شهر جدید', address: '', hours: '' },
|
||||
},
|
||||
gallery: {
|
||||
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
|
||||
titleKey: 'alt',
|
||||
fields: [F('src', 'تصویر', 'image', undefined, true), F('alt', 'متن جایگزین')],
|
||||
defaults: { src: '', alt: 'تصویر گالری' },
|
||||
},
|
||||
nav: {
|
||||
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
|
||||
defaults: { label: 'آیتم جدید', href: '/' },
|
||||
},
|
||||
seo_meta: {
|
||||
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||
titleKey: 'page_key',
|
||||
fields: [
|
||||
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact', true),
|
||||
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
|
||||
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
|
||||
],
|
||||
defaults: { page_key: '', title: '', description: '' },
|
||||
},
|
||||
brand: {
|
||||
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image', undefined, true), F('hero', 'تصویر هیرو', 'image', undefined, true), F('contact_bg', 'پسزمینه بخش تماس', 'image', undefined, true), F('phone', 'تلفن نمایشی', 'text', undefined, true), F('phone_href', 'لینک تلفن', 'text', undefined, true), F('email', 'ایمیل', 'text', undefined, true), F('address', 'نشانی', 'textarea')],
|
||||
defaults: {},
|
||||
},
|
||||
hero: {
|
||||
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||
defaults: {},
|
||||
},
|
||||
story: {
|
||||
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان بخش'), F('paragraphs', 'پاراگرافهای معرفی', 'list'), F('quote', 'نقلقول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقلقول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه', 'text', undefined, true)],
|
||||
defaults: {},
|
||||
},
|
||||
before_after: {
|
||||
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image', undefined, true), F('after', 'تصویر بعد', 'image', undefined, true), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||
defaults: {},
|
||||
},
|
||||
contact_section: {
|
||||
resource: 'contact-section', dataKey: 'contact_section', title: 'فرم تماس', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('label', 'برچسب بالا'), F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'),
|
||||
F('address_label', 'برچسب نشانی'), F('phone_label', 'برچسب تلفن'), F('email_label', 'برچسب ایمیل'),
|
||||
F('name_label', 'برچسب نام'), F('name_placeholder', 'جاینگهدار نام'),
|
||||
F('phone_field_label', 'برچسب تلفن (فرم)'), F('phone_placeholder', 'جاینگهدار تلفن'),
|
||||
F('note_label', 'برچسب توضیحات'), F('note_optional_label', 'برچسب اختیاری'), F('note_placeholder', 'جاینگهدار توضیحات'),
|
||||
F('submit_label', 'متن دکمه ارسال'), F('sending_label', 'متن در حال ارسال'),
|
||||
F('success_title', 'عنوان پیام موفقیت'), F('success_text', 'متن پیام موفقیت', 'textarea'), F('success_button_label', 'دکمه پیام موفقیت'),
|
||||
],
|
||||
defaults: {},
|
||||
},
|
||||
newsletter: {
|
||||
resource: 'newsletter', dataKey: 'newsletter', title: 'خبرنامه', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('placeholder', 'جاینگهدار ایمیل'), F('button_label', 'متن دکمه'), F('sending_label', 'متن در حال ارسال'), F('success_text', 'پیام موفقیت')],
|
||||
defaults: {},
|
||||
},
|
||||
footer: {
|
||||
resource: 'footer', dataKey: 'footer', title: 'فوتر', kind: 'singleton',
|
||||
titleKey: 'pages_heading',
|
||||
fields: [F('about_text', 'متن معرفی', 'textarea'), F('pages_heading', 'تیتر ستون صفحات'), F('services_heading', 'تیتر ستون خدمات'), F('contact_heading', 'تیتر ستون تماس'), F('copyright', 'متن کپیرایت'), F('tagline', 'شعار پایین')],
|
||||
defaults: {},
|
||||
},
|
||||
}
|
||||
|
||||
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
|
||||
export function isTranslatable(resource: string, name: string): boolean {
|
||||
if (name.endsWith('_en')) return false
|
||||
const config = ENTITIES[resource]
|
||||
return !!config?.fields.some((f) => f.name === name && !f.shared)
|
||||
}
|
||||
|
||||
/** فیلدهای قابل ویرایش یک منبع در زبان دادهشده — انگلیسی: بدون فیلدهای مشترک و با پسوند _en */
|
||||
export function editableFields(config: EntityConfig, lang: 'fa' | 'en'): FieldConfig[] {
|
||||
if (lang === 'fa') return config.fields
|
||||
return config.fields
|
||||
.filter((f) => !f.shared)
|
||||
.map((f) => ({ ...f, name: `${f.name}_en` }))
|
||||
}
|
||||
|
||||
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */
|
||||
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
|
||||
return data[config.dataKey as keyof SiteData] as never
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// لایهٔ نازک تحلیل رفتار (Umami) — اگر اسکریپت ردیاب بارگیری نشده باشد
|
||||
// (VITE_UMAMI_SRC خالی)، همهٔ فراخوانیها بیاثر و ارزاناند.
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: { track: (event?: string, data?: Record<string, unknown>) => void }
|
||||
}
|
||||
}
|
||||
|
||||
/** رخداد سفارشی — نام رخداد در داشبورد Umami نمایش داده میشود. */
|
||||
export function track(event: string, data?: Record<string, unknown>) {
|
||||
try {
|
||||
window.umami?.track(event, data)
|
||||
} catch {
|
||||
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
|
||||
}
|
||||
}
|
||||
+263
-12
@@ -2,11 +2,15 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Article, Project, Service } from './data/site'
|
||||
import { API_URL } from './config'
|
||||
import { getToken } from './admin/adminClient'
|
||||
import { localizeSite } from './i18n/localize'
|
||||
import { useLang } from './i18n/lang'
|
||||
|
||||
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
||||
export type BrandInfo = {
|
||||
id: number
|
||||
name: string
|
||||
name_en?: string
|
||||
logo: string
|
||||
hero: string
|
||||
contact_bg: string
|
||||
@@ -14,26 +18,186 @@ export type BrandInfo = {
|
||||
phone_href: string
|
||||
email: string
|
||||
address: string
|
||||
address_en?: string
|
||||
}
|
||||
|
||||
export type TestimonialItem = { id: number; name: string; role: string; quote: string }
|
||||
export type FaqItem = { id: number; question: string; answer: string }
|
||||
export type TeamMemberItem = { id: number; name: string; role: string; bio: string }
|
||||
export type TimelineItem = { id: number; year: string; title: string; desc: string; image: string }
|
||||
export type StatItem = { id: number; value: number; suffix: string; label: string }
|
||||
export type WhyUsItem = { id: number; title: string; desc: string }
|
||||
export type TestimonialItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; quote: string; quote_en?: string }
|
||||
export type FaqItem = { id: number; question: string; question_en?: string; answer: string; answer_en?: string }
|
||||
export type TeamMemberItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; bio: string; bio_en?: string }
|
||||
export type TimelineItem = { id: number; year: string; year_en?: string; title: string; title_en?: string; desc: string; desc_en?: string; image: string }
|
||||
export type StatItem = { id: number; value: number; suffix: string; suffix_en?: string; label: string; label_en?: string }
|
||||
export type WhyUsItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string }
|
||||
export type BeforeAfterInfo = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
before: string
|
||||
after: string
|
||||
before_label: string
|
||||
before_label_en?: string
|
||||
after_label: string
|
||||
after_label_en?: string
|
||||
}
|
||||
|
||||
export type NavItem = { id: number; label: string; label_en?: string; href: string }
|
||||
|
||||
export type HeroContent = {
|
||||
id: number
|
||||
kicker: string
|
||||
kicker_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
title_highlight: string
|
||||
title_highlight_en?: string
|
||||
subtitle: string
|
||||
subtitle_en?: string
|
||||
badge_value: string
|
||||
badge_value_en?: string
|
||||
badge_label: string
|
||||
badge_label_en?: string
|
||||
cta_primary_label: string
|
||||
cta_primary_label_en?: string
|
||||
cta_primary_href: string
|
||||
cta_secondary_label: string
|
||||
cta_secondary_label_en?: string
|
||||
cta_secondary_href: string
|
||||
scroll_label: string
|
||||
scroll_label_en?: string
|
||||
}
|
||||
|
||||
export type StoryContent = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
paragraphs: string[]
|
||||
paragraphs_en?: string[] | null
|
||||
quote: string
|
||||
quote_en?: string
|
||||
quote_author: string
|
||||
quote_author_en?: string
|
||||
stat1_value: string
|
||||
stat1_value_en?: string
|
||||
stat1_label: string
|
||||
stat1_label_en?: string
|
||||
stat2_value: string
|
||||
stat2_value_en?: string
|
||||
stat2_label: string
|
||||
stat2_label_en?: string
|
||||
link_label: string
|
||||
link_label_en?: string
|
||||
link_href: string
|
||||
bento: { src: string; alt: string }[]
|
||||
bento_en?: { src: string; alt: string }[] | null
|
||||
}
|
||||
|
||||
export type SectionHeadingContent = {
|
||||
id: number
|
||||
key: string
|
||||
label: string
|
||||
label_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
image: string
|
||||
cta_label: string
|
||||
cta_label_en?: string
|
||||
cta_href: string
|
||||
}
|
||||
|
||||
export type PageHeroContent = { id: number; key: string; title: string; title_en?: string; lead: string; lead_en?: string; image: string }
|
||||
|
||||
export type InvestmentModelItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string; points: string[]; points_en?: string[] | null }
|
||||
export type ProcessStepItem = { id: number; step: string; step_en?: string; title: string; title_en?: string; desc: string; desc_en?: string }
|
||||
export type OfficeItem = { id: number; city: string; city_en?: string; address: string; address_en?: string; hours: string; hours_en?: string }
|
||||
export type GalleryImageItem = { id: number; src: string; alt: string; alt_en?: string }
|
||||
export type StatusLabelItem = { id: number; key: string; label: string; label_en?: string }
|
||||
|
||||
export type ContactSectionContent = {
|
||||
id: number
|
||||
label: string
|
||||
label_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
address_label: string
|
||||
address_label_en?: string
|
||||
phone_label: string
|
||||
phone_label_en?: string
|
||||
email_label: string
|
||||
email_label_en?: string
|
||||
name_label: string
|
||||
name_label_en?: string
|
||||
name_placeholder: string
|
||||
name_placeholder_en?: string
|
||||
phone_field_label: string
|
||||
phone_field_label_en?: string
|
||||
phone_placeholder: string
|
||||
phone_placeholder_en?: string
|
||||
note_label: string
|
||||
note_label_en?: string
|
||||
note_optional_label: string
|
||||
note_optional_label_en?: string
|
||||
note_placeholder: string
|
||||
note_placeholder_en?: string
|
||||
submit_label: string
|
||||
submit_label_en?: string
|
||||
sending_label: string
|
||||
sending_label_en?: string
|
||||
success_title: string
|
||||
success_title_en?: string
|
||||
success_text: string
|
||||
success_text_en?: string
|
||||
success_button_label: string
|
||||
success_button_label_en?: string
|
||||
}
|
||||
|
||||
export type NewsletterContent = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
placeholder: string
|
||||
placeholder_en?: string
|
||||
button_label: string
|
||||
button_label_en?: string
|
||||
sending_label: string
|
||||
sending_label_en?: string
|
||||
success_text: string
|
||||
success_text_en?: string
|
||||
}
|
||||
|
||||
export type SocialItem = { label: string; href: string; icon: string }
|
||||
|
||||
export type FooterContent = {
|
||||
id: number
|
||||
about_text: string
|
||||
about_text_en?: string
|
||||
pages_heading: string
|
||||
pages_heading_en?: string
|
||||
services_heading: string
|
||||
services_heading_en?: string
|
||||
contact_heading: string
|
||||
contact_heading_en?: string
|
||||
copyright: string
|
||||
copyright_en?: string
|
||||
tagline: string
|
||||
tagline_en?: string
|
||||
socials: SocialItem[]
|
||||
socials_en?: SocialItem[] | null
|
||||
}
|
||||
|
||||
export interface SiteData {
|
||||
brand: BrandInfo
|
||||
nav: NavItem[]
|
||||
hero: HeroContent
|
||||
story: StoryContent
|
||||
section_headings: SectionHeadingContent[]
|
||||
page_heroes: PageHeroContent[]
|
||||
services: Service[]
|
||||
projects: Project[]
|
||||
articles: Article[]
|
||||
@@ -44,15 +208,31 @@ export interface SiteData {
|
||||
stats: StatItem[]
|
||||
why_us: WhyUsItem[]
|
||||
before_after: BeforeAfterInfo
|
||||
investment_models: InvestmentModelItem[]
|
||||
process_steps: ProcessStepItem[]
|
||||
offices: OfficeItem[]
|
||||
gallery: GalleryImageItem[]
|
||||
status_labels: StatusLabelItem[]
|
||||
contact_section: ContactSectionContent
|
||||
newsletter: NewsletterContent
|
||||
footer: FooterContent
|
||||
seo_meta: { id: number; page_key: string; title: string; title_en?: string; description: string; description_en?: string }[]
|
||||
}
|
||||
|
||||
interface SiteContextValue {
|
||||
data: SiteData | null
|
||||
error: boolean
|
||||
reload: () => void
|
||||
/** بهروزرسانی خوشبینانه درخت داده — پس از موفقیت PATCH استفاده میشود */
|
||||
updateSite: (mutate: (data: SiteData) => void) => void
|
||||
}
|
||||
|
||||
const SiteContext = createContext<SiteContextValue>({ data: null, error: false, reload: () => {} })
|
||||
const SiteContext = createContext<SiteContextValue>({
|
||||
data: null,
|
||||
error: false,
|
||||
reload: () => {},
|
||||
updateSite: () => {},
|
||||
})
|
||||
|
||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const [data, setData] = useState<SiteData | null>(null)
|
||||
@@ -66,9 +246,27 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<SiteData>
|
||||
})
|
||||
.then((json) => {
|
||||
.then(async (json) => {
|
||||
if (cancelled) return
|
||||
// برای ادمین واردشده، پیشنویس مقالهها از انتهای مدیریتی خوانده و ادغام میشوند —
|
||||
// بکاند در GET /api/site فقط مقالههای منتشرشده را برمیگرداند
|
||||
if (getToken()) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const all = (await res.json()) as Article[]
|
||||
const published = new Set(json.articles.map((a) => a.id))
|
||||
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
||||
json.articles = [...json.articles, ...drafts]
|
||||
}
|
||||
} catch {
|
||||
/* بدون پیشنویسها ادامه بده */
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
if (json.brand && json.before_after) setData(json)
|
||||
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
||||
else setError(true)
|
||||
}
|
||||
})
|
||||
@@ -88,18 +286,71 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return <SiteContext.Provider value={{ data, error, reload }}>{children}</SiteContext.Provider>
|
||||
const updateSite = useCallback((mutate: (data: SiteData) => void) => {
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
const next = structuredClone(prev)
|
||||
mutate(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SiteContext.Provider value={{ data, error, reload, updateSite }}>
|
||||
{children}
|
||||
</SiteContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSiteData(): SiteContextValue {
|
||||
return useContext(SiteContext)
|
||||
}
|
||||
|
||||
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند
|
||||
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند.
|
||||
// خروجی بومیسازیشده است — فیلدهای انگلیسی در زبان انگلیسی جایگزین فارسی میشوند (با برگشت به فارسی).
|
||||
// داده خام برای ویرایش مدیریتی از useSiteData() خوانده میشود.
|
||||
export function useSite(): SiteData {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
if (!data) {
|
||||
throw new Error('useSite باید پس از بارگذاری دادههای سایت استفاده شود')
|
||||
}
|
||||
return data
|
||||
return localizeSite(data, lang)
|
||||
}
|
||||
|
||||
const EMPTY_HEADING: SectionHeadingContent = {
|
||||
id: 0,
|
||||
key: '',
|
||||
label: '',
|
||||
title: '',
|
||||
lead: '',
|
||||
image: '',
|
||||
cta_label: '',
|
||||
cta_href: '',
|
||||
}
|
||||
|
||||
/** تیتر و توضیح یک بخش بر اساس key تعریفشده در پنل مدیریت */
|
||||
export function useSectionHeading(key: string): SectionHeadingContent {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return localized?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
|
||||
}
|
||||
|
||||
/** هیرو بالای صفحه بر اساس key تعریفشده در پنل مدیریت */
|
||||
export function usePageHero(key: string): PageHeroContent {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return (
|
||||
localized?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
|
||||
)
|
||||
}
|
||||
|
||||
/** برچسب وضعیت پروژه (done | progress) به زبان جاری — از پنل مدیریت */
|
||||
export function useStatusLabel(key: string): string {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return localized?.status_labels.find((s) => s.key === key)?.label ?? key
|
||||
}
|
||||
|
||||
@@ -2,44 +2,52 @@ import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { formatJalali } from '../utils/date'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
export function ArticleCard({ article }: { article: Article }) {
|
||||
const { t, href } = useLang()
|
||||
return (
|
||||
<Link
|
||||
to={`/blog/${article.slug}`}
|
||||
className="group flex h-full flex-col border border-ivory/10 bg-char transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`خواندن مقاله ${article.title}`}
|
||||
to={href(`/blog/${article.slug}`)}
|
||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`${t('readArticlePrefix')} ${article.title}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="relative overflow-hidden">
|
||||
<img
|
||||
src={article.image}
|
||||
alt={`تصویر مقاله ${article.title}`}
|
||||
className="h-52 w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105 transform-gpu"
|
||||
alt={`${t('articleImagePrefix')} ${article.title}`}
|
||||
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||
<div className="absolute top-4 ltr:left-4 rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||
{article.category}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
<p className="text-xs font-light text-copper">{article.category}</p>
|
||||
|
||||
<h3 className="mt-4 text-lg font-bold leading-9 text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-4 mb-8 text-sm font-light leading-[2.2rem] text-ivory/55">
|
||||
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-4">
|
||||
<span>{article.date}</span>
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-3">
|
||||
<span>{formatJalali(article.date)}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
{article.read}
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||
{toPersianDigits(article.read)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-copper">
|
||||
ادامه مطلب
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 ease-out group-hover:-translate-x-1.5" />
|
||||
|
||||
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
|
||||
{t('readMore')}
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CalendarIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
type ArticleMetaProps = {
|
||||
article: Article
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* سربرگ نویسنده/تاریخ مقاله — زیر تیتر اصلی نمایش داده میشود:
|
||||
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
|
||||
*/
|
||||
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
|
||||
const rawDate = (article.date ?? '').trim()
|
||||
const author = (article.author ?? '').trim() || 'گروه ساختمانی آرامش'
|
||||
const initial = author.charAt(0)
|
||||
|
||||
// تاریخ ذخیرهشده (میلادی) به شمسی تبدیل میشود؛ در نبود تاریخ، امروز با ساعت نمایش داده میشود
|
||||
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
|
||||
const dateLabel = formatJalali(rawDate, todayLabel)
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
|
||||
{/* آواتار و نام نویسنده */}
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-lg font-bold text-copper shadow-[0_0_20px_rgba(213,135,76,0.25),inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
{initial}
|
||||
</span>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-bold text-ivory">{author}</span>
|
||||
<span className="text-[11px] font-light text-ivory/45">نویسنده</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="hidden h-9 w-px bg-ivory/10 sm:block" aria-hidden="true" />
|
||||
|
||||
{/* تاریخ جلالی */}
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
<CalendarIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||
{dateLabel}
|
||||
</span>
|
||||
|
||||
{/* زمان مطالعه — با ارقام فارسی */}
|
||||
{article.read && (
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||
{toPersianDigits(article.read)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,26 +9,33 @@ type BaseProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const STYLES: Record<Variant, { base: string; fill: string; text: string }> = {
|
||||
// استایلهای فوقلوکس شیشهای (Glassmorphic) با رنگهای اختصاصی پروژه
|
||||
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
|
||||
copper: {
|
||||
base: 'bg-copper text-char',
|
||||
fill: 'bg-char',
|
||||
text: 'group-hover:text-ivory',
|
||||
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
|
||||
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
|
||||
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
|
||||
text: 'group-hover:text-char font-semibold',
|
||||
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
|
||||
},
|
||||
outline: {
|
||||
base: 'border border-ivory/30 text-ivory',
|
||||
fill: 'bg-copper',
|
||||
text: 'group-hover:text-char',
|
||||
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبهها
|
||||
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
|
||||
fill: 'bg-white/10',
|
||||
text: 'group-hover:text-copper',
|
||||
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
|
||||
},
|
||||
dark: {
|
||||
base: 'bg-char text-ivory',
|
||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||
fill: 'bg-copper',
|
||||
text: 'group-hover:text-char',
|
||||
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
|
||||
},
|
||||
'dark-ivory': {
|
||||
base: 'bg-char text-ivory',
|
||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||
fill: 'bg-ivory',
|
||||
text: 'group-hover:text-char',
|
||||
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -36,12 +43,18 @@ function Inner({ children, variant = 'copper' }: { children: React.ReactNode; va
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<>
|
||||
{/* خط انعکاس نوری شیشه در بالای دکمه */}
|
||||
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
|
||||
|
||||
{/* انیمیشن پر شدن موجی و نرم */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 translate-y-full transition-transform duration-[600ms] ease-lux group-hover:translate-y-0 ${style.fill}`}
|
||||
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
|
||||
/>
|
||||
|
||||
{/* متن و آیکون دکمه */}
|
||||
<span
|
||||
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-[600ms] ease-lux ${style.text}`}
|
||||
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
@@ -49,8 +62,9 @@ function Inner({ children, variant = 'copper' }: { children: React.ReactNode; va
|
||||
)
|
||||
}
|
||||
|
||||
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشهای
|
||||
const SHELL =
|
||||
'group relative inline-flex items-center justify-center overflow-hidden px-8 py-4 text-sm font-bold focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink'
|
||||
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
|
||||
|
||||
export function ButtonLink({
|
||||
to,
|
||||
@@ -58,8 +72,9 @@ export function ButtonLink({
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
}: BaseProps & { to: string }) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<Link to={to} className={`${SHELL} ${STYLES[variant].base} ${className}`}>
|
||||
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</Link>
|
||||
)
|
||||
@@ -72,8 +87,9 @@ export function ButtonAnchor({
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<a href={href} className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
|
||||
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</a>
|
||||
)
|
||||
@@ -85,8 +101,9 @@ export function Button({
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<button className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
|
||||
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BotIcon, SendIcon, SparklesIcon, XIcon } from 'lucide-react';
|
||||
import { track } from '../analytics';
|
||||
|
||||
type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
error?: boolean;
|
||||
};
|
||||
|
||||
type SseFrame = {
|
||||
content?: string;
|
||||
error?: string;
|
||||
done?: boolean;
|
||||
};
|
||||
|
||||
/** شناسه گفتگو برای هر نشست — در sessionStorage میماند تا رفرش صفحه تاریخچه را نگه دارد */
|
||||
function getThreadId(): string {
|
||||
const KEY = 'chat_thread_id';
|
||||
let id = sessionStorage.getItem(KEY);
|
||||
if (!id) {
|
||||
id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
sessionStorage.setItem(KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** خواندن فریمهای SSE از پاسخ fetch (استریم توکنبهتوکن) */
|
||||
async function readSse(response: Response, onFrame: (frame: SseFrame) => void) {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('پاسخ استریمی ندارد');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
try {
|
||||
onFrame(JSON.parse(trimmed.slice(6)) as SseFrame);
|
||||
} catch {
|
||||
// فریم ناقص — نادیده بگیر
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatWidget() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const scrollEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const assistantIndexRef = useRef<number | null>(null);
|
||||
|
||||
// پیشنهاد پرسشها فقط یک بار برای هر بازشدن اول
|
||||
useEffect(() => {
|
||||
if (!open || suggestions.length > 0) return;
|
||||
fetch('/chat/suggestions')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((d) => setSuggestions((d.questions ?? []).slice(0, 4)))
|
||||
.catch(() => setSuggestions([]));
|
||||
}, [open, suggestions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}, [messages, open]);
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
const question = text.trim();
|
||||
if (!question || isLoading) return;
|
||||
track('chat-question')
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
setMessages((prev) => {
|
||||
const next: ChatMessage[] = [
|
||||
...prev,
|
||||
{ role: 'user', content: question },
|
||||
{ role: 'assistant', content: '', streaming: true },
|
||||
];
|
||||
assistantIndexRef.current = next.length - 1;
|
||||
return next;
|
||||
});
|
||||
|
||||
const patchAssistant = (patch: Partial<ChatMessage>) => {
|
||||
setMessages((prev) => {
|
||||
const idx = assistantIndexRef.current;
|
||||
if (idx === null || !prev[idx]) return prev;
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], ...patch };
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ message: question, thread_id: getThreadId() });
|
||||
const response = await fetch(`/chat/stream?${params.toString()}`, {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
let accumulated = '';
|
||||
await readSse(response, (frame) => {
|
||||
if (frame.error) {
|
||||
patchAssistant({ content: frame.error, streaming: false, error: true });
|
||||
return;
|
||||
}
|
||||
if (frame.content) {
|
||||
accumulated += frame.content;
|
||||
patchAssistant({ content: accumulated });
|
||||
}
|
||||
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
|
||||
});
|
||||
patchAssistant({ streaming: false });
|
||||
} catch (err) {
|
||||
patchAssistant({
|
||||
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
|
||||
streaming: false,
|
||||
error: true,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* دکمه شناور — گوشه راستپایین (دکمه تماس سمت چپ است) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen((v) => {
|
||||
if (!v) track('chat-open');
|
||||
return !v;
|
||||
});
|
||||
}}
|
||||
aria-label={open ? 'بستن گفتگو' : 'گفتگو با دستیار هوشمند'}
|
||||
className="group fixed bottom-8 right-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-ivory text-ink shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
|
||||
<span className="absolute inset-0 rounded-full border border-ivory/60 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
|
||||
{open ? <XIcon className="h-5 w-5" /> : <BotIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* پنل گفتگو */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed bottom-24 right-4 z-40 flex h-[min(560px,72vh)] w-[min(380px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-copper/30 bg-char shadow-lux sm:right-8">
|
||||
{/* سربرگ */}
|
||||
<div className="flex items-center gap-3 border-b border-ivory/10 bg-ink px-4 py-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper/15 text-copper">
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold text-ivory">دستیار هوشمند آرامش</p>
|
||||
<p className="text-[11px] font-light text-ivory/50">پاسخ از محتوای همین سایت</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="بستن"
|
||||
className="rounded-full p-1.5 text-ivory/50 transition-colors hover:bg-ivory/10 hover:text-ivory">
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* پیامها */}
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="mt-6 text-center">
|
||||
<BotIcon className="mx-auto h-8 w-8 text-copper/60" />
|
||||
<p className="mt-3 text-sm font-light leading-7 text-ivory/60">
|
||||
سلام! هر سؤالی درباره خدمات، پروژهها و مقالات
|
||||
<span className="font-bold text-ivory"> گروه ساختمانی آرامش </span>
|
||||
دارید بپرسید.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-[13px] leading-6 ${
|
||||
msg.role === 'user'
|
||||
? 'rounded-bl-md bg-copper text-char'
|
||||
: msg.error
|
||||
? 'rounded-br-md border border-red-400/30 bg-red-400/10 text-red-200'
|
||||
: 'rounded-br-md border border-ivory/10 bg-ink text-ivory'
|
||||
}`}>
|
||||
{msg.content || (msg.streaming ? '…' : '')}
|
||||
{msg.streaming && msg.content && (
|
||||
<span className="ms-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-copper align-middle" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={scrollEndRef} />
|
||||
</div>
|
||||
|
||||
{/* پیشنهاد پرسشها */}
|
||||
{suggestions.length > 0 && messages.length === 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 border-t border-ivory/10 px-3 py-2.5">
|
||||
{suggestions.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
type="button"
|
||||
onClick={() => sendMessage(q)}
|
||||
className="rounded-full border border-copper/30 px-2.5 py-1 text-[11px] text-copper transition-colors hover:bg-copper hover:text-char">
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ورودی */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendMessage(input);
|
||||
}}
|
||||
className="flex items-center gap-2 border-t border-ivory/10 bg-ink px-3 py-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="پرسش خود را بنویسید…"
|
||||
disabled={isLoading}
|
||||
className="flex-1 rounded-full border border-ivory/15 bg-char px-4 py-2 text-[13px] text-ivory placeholder:text-ivory/30 focus:border-copper/60 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
aria-label="ارسال"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-copper text-char transition-transform hover:scale-105 disabled:bg-ivory/15 disabled:text-ivory/40 disabled:hover:scale-100">
|
||||
<SendIcon className="h-4 w-4 -scale-x-100" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,34 @@
|
||||
import React, { useState } from 'react'
|
||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
|
||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from './Reveal'
|
||||
import { Button } from './Button'
|
||||
import { useSite } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { API_URL } from '../config'
|
||||
import { track } from '../analytics'
|
||||
|
||||
export function ContactSection() {
|
||||
const { brand } = useSite()
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||
const { brand, contact_section: content } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '', website: '' })
|
||||
const [sent, setSent] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
const saveContent = (field: string, value: string) =>
|
||||
save('contact-section', content.id, { [field]: value }, (d) => {
|
||||
;(d.contact_section as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const saveBrand = (field: string, value: string) =>
|
||||
save('brand', brand.id, { [field]: value }, (d) => {
|
||||
;(d.brand as unknown as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
const showErrorAlert = (title: string, text: string) => {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
@@ -20,14 +37,14 @@ export function ContactSection() {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonText: t('understood'),
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-[8px] bg-ink/60',
|
||||
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
popup: 'font-pinar border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl bg-char/95',
|
||||
title: 'font-pinar font-bold text-lg text-ivory pt-2',
|
||||
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
|
||||
confirmButton: 'mt-6 bg-copper text-char font-bold px-10 py-3 transition-transform duration-300 ease-lux hover:scale-105',
|
||||
confirmButton: 'mt-6 rounded-full bg-copper text-char font-bold px-10 py-3 transition-all duration-300 ease-lux hover:scale-105 hover:shadow-[0_0_20px_rgba(197,143,92,0.5)]',
|
||||
},
|
||||
showClass: {
|
||||
popup: 'swal2-show',
|
||||
@@ -36,7 +53,7 @@ export function ContactSection() {
|
||||
hideClass: {
|
||||
popup: 'swal2-hide',
|
||||
backdrop: 'swal2-backdrop-hide',
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,13 +65,13 @@ export function ContactSection() {
|
||||
const phoneValue = form.phone.trim()
|
||||
|
||||
if (nameValue.length < 3) {
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
|
||||
showErrorAlert(t('incompleteInfo'), t('nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
|
||||
if (!phoneValue || !phoneRegex.test(phoneValue)) {
|
||||
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
|
||||
showErrorAlert(t('phoneInvalidTitle'), t('phoneInvalidText'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,20 +80,21 @@ export function ContactSection() {
|
||||
const res = await fetch(`${API_URL}/api/contact`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim() }),
|
||||
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim(), website: form.website }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در ثبت درخواست', detail)
|
||||
typeof data?.detail === 'string' ? data.detail : t('formCheck')
|
||||
showErrorAlert(t('submitErrorTitle'), detail)
|
||||
return
|
||||
}
|
||||
|
||||
track('contact-submit')
|
||||
setSent(true)
|
||||
} catch {
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -90,53 +108,74 @@ export function ContactSection() {
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
|
||||
|
||||
<div className="relative mx-auto max-w-site px-6 lg:px-10">
|
||||
<div className="mx-auto grid max-w-6xl items-stretch gap-6 lg:grid-cols-12">
|
||||
|
||||
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[8px] md:p-10 lg:col-span-5">
|
||||
<div className="mx-auto grid max-w-6xl items-stretch gap-8 lg:grid-cols-12">
|
||||
|
||||
{/* باکس اطلاعات تماس */}
|
||||
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
|
||||
<Reveal>
|
||||
<p className="text-sm font-light text-copper">تماس با ما</p>
|
||||
<h2 id="contact-title" className="mt-5 text-section font-bold text-ivory">
|
||||
پروژهتان را با ما در میان بگذارید
|
||||
<p className="text-sm font-light tracking-wide text-copper">
|
||||
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
|
||||
</p>
|
||||
<h2 id="contact-title" className="mt-4 text-section font-bold text-ivory">
|
||||
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
|
||||
</h2>
|
||||
<p className="mt-6 text-base font-light leading-9 text-ivory/75">
|
||||
شمارهتان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس میگیرد.
|
||||
<p className="mt-5 text-base font-light leading-9 text-ivory/75">
|
||||
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<ul className="mt-10 space-y-6">
|
||||
<li className="flex items-start gap-4">
|
||||
<MapPinIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
<MapPinIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ivory">دفتر مرکزی</p>
|
||||
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">{brand.address}</p>
|
||||
<p className="text-sm font-bold text-ivory">
|
||||
<InlineEdit value={content.address_label} onSave={(v) => saveContent('address_label', v)} />
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">
|
||||
<InlineEdit value={brand.address} onSave={(v) => saveBrand('address', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-4">
|
||||
<PhoneIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
<PhoneIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ivory">تلفن</p>
|
||||
<p className="text-sm font-bold text-ivory">
|
||||
<InlineEdit value={content.phone_label} onSave={(v) => saveContent('phone_label', v)} />
|
||||
</p>
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
dir="ltr"
|
||||
onClick={() => track('phone-click', { from: 'contact-section' })}
|
||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
{brand.phone}
|
||||
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-4">
|
||||
<MailIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
<MailIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ivory">ایمیل</p>
|
||||
<p className="text-sm font-bold text-ivory">
|
||||
<InlineEdit value={content.email_label} onSave={(v) => saveContent('email_label', v)} />
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:${brand.email}`}
|
||||
dir="ltr"
|
||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
{brand.email}
|
||||
<InlineEdit value={brand.email} onSave={(v) => saveBrand('email', v)} />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
@@ -144,13 +183,19 @@ export function ContactSection() {
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[9px] md:p-10 lg:col-span-7">
|
||||
{/* باکس فرم ارسال درخواست */}
|
||||
<div className="relative rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-7">
|
||||
<SingletonPill resource="contact_section" label="ویرایش برچسبها و متنهای فرم" className="absolute left-4 top-4" />
|
||||
{sent ? (
|
||||
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
|
||||
<CheckCircle2Icon className="h-12 w-12 text-copper" />
|
||||
<h3 className="mt-6 text-xl font-bold text-ivory">درخواست شما ثبت شد</h3>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-copper shadow-[0_0_30px_rgba(197,143,92,0.4)]">
|
||||
<CheckCircle2Icon className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-bold text-ivory">
|
||||
<InlineEdit value={content.success_title} onSave={(v) => saveContent('success_title', v)} />
|
||||
</h3>
|
||||
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
|
||||
از اعتماد شما سپاسگزاریم. کارشناسان ما بهزودی با شما تماس میگیرند.
|
||||
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -161,28 +206,39 @@ 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">
|
||||
{/* honeypot — خارج از دید؛ فقط رباتها آن را پر میکنند */}
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
value={form.website}
|
||||
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="contact-name" className="block text-sm font-bold text-ivory/90">
|
||||
نام
|
||||
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="نام و نام خانوادگی"
|
||||
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
|
||||
placeholder={content.name_placeholder}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="contact-phone" className="block text-sm font-bold text-ivory/90">
|
||||
شماره تلفن <span className="text-copper">*</span>
|
||||
<label htmlFor="contact-phone" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.phone_field_label} onSave={(v) => saveContent('phone_field_label', v)} /> <span className="text-copper">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="contact-phone"
|
||||
@@ -191,28 +247,40 @@ export function ContactSection() {
|
||||
required
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder="09xx xxx xxxx"
|
||||
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-right text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
|
||||
placeholder={content.phone_placeholder}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light ltr:text-left rtl:text-right text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="contact-note" className="block text-sm font-bold text-ivory/90">
|
||||
توضیحات <span className="font-light text-ivory/50">(اختیاری)</span>
|
||||
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{content.note_optional_label}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-note"
|
||||
rows={6}
|
||||
rows={5}
|
||||
value={form.note}
|
||||
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||
placeholder="درباره پروژه یا درخواست خود بنویسید…"
|
||||
className="mt-3 w-full resize-none border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light leading-8 text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
|
||||
placeholder={content.note_placeholder}
|
||||
className="mt-3 w-full resize-none rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light leading-8 text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit" variant="copper" className="w-full md:w-auto md:px-12" disabled={sending}>
|
||||
{sending ? 'در حال ارسال…' : 'ارسال درخواست مشاوره'}
|
||||
<div className="md:col-span-2 flex justify-start">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="copper"
|
||||
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? (
|
||||
content.sending_label
|
||||
) : (
|
||||
<>
|
||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import React from 'react';
|
||||
import { PhoneIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { track } from '../analytics';
|
||||
|
||||
export function FloatingContact() {
|
||||
const { brand } = useSite();
|
||||
const { t } = useLang();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
aria-label="تماس تلفنی با گروه ساختمانی آرامش"
|
||||
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-copper text-char shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
|
||||
|
||||
<span className="absolute inset-0 rounded-full border border-copper/50 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
|
||||
<PhoneIcon className="h-5 w-5" />
|
||||
</a>);
|
||||
aria-label={t('callBrand')}
|
||||
onClick={() => track('phone-click', { from: 'floating' })}
|
||||
className="group fixed bottom-8 ltr:right-8 rtl:left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
|
||||
>
|
||||
{/* پالس هاله نوری در پسزمینه مانند رادار */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-full border border-copper/60 animate-ping opacity-30"
|
||||
/>
|
||||
|
||||
{/* آیکون تلفن با انیمیشن چرخش ظریف در زمان هاور */}
|
||||
<PhoneIcon className="relative z-10 h-6 w-6 transition-transform duration-300 ease-lux group-hover:rotate-12" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +1,111 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { NAV } from '../data/site';
|
||||
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from './admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
|
||||
|
||||
const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
instagram: InstagramIcon,
|
||||
linkedin: LinkedinIcon,
|
||||
telegram: SendIcon,
|
||||
};
|
||||
|
||||
export function Footer() {
|
||||
const { brand, services } = useSite();
|
||||
const { brand, services, nav, footer } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveFooter = (field: string, value: string) =>
|
||||
save('footer', footer.id, { [field]: value }, (d) => {
|
||||
;(d.footer as unknown as Record<string, unknown>)[field] = value;
|
||||
});
|
||||
|
||||
const saveNav = (id: number, changes: Record<string, unknown>) =>
|
||||
save('nav', id, changes, (d) => {
|
||||
const item = d.nav.find((n) => n.id === id);
|
||||
if (item) Object.assign(item, changes);
|
||||
});
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="border-t border-ivory/10 bg-char">
|
||||
<footer className="relative border-t border-white/10 bg-char/95 backdrop-blur-2xl">
|
||||
<div className="mx-auto max-w-site px-6 py-16 lg:px-10">
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
<div className="lg:col-span-4">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/55">
|
||||
گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمانهایی میسازد که قرار است چند نسل در آنها زندگی کنند؛ معماری آرام،
|
||||
اجرای دقیق و سرمایهگذاری شفاف.
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
||||
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
{[
|
||||
{ Icon: InstagramIcon, label: 'اینستاگرام' },
|
||||
{ Icon: LinkedinIcon, label: 'لینکدین' },
|
||||
{ Icon: SendIcon, label: 'تلگرام' }].
|
||||
map(({ Icon, label }) =>
|
||||
<a
|
||||
key={label}
|
||||
href="#"
|
||||
aria-label={label}
|
||||
className="flex h-10 w-10 items-center justify-center border border-ivory/15 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
||||
|
||||
<Icon className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
{/* آیکونهای شبکههای اجتماعی: گویهای شیشهای شناور */}
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
{footer.socials.map(({ label, href, icon }) => {
|
||||
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
||||
return (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
aria-label={label}
|
||||
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
|
||||
>
|
||||
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-sm font-bold text-copper">صفحات</h3>
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{NAV.map((item) =>
|
||||
<li key={item.to}>
|
||||
<Link
|
||||
to={item.to}
|
||||
className="text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper">
|
||||
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<Link
|
||||
to={href(item.href)}
|
||||
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4">
|
||||
<AddItemButton resource="nav" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">خدمات</h3>
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{services.map((service) =>
|
||||
<li key={service.title} className="text-sm font-light text-ivory/60">
|
||||
{services.map((service) => (
|
||||
<li key={service.id} className="text-sm font-light text-ivory/60">
|
||||
{service.title}
|
||||
</li>
|
||||
)}
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">ارتباط با ما</h3>
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
|
||||
<li className="flex items-start gap-3">
|
||||
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
||||
@@ -84,11 +127,28 @@ export function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-ivory/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||
<p>© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.</p>
|
||||
<p>طراحی و ساخت با دقتِ معماری</p>
|
||||
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
|
||||
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||
<p>
|
||||
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>{t('backToTop')}</span>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p>
|
||||
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>);
|
||||
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
||||
import { NAV } from "../data/site";
|
||||
import { useSite } from "../api";
|
||||
import { use } from "framer-motion/m";
|
||||
import { useLang } from "../i18n/lang";
|
||||
import { useFieldSaver } from "../admin/AdminContext";
|
||||
import { InlineEdit } from "./admin/InlineEdit";
|
||||
import { EditableItem, SingletonPill } from "../admin/Editable";
|
||||
import { LangToggle } from "./LangToggle";
|
||||
|
||||
export function Header() {
|
||||
const { brand } = useSite();
|
||||
const { brand, nav } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveNav = (id: number, changes: Record<string, unknown>) =>
|
||||
save("nav", id, changes, (d) => {
|
||||
const item = d.nav.find((n) => n.id === id);
|
||||
if (item) Object.assign(item, changes);
|
||||
});
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { pathname } = useLocation();
|
||||
@@ -23,76 +34,86 @@ export function Header() {
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed inset-x-0 top-0 z-50 transition-[background-color,border-color,backdrop-filter] duration-300 ease-lux ${
|
||||
className={`fixed inset-x-0 top-0 z-50 transition-all duration-300 ease-lux ${
|
||||
scrolled
|
||||
? "border-b border-ivory/10 bg-ink/90 backdrop-blur-md"
|
||||
? "border-b border-white/10 bg-ink/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.5)]"
|
||||
: "border-b border-transparent bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-3"
|
||||
aria-label={brand.name}
|
||||
>
|
||||
<img
|
||||
src={brand.logo}
|
||||
alt={brand.name}
|
||||
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
|
||||
scrolled ? "h-14" : "h-20"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
<div className="relative flex items-center">
|
||||
<Link
|
||||
to={href("/")}
|
||||
className="flex items-center gap-3"
|
||||
aria-label={brand.name}
|
||||
>
|
||||
<img
|
||||
src={brand.logo}
|
||||
alt={brand.name}
|
||||
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
|
||||
scrolled ? "h-14" : "h-20"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
|
||||
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
|
||||
<nav aria-label={t("mainNav")} className="hidden xl:block">
|
||||
<ul className="flex items-center gap-7">
|
||||
{NAV.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{item.label}
|
||||
<span
|
||||
className={`absolute -bottom-0.5 left-0 right-0 h-px w-full origin-center bg-copper transition-transform duration-300 ease-lux ${
|
||||
isActive
|
||||
? "scale-x-100"
|
||||
: "scale-x-0 group-hover:scale-x-100"
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={href(item.href)}
|
||||
className={({ isActive }) =>
|
||||
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
|
||||
isActive ? "text-copper font-normal" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 left-0 right-0 h-0.5 w-full origin-center rounded-full bg-copper transition-transform duration-300 ease-lux ${
|
||||
isActive
|
||||
? "scale-x-100 shadow-[0_0_8px_rgba(197,143,92,0.8)]"
|
||||
: "scale-x-0 group-hover:scale-x-100"
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* دکمه تماس کپسولی شیشهای لوکس */}
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
className="group relative hidden items-center gap-2 overflow-hidden border border-copper/50 px-5 py-2.5 text-sm font-medium text-copper [text-shadow:_0_1px_2px_rgba(0,0,0,0.5)] lg:flex"
|
||||
className="group relative hidden items-center gap-3 rounded-full border border-copper/40 bg-white/[0.04] px-5 py-2 text-sm font-medium text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_20px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_25px_rgba(197,143,92,0.35)] active:scale-95 lg:flex"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-lux group-hover:translate-y-0"
|
||||
/>
|
||||
<span className="relative z-10 flex items-center gap-2 transition-colors duration-300 ease-lux group-hover:text-char">
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
<span dir="ltr">{brand.phone}</span>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-copper/20 text-copper transition-all duration-300 group-hover:bg-copper group-hover:text-char">
|
||||
<PhoneIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
||||
</span>
|
||||
<span dir="ltr" className="tracking-wide">{brand.phone}</span>
|
||||
</a>
|
||||
|
||||
{/* تغییر زبان — فارسی/انگلیسی */}
|
||||
<LangToggle />
|
||||
|
||||
{/* دکمه منوی موبایل دایرهای شیشهای */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper xl:hidden"
|
||||
aria-label={open ? "بستن منو" : "باز کردن منو"}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/5 text-ivory backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 xl:hidden"
|
||||
aria-label={open ? t("closeMenu") : t("openMenu")}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? (
|
||||
@@ -106,23 +127,28 @@ export function Header() {
|
||||
|
||||
{open && (
|
||||
<nav
|
||||
aria-label="ناوبری موبایل"
|
||||
className="border-t border-ivory/10 bg-ink/98 px-6 py-4 xl:hidden"
|
||||
aria-label={t("mobileNav")}
|
||||
className="border-t border-white/10 bg-ink/95 backdrop-blur-2xl px-6 py-4 xl:hidden shadow-2xl"
|
||||
>
|
||||
<ul className="grid gap-1">
|
||||
{NAV.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={href(item.href)}
|
||||
className={({ isActive }) =>
|
||||
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
</NavLink>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { LanguagesIcon } from 'lucide-react'
|
||||
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
||||
import { track } from '../analytics'
|
||||
|
||||
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
||||
export function LangToggle({ className = '' }: { className?: string }) {
|
||||
const { lang, t } = useLang()
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const target = counterpartPath(pathname, lang)
|
||||
|
||||
const switchLang = () => {
|
||||
const next = lang === 'fa' ? 'en' : 'fa'
|
||||
track('lang-switch', { to: next })
|
||||
writeLangCookie(next)
|
||||
// پرش خودکار بازدید بعدی با انتخاب دستی همینجا گره نمیخورد
|
||||
sessionStorage.setItem('langAutoRedirect', '1')
|
||||
navigate(target)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchLang}
|
||||
aria-label={t('switchLangAria')}
|
||||
title={t('switchLangAria')}
|
||||
className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 cursor-pointer ${className}`}
|
||||
>
|
||||
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
||||
<span>{t('switchLang')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -2,52 +2,90 @@ import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeftIcon } from 'lucide-react'
|
||||
import { usePageHero } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { ImageField } from './admin/ImageField'
|
||||
|
||||
type PageHeroProps = {
|
||||
title: string
|
||||
lead: string
|
||||
image: string
|
||||
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایشپذیر میشوند */
|
||||
pageKey: string
|
||||
}
|
||||
|
||||
export function PageHero({ title, lead, image }: PageHeroProps) {
|
||||
export function PageHero({ pageKey }: PageHeroProps) {
|
||||
const hero = usePageHero(pageKey)
|
||||
const { t, href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveField = (field: 'title' | 'lead' | 'image', value: string) =>
|
||||
save('page-heroes', hero.id, { [field]: value }, (d) => {
|
||||
const h = d.page_heroes.find((p) => p.id === hero.id)
|
||||
if (h) (h as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
|
||||
<img src={image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
||||
|
||||
{hero.image && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />}
|
||||
|
||||
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
|
||||
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||
{t('home')}
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<span className="text-copper">{title}</span>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
||||
<span className="text-copper">{hero.title}</span>
|
||||
</nav>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-5 max-w-[700px] text-hero font-bold text-ivory"
|
||||
className="mt-5 max-w-[700px]"
|
||||
>
|
||||
{title}
|
||||
<InlineEdit
|
||||
as="span"
|
||||
value={hero.title}
|
||||
onSave={(v) => saveField('title', v)}
|
||||
className="block text-hero font-bold text-ivory"
|
||||
/>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/65"
|
||||
className="mt-5 max-w-2xl"
|
||||
>
|
||||
{lead}
|
||||
<InlineEdit
|
||||
as="span"
|
||||
multiline
|
||||
value={hero.lead}
|
||||
onSave={(v) => saveField('lead', v)}
|
||||
className="block text-base font-light leading-9 text-ivory/65"
|
||||
/>
|
||||
</motion.p>
|
||||
<div className="mt-8 h-px w-28 bg-copper" />
|
||||
|
||||
{/* ویرایش تصویر پسزمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
|
||||
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
|
||||
<SingletonPill
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی این صفحه"
|
||||
select={(d) => d.seo_meta.find((s) => s.page_key === pageKey)}
|
||||
// اگر ردیف سئوی این صفحه هنوز ساخته نشده باشد، همینجا ساخته میشود
|
||||
createDefaults={() => ({ page_key: pageKey, title: '', description: '' })}
|
||||
/>
|
||||
<ImageField label="تصویر پسزمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { BRAND } from '../data/site'
|
||||
import { useSiteData } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری دادهها از دیتابیس استفاده میشود
|
||||
const FALLBACK_LOGO = '/images/logo.png'
|
||||
|
||||
export function Preloader() {
|
||||
const { data } = useSiteData()
|
||||
const { t } = useLang()
|
||||
const [done, setDone] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const logo = data?.brand.logo || FALLBACK_LOGO
|
||||
const name = data?.brand.name || t('brandFallback')
|
||||
|
||||
useEffect(() => {
|
||||
const start = Date.now()
|
||||
const total = 1800
|
||||
@@ -35,17 +44,17 @@ export function Preloader() {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
aria-live="polite"
|
||||
aria-label="در حال بارگذاری"
|
||||
aria-label={t('loading')}
|
||||
>
|
||||
<div className="relative h-36 w-36">
|
||||
<img
|
||||
src={BRAND.logo}
|
||||
src={logo}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-contain opacity-[0.12]"
|
||||
/>
|
||||
<div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(${100 - progress}% 0 0 0)` }}>
|
||||
<img src={BRAND.logo} alt="" aria-hidden="true" className="h-full w-full object-contain" />
|
||||
<img src={logo} alt="" aria-hidden="true" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-x-0 h-px bg-copper"
|
||||
@@ -64,10 +73,10 @@ export function Preloader() {
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mt-4 text-sm font-light tracking-widest text-copper font-pinar"
|
||||
>
|
||||
{BRAND.name}
|
||||
{name}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,58 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { useStatusLabel } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
export function ProjectCard({ project }: any) {
|
||||
const isOngoing = project.status === 'progress'
|
||||
|
||||
const statusText = isOngoing ? 'در حال انجام' : 'انجام شده'
|
||||
|
||||
const statusText = useStatusLabel(project.status)
|
||||
const { t, href, lang } = useLang()
|
||||
|
||||
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
||||
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
||||
|
||||
return (
|
||||
<article className="group flex h-full flex-col overflow-hidden border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
||||
<Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
|
||||
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
||||
<Link to={href(`/projects/${project.slug || '#'}`)} className="flex h-full flex-col focus:outline-none">
|
||||
|
||||
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشهای */}
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden">
|
||||
<img
|
||||
src={project.image}
|
||||
alt={project.title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
<div className={`absolute right-4 top-4 flex items-center gap-2 bg-ink/85 px-4 py-2 text-xs font-light backdrop-blur-md ${textColor}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${dotColor}`} />
|
||||
|
||||
{/* نشانگر وضعیت کپسولی شیشهای */}
|
||||
<div className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
|
||||
{statusText}
|
||||
</div>
|
||||
|
||||
{/* گوی دکمه شیشهای شناور برای ورود به پروژه */}
|
||||
<div className="absolute bottom-4 ltr:right-4 rtl:left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
|
||||
<ArrowLeftIcon className="h-5 w-5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<div className="text-center">
|
||||
<h3 className="font-pinar text-xl font-bold text-ivory">
|
||||
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="mt-7 text-sm font-light text-ivory/60">
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||
{project.location}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="my-7 h-px w-full bg-ivory/10" />
|
||||
<div className="my-6 h-px w-full bg-white/10" />
|
||||
|
||||
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
|
||||
<span>سال: {project.year}</span>
|
||||
<span>{project.area} متر مربع</span>
|
||||
<span>{t('yearLabel')} {project.year}</span>
|
||||
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
||||
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
|
||||
@@ -1,29 +1,58 @@
|
||||
import React from 'react'
|
||||
import { Reveal } from './Reveal'
|
||||
import { useSectionHeading } from '../api'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
|
||||
type SectionHeadingProps = {
|
||||
title: string
|
||||
/** در صورت ارسال، تیتر و توضیح از دیتابیس خوانده و بهصورت درجا ویرایشپذیر میشوند */
|
||||
headingKey?: string
|
||||
title?: string
|
||||
lead?: string
|
||||
align?: 'right' | 'center'
|
||||
tone?: 'copper' | 'ivory'
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
headingKey,
|
||||
title,
|
||||
lead,
|
||||
align = 'right',
|
||||
tone = 'ivory',
|
||||
}: SectionHeadingProps) {
|
||||
const heading = useSectionHeading(headingKey ?? '')
|
||||
const save = useFieldSaver()
|
||||
const isDb = !!headingKey
|
||||
const currentTitle = isDb ? heading.title : (title ?? '')
|
||||
const currentLead = isDb ? heading.lead : (lead ?? '')
|
||||
const isCenter = align === 'center'
|
||||
|
||||
const saveField = (field: 'title' | 'lead', value: string) =>
|
||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) (h as Record<string, unknown>)[field] = value
|
||||
})
|
||||
|
||||
return (
|
||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
|
||||
<h2 className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{lead && <p className="mt-5 text-base font-light leading-9 text-ivory/60">{lead}</p>}
|
||||
|
||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl ltr:text-left rtl:text-right'}>
|
||||
<InlineEdit
|
||||
as="h2"
|
||||
value={currentTitle}
|
||||
onSave={(v) => saveField('title', v)}
|
||||
className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}
|
||||
/>
|
||||
|
||||
{currentLead && (
|
||||
<InlineEdit
|
||||
as="p"
|
||||
multiline
|
||||
value={currentLead}
|
||||
onSave={(v) => saveField('lead', v)}
|
||||
className="mt-5 text-base font-light leading-9 text-ivory/60"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`mt-6 h-px w-24 bg-copper/70 ${isCenter ? 'mx-auto' : ''}`} />
|
||||
</Reveal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { MouseEvent } from 'react'
|
||||
import { ListIcon } from 'lucide-react'
|
||||
import { slugifyFa, uniqueSlug } from '../utils/slug'
|
||||
|
||||
export type TocItem = { id: string; text: string; level: 2 | 3 }
|
||||
|
||||
/**
|
||||
* متن یک تیتر از HTML — همه گرههای تودرتو پیمایش میشوند و <br> به فاصله تبدیل
|
||||
* میشود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
|
||||
*/
|
||||
function headingText(element: Element): string {
|
||||
const parts: string[] = []
|
||||
|
||||
element.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
|
||||
else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const child = node as Element
|
||||
parts.push(child.tagName === 'BR' ? ' ' : headingText(child))
|
||||
}
|
||||
})
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* استخراج فهرست مطالب از HTML ویرایشگر — همه تیترهای H2 و H3 در هر عمقی از سند
|
||||
* (حتی داخل نقلقول) شناسه لنگر میگیرند؛ متن فارسی در شناسه حفظ میشود و تیترهای
|
||||
* تکراری پسوند عددی میگیرند. HTML برگشتی همان است که باید رندر شود.
|
||||
*/
|
||||
export function extractToc(
|
||||
html: string,
|
||||
transform?: (root: HTMLElement) => void,
|
||||
): { items: TocItem[]; html: string } {
|
||||
if (!html || typeof window === 'undefined') return { items: [], html }
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
const used = new Map<string, number>()
|
||||
const items: TocItem[] = []
|
||||
|
||||
doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
|
||||
const text = headingText(element).replace(/\s+/g, ' ').trim()
|
||||
if (!text) return
|
||||
// تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی میگیرد تا لنگرش بینام نماند
|
||||
const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
|
||||
element.setAttribute('id', id)
|
||||
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
|
||||
})
|
||||
|
||||
// تبدیل اختیاری (بستهبندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا میشود
|
||||
transform?.(doc.body)
|
||||
|
||||
return { items, html: doc.body.innerHTML }
|
||||
}
|
||||
|
||||
type TableOfContentsProps = {
|
||||
items: TocItem[]
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* فهرست مطالب کلیکپذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
|
||||
* همه اندازهها و ترازبندیها منطقیاند (start/end) تا در چیدمان راستبهچپ و
|
||||
* چپبهراست — بر اساس جهت مقاله — درست بنشیند.
|
||||
*/
|
||||
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
|
||||
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setActiveId(items[0]?.id ?? '')
|
||||
}, [items])
|
||||
|
||||
useEffect(() => {
|
||||
if (!items.length || typeof IntersectionObserver === 'undefined') return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||
const first = visible[0]?.target.id
|
||||
if (first) setActiveId(first)
|
||||
},
|
||||
{ rootMargin: '-15% 0px -70% 0px', threshold: [0, 1] },
|
||||
)
|
||||
items.forEach((item) => {
|
||||
const el = document.getElementById(item.id)
|
||||
if (el) observer.observe(el)
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}, [items])
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
const go = (event: MouseEvent<HTMLButtonElement>, id: string) => {
|
||||
event.preventDefault()
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
window.history.replaceState(null, '', `#${id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="فهرست مطالب"
|
||||
className={`rounded-2xl border border-white/10 bg-char/60 p-6 backdrop-blur-xl shadow-[0_20px_50px_rgba(0,0,0,0.35),inset_0_1px_1px_rgba(255,255,255,0.08)] ${className}`}
|
||||
>
|
||||
<p className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||
<ListIcon className="h-4 w-4" aria-hidden="true" />
|
||||
{title}
|
||||
</p>
|
||||
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
|
||||
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود میشود تا بریده نشود */}
|
||||
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => go(event, item.id)}
|
||||
aria-current={isActive ? 'location' : undefined}
|
||||
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
|
||||
item.level === 3 ? 'ps-4 text-[13px]' : ''
|
||||
} ${
|
||||
isActive
|
||||
? 'font-normal text-copper'
|
||||
: 'text-ivory/60 hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||
import {
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
BoldIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ImagePlusIcon,
|
||||
ItalicIcon,
|
||||
Link2Icon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
Loader2Icon,
|
||||
PilcrowIcon,
|
||||
QuoteIcon,
|
||||
Redo2Icon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
Undo2Icon,
|
||||
} from 'lucide-react'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { DEFAULT_IMAGE_ALT } from '../../data/constants'
|
||||
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
|
||||
import { ArticleImage } from './ArticleImage'
|
||||
import { BidiTypography, bidiPluginKey } from './BidiTypography'
|
||||
import { EnglishText } from './EnglishText'
|
||||
import { LinkModal } from './LinkModal'
|
||||
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
|
||||
import type { LinkModalValue } from './LinkModal'
|
||||
|
||||
/**
|
||||
* مارکهای قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آنها از نوار ابزار برداشته شده و
|
||||
* فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شدهاند.
|
||||
*/
|
||||
const LegacySmallSizeMark = Mark.create({
|
||||
name: 'smallSize',
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-small]' }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
|
||||
},
|
||||
})
|
||||
|
||||
/** اکستنشنهای مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
|
||||
export const ARTICLE_EXTENSIONS = [
|
||||
StarterKit.configure({
|
||||
link: false,
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
ArticleImage.configure({ allowBase64: false }),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||
isAllowedUri: (uri: string) => isAllowedHref(uri),
|
||||
}),
|
||||
LegacySmallSizeMark,
|
||||
EnglishText,
|
||||
BidiTypography,
|
||||
]
|
||||
|
||||
type ArticleEditorProps = {
|
||||
doc: Record<string, unknown>
|
||||
onChange: (json: Record<string, unknown>) => void
|
||||
/**
|
||||
* شناسه محتوا — با هر تغییر آن، بدنه ویرایشگر از `doc` جاری دوباره بارگذاری میشود
|
||||
* (مثلاً وقتی مقاله از سرور میرسد). بدون این prop محتوا فقط یکبار مقداردهی میشود.
|
||||
*/
|
||||
contentKey?: string | number
|
||||
}
|
||||
|
||||
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمیگرداند */
|
||||
type EditorInstance = NonNullable<ReturnType<typeof useEditor>>
|
||||
|
||||
type TextDirection = 'rtl' | 'ltr'
|
||||
|
||||
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپشده */
|
||||
type LinkApplyMode = 'selection' | 'insert' | 'range'
|
||||
|
||||
type LinkModalState = {
|
||||
open: boolean
|
||||
editing: boolean
|
||||
mode: LinkApplyMode
|
||||
range: { from: number; to: number } | null
|
||||
initial: LinkModalValue
|
||||
}
|
||||
|
||||
const CLOSED_LINK_MODAL: LinkModalState = {
|
||||
open: false,
|
||||
editing: false,
|
||||
mode: 'selection',
|
||||
range: null,
|
||||
initial: { href: '', newTab: true, nofollow: false },
|
||||
}
|
||||
|
||||
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
||||
const URL_LOOKBACK = 300
|
||||
|
||||
function ToolButton({
|
||||
onClick,
|
||||
active = false,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
|
||||
active
|
||||
? 'border-copper/50 bg-copper/15 text-copper'
|
||||
: 'border-transparent text-ivory/60 hover:border-ivory/15 hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
|
||||
|
||||
/**
|
||||
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشندار، مودال لینک سئو-محور،
|
||||
* کلید راستچین/چپچین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
|
||||
*/
|
||||
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
|
||||
const { runSave } = useAdmin()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [direction, setDirection] = useState<TextDirection>('rtl')
|
||||
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: ARTICLE_EXTENSIONS,
|
||||
content: doc && Object.keys(doc).length ? doc : undefined,
|
||||
immediatelyRender: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
dir: 'rtl',
|
||||
lang: 'fa',
|
||||
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
||||
'data-wysiwyg': 'true',
|
||||
},
|
||||
handlePaste: (_view, event, _slice) => {
|
||||
const file = event.clipboardData?.files?.[0]
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
event.preventDefault()
|
||||
void uploadAndInsert(file)
|
||||
return true
|
||||
}
|
||||
// چسباندن یک نشانی → مودال لینک بلافاصله باز میشود
|
||||
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
|
||||
if (isUrlLike(text)) {
|
||||
event.preventDefault()
|
||||
setLinkModal({
|
||||
open: true,
|
||||
editing: false,
|
||||
mode: 'insert',
|
||||
range: null,
|
||||
initial: { href: normalizeHref(text) ?? text, newTab: true, nofollow: false },
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
handleDrop: (_view, event, _slice, moved) => {
|
||||
if (moved) return false
|
||||
const file = event.dataTransfer?.files?.[0]
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
event.preventDefault()
|
||||
void uploadAndInsert(file)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
||||
handleTextInput: (view, from, to, text) => {
|
||||
if (text !== ' ' || from !== to) return false
|
||||
const start = Math.max(0, from - URL_LOOKBACK)
|
||||
const before = view.state.doc.textBetween(start, from, ' ', ' ').trimEnd()
|
||||
const lastToken = before.slice(before.lastIndexOf(' ') + 1)
|
||||
if (!isUrlLike(lastToken)) return false
|
||||
setLinkModal({
|
||||
open: true,
|
||||
editing: false,
|
||||
mode: 'range',
|
||||
range: { from: from - lastToken.length, to: from },
|
||||
initial: { href: normalizeHref(lastToken) ?? lastToken, newTab: true, nofollow: false },
|
||||
})
|
||||
return false
|
||||
},
|
||||
// Enter داخل تیتر، بلوک تازه را پاراگراف میکند تا قالب تیتر سرایت نکند
|
||||
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
||||
})
|
||||
|
||||
// جهت متن فقط روی ناحیه ویرایش اعمال میشود؛ نوار ابزار همیشه راستچین میماند
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.view.dom.setAttribute('dir', direction)
|
||||
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
|
||||
// تزئینهای دوزبانه (اندازه ۲ پیکسل کوچکتر متن لاتین) با جهت تازه بازمحاسبه میشوند
|
||||
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, direction))
|
||||
}, [editor, direction])
|
||||
|
||||
/**
|
||||
* ترکیب (نمونه ویرایشگر، شناسه محتوا) که آخرین بار بارگذاری شده است.
|
||||
* تغییرهای معمول سند (تایپ کاربر) نباید بدنه را از نو بارگذاری کنند.
|
||||
*/
|
||||
const loadedContentRef = useRef<{ editor: EditorInstance; key: string | number } | null>(null)
|
||||
|
||||
// بارگذاری بدنه با تغییر شناسه محتوا (رسیدن مقاله از سرور) یا ساختهشدن نمونه ویرایشگر
|
||||
useEffect(() => {
|
||||
if (!editor || contentKey === undefined) return
|
||||
const loaded = loadedContentRef.current
|
||||
if (loaded && loaded.editor === editor && loaded.key === contentKey) return
|
||||
const incoming = doc && Object.keys(doc).length ? doc : null
|
||||
if (!incoming) return
|
||||
loadedContentRef.current = { editor, key: contentKey }
|
||||
// emitUpdate=false تا بارگذاری، ذخیره خودکارِ بیمورد را فعال نکند
|
||||
editor.commands.setContent(incoming, { emitUpdate: false })
|
||||
}, [editor, contentKey, doc])
|
||||
|
||||
async function uploadAndInsert(file: File) {
|
||||
if (!editor) return
|
||||
setUploading(true)
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
||||
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
|
||||
.run()
|
||||
})
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
|
||||
const openLinkModalForSelection = () => {
|
||||
if (!editor) return
|
||||
const attrs = editor.getAttributes('link') as {
|
||||
href?: string | null
|
||||
target?: string | null
|
||||
rel?: string | null
|
||||
}
|
||||
const editing = Boolean(attrs.href)
|
||||
setLinkModal({
|
||||
open: true,
|
||||
editing,
|
||||
mode: 'selection',
|
||||
range: null,
|
||||
initial: {
|
||||
href: attrs.href ?? '',
|
||||
newTab: editing ? attrs.target === '_blank' : true,
|
||||
nofollow: editing ? parseLinkRel(attrs.rel).nofollow : false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const applyLink = ({ href, newTab, nofollow }: LinkModalValue) => {
|
||||
if (!editor) return
|
||||
const linkAttrs = { href, target: newTab ? '_blank' : null, rel: buildLinkRel({ newTab, nofollow }) }
|
||||
const chain = editor.chain().focus()
|
||||
|
||||
if (linkModal.mode === 'insert') {
|
||||
// نشانی چسباندهشده: متن لینک تازه درج میشود
|
||||
chain.insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: linkAttrs }] }).run()
|
||||
} else if (linkModal.mode === 'range' && linkModal.range) {
|
||||
// نشانی تایپشده: همان محدوده به لینک تبدیل میشود
|
||||
chain.setTextSelection(linkModal.range).extendMarkRange('link').setLink(linkAttrs).run()
|
||||
} else {
|
||||
chain.extendMarkRange('link').setLink(linkAttrs).run()
|
||||
}
|
||||
|
||||
setLinkModal((state) => ({ ...state, open: false }))
|
||||
}
|
||||
|
||||
const removeLink = () => {
|
||||
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
setLinkModal((state) => ({ ...state, open: false }))
|
||||
}
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border border-ivory/15 bg-char" data-dir={direction}>
|
||||
{/* نوار ابزار: همیشه راستچین و در بالای ویرایشگر ثابت میماند */}
|
||||
<div
|
||||
dir="rtl"
|
||||
className="sticky top-14 z-20 flex flex-wrap items-center gap-1 border-b border-ivory/10 bg-char px-2 py-1.5"
|
||||
>
|
||||
<ToolButton
|
||||
label="پاراگراف"
|
||||
active={editor.isActive('paragraph')}
|
||||
onClick={() => applyBlockFormat(editor.view, 'paragraph')}
|
||||
>
|
||||
<PilcrowIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۱"
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
onClick={() => applyBlockFormat(editor.view, 1)}
|
||||
>
|
||||
<Heading1Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۲"
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
onClick={() => applyBlockFormat(editor.view, 2)}
|
||||
>
|
||||
<Heading2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۳"
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
onClick={() => applyBlockFormat(editor.view, 3)}
|
||||
>
|
||||
<Heading3Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* جهت متن — فقط محتوای ویرایشگر را عوض میکند */}
|
||||
<ToolButton
|
||||
label="راستچین (فارسی)"
|
||||
active={direction === 'rtl'}
|
||||
onClick={() => setDirection('rtl')}
|
||||
>
|
||||
<AlignRightIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="چپچین (انگلیسی)"
|
||||
active={direction === 'ltr'}
|
||||
onClick={() => setDirection('ltr')}
|
||||
>
|
||||
<AlignLeftIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||
<BoldIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||
<ItalicIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
||||
<UnderlineIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="خطخورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||
<StrikethroughIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="لیست نقطهای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="لیست شمارهای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||
<ListOrderedIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="نقلقول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||
<QuoteIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="افزودن لینک" active={editor.isActive('link')} onClick={openLinkModalForSelection}>
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
||||
<Undo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
||||
<Redo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
</div>
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
data-editor-file-input
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) void uploadAndInsert(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
||||
{linkModal.open && (
|
||||
<LinkModal
|
||||
initial={linkModal.initial}
|
||||
editing={linkModal.editing}
|
||||
onApply={applyLink}
|
||||
onRemove={removeLink}
|
||||
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user