Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
575e4868be | ||
|
|
846682ac4a | ||
|
|
f7105a62cc | ||
|
|
0628344dcf | ||
|
|
d25db9367c | ||
|
|
1d29e50852 | ||
|
|
853ad67db5 | ||
|
|
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 | ||
|
|
8bb303dda2 | ||
|
|
40ed7b5caf | ||
|
|
ad75f7a7bd | ||
|
|
ed14435485 | ||
|
|
854309f976 |
@@ -1,4 +1,5 @@
|
||||
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
|
||||
# راه سادهتر: bash scripts/gen-env.sh — همهٔ رمزها را تصادفی و قوی میسازد.
|
||||
# docker compose بهطور خودکار این فایل را میخواند؛ بدون آن استک بالا نمیآید.
|
||||
|
||||
# --- رمزهای اصلی (اجباری) ---
|
||||
@@ -14,10 +15,57 @@ 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
|
||||
# دامنه دوم (در صورت وجود) — مسیرها و گواهی TLS برای هر دو صادر میشود
|
||||
# DOMAIN_ALIAS=aramesh.example.ir
|
||||
|
||||
# --- پیشنمایش خصوصی و انتشار (scripts/preview-mode.sh) ---
|
||||
# «on» یعنی سایت آنلاین است اما پشت گیت ورود و بدون ایندکس؛ «off» یعنی انتشار عمومی.
|
||||
# این مقادیر را دستی ویرایش نکنید — اسکریپت preview-mode.sh هماهنگشان میکند:
|
||||
# bash scripts/preview-mode.sh on|off|status → bash scripts/stack.sh deploy-prod
|
||||
# PREVIEW_MODE=on
|
||||
# هش htpasswd کاربر مشترک پیشنمایش — اولین «on» خودش میسازد
|
||||
# PREVIEW_AUTH_USERS=preview:$apr1$...
|
||||
# فهرست میانافزارهای فعال؛ «on» پرش میکند و «off» خالیش میکند
|
||||
# PREVIEW_MIDDLEWARES=preview-auth@docker,preview-noindex@docker
|
||||
# در انتشار، دامنه دوم را به اصلی ریدایرکت میکند تا محتوای تکراری ساخته نشود
|
||||
# ALIAS_REDIRECT_MIDDLEWARES=to-primary@docker
|
||||
|
||||
@@ -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
|
||||
@@ -9,6 +9,12 @@ env/
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# مانیتورینگ — کانفیگ رندرشدهٔ alertmanager (حاوی توکن بله است)
|
||||
monitoring/alertmanager/alertmanager.rendered.yml
|
||||
|
||||
# تنظیمات محلی هر ماشین
|
||||
docker-compose.override.yml
|
||||
|
||||
# پشتیبانگیری محلی
|
||||
backups/
|
||||
|
||||
@@ -22,6 +28,7 @@ dist/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.zcode/
|
||||
|
||||
# Database
|
||||
*.sqlite3
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
مونوریپوی سایت گروه ساختمانی آرامش شامل:
|
||||
|
||||
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متنها و تصاویر از API خوانده میشود)
|
||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT + MinIO
|
||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL (pgvector) + JWT + MinIO
|
||||
- **`chatbot/`** — دستیار هوشمند (FastAPI + LangGraph + RAG با pgvector، پاسخ استریمی)
|
||||
- **مانیتورینگ و آنالیتیکس** — Prometheus/Grafana/Loki و Umami بهصورت لایههای compose
|
||||
|
||||
## معماری
|
||||
|
||||
```
|
||||
Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
|
||||
├── / → Caddy (فایلهای استاتیک SPA)
|
||||
├── /api → FastAPI (بکاند)
|
||||
└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی)
|
||||
Traefik (تنها نقطه ورود — 80/443 در تولید)
|
||||
├── / → Caddy (فایلهای استاتیک SPA)
|
||||
├── /api → FastAPI (بکاند) + متریک /metrics فقط داخلی
|
||||
├── /media → MinIO (باکت aramesh-media)
|
||||
├── /chat → چتبات (SSE استریم پاسخ)
|
||||
├── grafana.* → Grafana (مانیتورینگ — لایه monitoring)
|
||||
└── analytics.* → Umami (آنالیتیکس محصول)
|
||||
```
|
||||
|
||||
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانتاند هاردکد نشده؛
|
||||
@@ -19,97 +24,213 @@ Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
|
||||
- **تصاویر آپلودی در 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 + سرو استاتیک با Caddy
|
||||
│ └── Caddyfile # فقط سرو فایلهای استاتیک (مسیریابی روی Traefik است)
|
||||
├── backend/ # بکاند FastAPI
|
||||
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
|
||||
│ ├── tests/ # تستهای API (pytest)
|
||||
│ ├── requirements.txt
|
||||
│ ├── Dockerfile
|
||||
│ └── .env.example
|
||||
├── scripts/backup.sh # پشتیبانگیری دیتابیس و تصاویر
|
||||
├── docker-compose.yml # اجرای کامل با یک دستور
|
||||
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
|
||||
├── 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
|
||||
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمیآید
|
||||
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 (از طریق Traefik)
|
||||
- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی)
|
||||
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در compose
|
||||
- دیتابیس PostgreSQL و آبجکتاستور MinIO هر دو در کانتینر جدا با volume دائمی
|
||||
- بکاند هنگام شروع، خودش جداول را میسازد و **فقط جدولهای خالی** را seed میکند —
|
||||
ارتقای نسخه دادههای فعلی را از بین نمیبرد
|
||||
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را میسازد و خواندن عمومی را فعال میکند
|
||||
- سایت: 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)
|
||||
|
||||
برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید:
|
||||
پیشنیاز: رکوردهای DNS (`example.com`، `analytics.example.com` و اگر مانیتورینگ فعال
|
||||
است `grafana.example.com`) به این سرور، سپس در `.env`: `DOMAIN`، `ACME_EMAIL` و
|
||||
`SITE_URL=https://example.com`:
|
||||
|
||||
```bash
|
||||
# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
bash scripts/stack.sh deploy-prod
|
||||
```
|
||||
|
||||
- Traefik گواهی Let's Encrypt میگیرد و تمدید میکند؛ همه درخواستهای HTTP به HTTPS هدایت میشوند
|
||||
- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرسهای OG مطلق شوند
|
||||
- **پورتهای مدیریتی MinIO (۹۰۰۱ کنسول و ۱۹۰۰۰ API) در تولید فقط روی `127.0.0.1`
|
||||
بسته میشوند** — دسترسی از راه دور فقط با تونل SSH:
|
||||
`ssh -L 9001:127.0.0.1:9001 server`
|
||||
- همه روترهای websecure هدرهای امنیتی میگیرند (HSTS + nosniff)
|
||||
|
||||
## اجرای محلی (توسعه)
|
||||
### پیشنمایش خصوصی (کار روی سایت، قبل از انتشار عمومی)
|
||||
|
||||
### بکاند
|
||||
سایت روی اینترنت بالا میآید و تیم با همان پنل مدیریت محتوا را ویرایش میکند
|
||||
(ویرایش درجای ایمیل/تلفن/متنها و نوشتن مقاله)، اما **نه عموم به آن دسترسی دارند و
|
||||
نه گوگل چیزی ایندکس میکند**. دو لایه محافظت:
|
||||
|
||||
1. **گیت ورود Traefik (Basic Auth)** — همه مسیرها (سایت، API، تصاویر و چت) پشت یک
|
||||
نام کاربری/رمز مشترکاند؛ بدون آن فقط ۴۰۱ برمیگردد
|
||||
2. **noindex** — هدر `X-Robots-Tag` روی همه پاسخها + `robots.txt` مسدودکننده +
|
||||
نقشه سایت خالی؛ حتی اگر گیت برداشته شود، گوگل چیزی ذخیره نمیکند
|
||||
|
||||
```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/preview-mode.sh on # فعالسازی (بار اول نام کاربری/رمز میسازد و چاپ میکند)
|
||||
bash scripts/stack.sh deploy-prod # اعمال
|
||||
bash scripts/preview-mode.sh status # وضعیت فعلی
|
||||
```
|
||||
|
||||
### فرانتاند
|
||||
- دامنه اصلی (`DOMAIN`) و دامنه دوم (`DOMAIN_ALIAS`) هر دو سرو میشوند و هر دو گیت دارند
|
||||
- برای دادن دسترسی به کارفرما، همان نام کاربری/رمز مشترک را بدهید
|
||||
- رمز مشترک در `.env` (`PREVIEW_AUTH_USERS`) میماند — فقط یکبار چاپ میشود
|
||||
|
||||
### انتشار عمومی (برداشتن پیشنمایش)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
bash scripts/preview-mode.sh off # حذف گیت ورود + اجازه ایندکس
|
||||
bash scripts/stack.sh deploy-prod
|
||||
```
|
||||
|
||||
در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند؛
|
||||
پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `frontend/src/config.ts` از
|
||||
`VITE_API_URL` خوانده میشود — پیشفرض خالی یعنی درخواستهای هممبدأ.
|
||||
بعد از این مرحله:
|
||||
|
||||
1. دامنه دوم بهصورت ۳۰۱ دائمی به دامنه اصلی ریدایرکت میشود (جلوگیری از محتوای تکراری)
|
||||
2. در Search Console دامنه را تأیید و `https://DOMAIN/sitemap.xml` را ثبت کنید
|
||||
3. اگر پیشتر چیزی ایندکس شده بود، حذفش را از همان کنسول درخواست کنید
|
||||
|
||||
### چکلیست رفتن به تولید
|
||||
|
||||
1. `bash scripts/gen-env.sh` — رمزهای قوی جدید (رمزهای `.env` توسعه را کپی نکنید)
|
||||
2. DNS: رکوردهای دامنه و زیردامنهها + باز بودن پورتهای ۸۰/۴۴۳ در فایروال
|
||||
3. `DOMAIN`، `DOMAIN_ALIAS` (دامنه دوم)، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید
|
||||
4. `bash scripts/preview-mode.sh on` — اولین انتشار بهصورت پیشنمایش خصوصی میرود؛
|
||||
محتوا را روی سایت آنلاین نهایی کنید و بعد `preview-mode.sh off` + deploy مجدد
|
||||
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
|
||||
bash scripts/stack.sh monitoring
|
||||
# در تولید (سه فایل با هم):
|
||||
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
- داشبورد: `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/...`
|
||||
- **فرم تماس** `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/...` برمیگردد
|
||||
ذخیره و URL نسبی `/media/...` برمیگردد؛ در همان لحظه تا ۲۰۰۰ پیکسل کوچک و دوباره
|
||||
فشرده میشود (Pillow) تا سرعت صفحه حفظ شود
|
||||
- **خروجی CSV** از نوار مدیریت: پیامهای تماس و مشترکان خبرنامه (با BOM برای Excel)
|
||||
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسالشده عوض میشوند
|
||||
- **ترتیببندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
|
||||
- همه محتوا در یک درخواست: `GET /api/site` (مقالههای پیشنویس در آن نمیآیند)
|
||||
- **تستها**: `cd backend && pip install -r requirements-dev.txt && pytest tests`
|
||||
- **مهاجرتهای اسکیما**: 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` و `MINIO_ROOT_PASSWORD` را تغییر دهید
|
||||
> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
|
||||
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را قوی
|
||||
> بگیرید (`bash scripts/gen-env.sh`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
|
||||
|
||||
## ویرایش زنده سایت (دوقلوی دیجیتال)
|
||||
|
||||
@@ -147,7 +268,9 @@ npm run dev
|
||||
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
|
||||
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل میکند، پس فقط تگهای شناختهشده
|
||||
(پاراگراف، تیتر، لیست، نقلقول، تصویر، لینک) تولید میشوند. مقالات قدیمی (لیست پاراگراف)
|
||||
در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند.
|
||||
در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند. در زمان ذخیره، آدرسهای
|
||||
`href/src` سند در سمت سرور پاکسازی میشوند (`app/sanitize.py`) تا اسکیمهای خطرناک
|
||||
مثل `javascript:` هرگز ذخیره نشوند.
|
||||
|
||||
## سئو
|
||||
|
||||
@@ -169,24 +292,39 @@ npm run dev
|
||||
دیتابیس داخل آن تزریق میکند (`app/seo_pages.py`) — بازدیدکنندههای عادی همان SPA
|
||||
همیشگی را میبینند
|
||||
|
||||
## پشتیبانگیری
|
||||
## پشتیبانگیری و بازگردانی
|
||||
|
||||
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است — هر دو را با یک دستور پشتیبان بگیرید:
|
||||
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است:
|
||||
|
||||
```bash
|
||||
bash scripts/backup.sh # در backups/ — نگهداری پیشفرض ۱۴ روز
|
||||
bash scripts/stack.sh backup # پشتیبان کامل: DB + تصاویر MinIO
|
||||
docker compose --profile ops up -d db-backup # پشتیبان ساعتی خودکار DB (بدون cron میزبان)
|
||||
```
|
||||
|
||||
- خروجی: `backups/db-<زمان>.sql.gz` (فشرده) و `backups/images-<زمان>/` (آینه باکت)
|
||||
- زمانبندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
|
||||
- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza`
|
||||
- خروجی: `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` ریشه هستند (در `.gitignore`) و در `docker-compose.yml`
|
||||
با سینتکس `${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید
|
||||
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در compose با سینتکس
|
||||
`${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید؛ هیچ رمز پیشفرضی در کد نیست
|
||||
- رمز عبور ادمین با bcrypt هش میشود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
|
||||
- ورود با محدودیت تلاش محافظت میشود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
|
||||
- توکنهای JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
|
||||
- توکنهای 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
|
||||
@@ -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='*'"]
|
||||
|
||||
@@ -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
|
||||
@@ -1,10 +1,19 @@
|
||||
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
||||
|
||||
import csv
|
||||
import ipaddress
|
||||
import io
|
||||
import re
|
||||
import socket
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import boto3
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from PIL import Image, ImageOps
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -263,10 +272,69 @@ ALLOWED_IMAGE_TYPES = {
|
||||
"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
|
||||
|
||||
|
||||
def _readable_key(name: str, ext: str) -> str:
|
||||
"""کلید ذخیرهسازی خوانا از نام دلخواه/نام فایل — «۱۴۰۳/elahieh-tower-a1b2c3.jpg».
|
||||
|
||||
نامگذاریها ایمن میشوند: نویسههای ناامن و جداکننده مسیر حذف، فاصلهها خط تیره،
|
||||
طول محدود؛ خالی بودن نام → شناسه تصادفی مثل قبل. پسوند ۶ نویسهای تضمین یکتایی است.
|
||||
"""
|
||||
slug = re.sub(r"[^\w-ۿ\- ]+", "", name, flags=re.UNICODE).strip()
|
||||
slug = re.sub(r"[\s_]+", "-", slug).strip("-")[:80]
|
||||
unique = uuid.uuid4().hex[:6]
|
||||
base = f"{slug}-{unique}" if slug else uuid.uuid4().hex
|
||||
return f"{datetime.now(timezone.utc):%Y/%m}/{base}.{ext}"
|
||||
|
||||
|
||||
def _store_image(data: bytes, content_type: str, name: str) -> str:
|
||||
"""ذخیره تصویر در Object Storage با کلید خوانا — URL نسبی برمیگردد."""
|
||||
ext = ALLOWED_IMAGE_TYPES[content_type]
|
||||
key = _readable_key(name, 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 f"{settings.media_public_base}/{key}"
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_media(
|
||||
file: UploadFile = File(...),
|
||||
name: str = Form(""),
|
||||
_admin: models.AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
content_type = (file.content_type or "").lower()
|
||||
@@ -287,29 +355,143 @@ async def upload_media(
|
||||
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
|
||||
width = height = None
|
||||
if content_type in REENCODE_TYPES:
|
||||
try:
|
||||
data, content_type, width, height = _optimize_image(data, content_type)
|
||||
except Exception:
|
||||
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره میشود
|
||||
pass
|
||||
|
||||
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 ناموفق بود")
|
||||
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
|
||||
display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
|
||||
url = _store_image(data, content_type, display_name)
|
||||
|
||||
return {
|
||||
"url": f"{settings.media_public_base}/{key}",
|
||||
"key": key,
|
||||
"url": url,
|
||||
"key": url.removeprefix(f"{settings.media_public_base}/"),
|
||||
"size": len(data),
|
||||
"content_type": content_type,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
|
||||
|
||||
# ---------- درونریزی تصویر از نشانی خارجی (چسباندن از Outline و مانند آن) ----------
|
||||
|
||||
|
||||
class ImportImageIn(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
def _assert_public_http(url: str) -> None:
|
||||
"""فقط http/https به مقاصد عمومی — جلوگیری از SSRF به شبکه داخلی."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise HTTPException(status_code=400, detail="نشانی تصویر معتبر نیست")
|
||||
try:
|
||||
infos = socket.getaddrinfo(parsed.hostname, None)
|
||||
except socket.gaierror:
|
||||
raise HTTPException(status_code=400, detail="میزبان تصویر پیدا نشد")
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if not ip.is_global:
|
||||
raise HTTPException(status_code=400, detail="نشانی تصویر به میزبان داخلی اشاره میکند")
|
||||
|
||||
|
||||
@router.post("/import-image")
|
||||
async def import_image(
|
||||
payload: ImportImageIn,
|
||||
_admin: models.AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
"""دانلود تصویر از نشانی خارجی، بهینهسازی و ذخیره در Object Storage خودِ سایت —
|
||||
تصاویر چسباندهشده از Outline/مستندات خارجی به میزبانی سایت منتقل میشوند."""
|
||||
_assert_public_http(payload.url)
|
||||
|
||||
max_bytes = settings.media_max_upload_mb * 1024 * 1024
|
||||
request = urllib.request.Request(
|
||||
payload.url, headers={"User-Agent": "ArameshSite/1.0 (+image import)"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
content_type = (response.headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
||||
if content_type not in ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="فرمت تصویر پشتیبانی نمیشود (مجاز: jpg، png، webp، gif، avif)",
|
||||
)
|
||||
data = response.read(max_bytes + 1)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(status_code=502, detail="دانلود تصویر از نشانی ناموفق بود")
|
||||
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
|
||||
|
||||
# نام خوانا از مسیر نشانی — بدون پارامترهای کوئری
|
||||
url_name = urlparse(payload.url).path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
media_url = _store_image(data, content_type, url_name)
|
||||
|
||||
return {
|
||||
"url": media_url,
|
||||
"key": media_url.removeprefix(f"{settings.media_public_base}/"),
|
||||
"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
|
||||
)
|
||||
|
||||
|
||||
# پیامهای تماس
|
||||
@router.get("/messages", response_model=list[ContactMessageOut])
|
||||
def list_messages(unread_only: bool = False, db: Session = Depends(get_db)):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,8 @@ 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"
|
||||
@@ -31,6 +32,10 @@ class Settings(BaseSettings):
|
||||
site_url: str = ""
|
||||
# مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
|
||||
frontend_dist: str = "../frontend/dist"
|
||||
# پیشنمایش خصوصی: سایت روی اینترنت است اما نه عمومی و نه ایندکسشدنی.
|
||||
# گیت اصلی ورود در Traefik است (Basic Auth)؛ این پرچم لایه دوم است:
|
||||
# هدر noindex روی همه پاسخها + robots.txt مسدودکننده.
|
||||
preview_mode: bool = False
|
||||
|
||||
|
||||
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,15 +1,17 @@
|
||||
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 .migrations import run_all as run_migrations
|
||||
from .seo_pages import router as seo_router
|
||||
|
||||
# ساخت جداول پایه — تغییرات بعدی اسکیما فقط با revisionهای Alembic اعمال میشوند
|
||||
# (مigrations/versions). اجرای اینجا برای اجرای مستقیم uvicorn در محیط توسعه است؛
|
||||
# در کانتینر، CMD همین کار را قبل از alembic upgrade head انجام میدهد.
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
app = FastAPI(
|
||||
title="Aramesh Building Group API",
|
||||
@@ -29,6 +31,19 @@ app.add_middleware(
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
# پاسخهای JSON /api — فشردهسازی وقتی بدنه قابلتوجه است
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||
|
||||
|
||||
# پیشنمایش خصوصی — لایه دوم جلوگیری از ایندکس (لایه اول، گیت ورود Traefik است).
|
||||
# روی همه پاسخها هدر noindex مینشیند تا حتی در صورت حذف گیت، گوگل چیزی ذخیره نکند.
|
||||
# همیشه ثبت میشود و پرچم در زمان هر درخواست خوانده میشود (خاموشکردن انتشار = فقط تغییر .env).
|
||||
@app.middleware("http")
|
||||
async def _preview_noindex(request, call_next):
|
||||
response = await call_next(request)
|
||||
if settings.preview_mode:
|
||||
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
|
||||
return response
|
||||
|
||||
app.include_router(public.router, prefix="/api", tags=["عمومی"])
|
||||
app.include_router(forms.router, prefix="/api", tags=["فرمها"])
|
||||
@@ -37,6 +52,12 @@ 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("/")
|
||||
def root():
|
||||
|
||||
@@ -15,6 +15,119 @@ POSITION_TABLES = [
|
||||
"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."""
|
||||
@@ -32,6 +145,25 @@ def run_ddl_migrations() -> None:
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
||||
)
|
||||
# متادیتای تصویر شاخص مقاله + پرسشوپاسخهای پایان مقاله (سئو)
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
|
||||
)
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_title VARCHAR(256) NOT NULL DEFAULT ''")
|
||||
)
|
||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs JSON"))
|
||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs_en JSON"))
|
||||
# سطرهای موجود ستون تازه را NULL میگیرند — آرایه خالی یعنی «بدون پرسشوپاسخ»
|
||||
conn.execute(text("UPDATE articles SET faqs = '[]'::json WHERE faqs IS NULL"))
|
||||
# ستونهای انگلیسی — رشتهها با پیشفرض خالی، 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:
|
||||
@@ -66,6 +198,122 @@ def migrate_article_bodies() -> None:
|
||||
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()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
@@ -10,6 +11,10 @@ def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ستونهای *_en معادل انگلیسی فیلدهای متنیِ قابل نمایش هستند؛
|
||||
# مقدار خالی یعنی «ترجمهای ثبت نشده» و فرانتاند به نسخه فارسی برمیگردد.
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
@@ -23,6 +28,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,6 +36,7 @@ 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):
|
||||
@@ -39,7 +46,9 @@ class Service(Base):
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -51,17 +60,26 @@ class Project(Base):
|
||||
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):
|
||||
@@ -72,12 +90,24 @@ class Article(Base):
|
||||
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="")
|
||||
# متن جایگزین و عنوان تصویر شاخص — سئو و دسترسپذیری
|
||||
image_alt: Mapped[str] = mapped_column(String(256), default="")
|
||||
image_title: Mapped[str] = mapped_column(String(256), 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 انگلیسی
|
||||
# پرسش و پاسخهای پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
|
||||
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
||||
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||
@@ -91,8 +121,11 @@ class Testimonial(Base):
|
||||
|
||||
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):
|
||||
@@ -102,7 +135,9 @@ class Faq(Base):
|
||||
|
||||
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):
|
||||
@@ -112,8 +147,11 @@ class TeamMember(Base):
|
||||
|
||||
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):
|
||||
@@ -123,8 +161,11 @@ class TimelineItem(Base):
|
||||
|
||||
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="")
|
||||
|
||||
|
||||
@@ -136,7 +177,9 @@ class Stat(Base):
|
||||
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):
|
||||
@@ -146,7 +189,9 @@ class WhyUsItem(Base):
|
||||
|
||||
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):
|
||||
@@ -154,11 +199,15 @@ 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):
|
||||
@@ -168,6 +217,7 @@ class NavItem(Base):
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@@ -178,16 +228,25 @@ class Hero(Base):
|
||||
|
||||
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):
|
||||
@@ -197,16 +256,26 @@ class StorySection(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="")
|
||||
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):
|
||||
@@ -217,10 +286,14 @@ class SectionHeadingContent(Base):
|
||||
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="")
|
||||
|
||||
|
||||
@@ -232,7 +305,9 @@ class PageHeroContent(Base):
|
||||
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="")
|
||||
|
||||
|
||||
@@ -243,8 +318,11 @@ class InvestmentModel(Base):
|
||||
|
||||
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):
|
||||
@@ -254,8 +332,11 @@ class ProcessStep(Base):
|
||||
|
||||
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):
|
||||
@@ -265,8 +346,11 @@ class Office(Base):
|
||||
|
||||
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):
|
||||
@@ -277,6 +361,7 @@ class GalleryImage(Base):
|
||||
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):
|
||||
@@ -287,6 +372,7 @@ class StatusLabel(Base):
|
||||
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):
|
||||
@@ -296,23 +382,41 @@ class ContactSectionContent(Base):
|
||||
|
||||
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):
|
||||
@@ -322,11 +426,17 @@ class NewsletterSectionContent(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="")
|
||||
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):
|
||||
@@ -336,12 +446,19 @@ class FooterContent(Base):
|
||||
|
||||
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):
|
||||
@@ -352,7 +469,9 @@ class SeoMeta(Base):
|
||||
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):
|
||||
|
||||
@@ -1,38 +1,74 @@
|
||||
"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login.
|
||||
"""محدودسازی نرخ درخواست — پنجرهٔ لغزان درونحافظهای.
|
||||
|
||||
پیشفرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک میکند.
|
||||
شمارنده در حافظه همین پروسه است — با ریاستارت بکاند پاک میشود و همین کافی است.
|
||||
برای ورود ادمین (بر پایه تلاش ناموفق) و فرمهای عمومی (بر پایه هر ارسال)
|
||||
استفاده میشود. شمارندهها در حافظهٔ همین پروسهاند — با ریاستارت پاک میشوند؛
|
||||
به همین دلیل uvicorn تککارگر نگه داشته شده است.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
MAX_FAILED_ATTEMPTS = 5
|
||||
WINDOW_SECONDS = 15 * 60
|
||||
|
||||
_failed: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""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"
|
||||
|
||||
|
||||
def _prune(key: str, now: float) -> list[float]:
|
||||
recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS]
|
||||
_failed[key] = recent
|
||||
return recent
|
||||
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)
|
||||
recent = _prune(key, time.monotonic())
|
||||
if len(recent) >= MAX_FAILED_ATTEMPTS:
|
||||
retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0])))
|
||||
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="تلاشهای ناموفق زیاد است — بعداً دوباره امتحان کنید",
|
||||
@@ -47,3 +83,18 @@ def record_failure(request: Request) -> None:
|
||||
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)
|
||||
@@ -3,6 +3,8 @@ from typing import Literal, Optional
|
||||
|
||||
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,29 +45,49 @@ 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
|
||||
image_alt: str = ""
|
||||
image_title: str = ""
|
||||
read: str
|
||||
read_en: str = ""
|
||||
excerpt: str
|
||||
excerpt_en: str = ""
|
||||
body: dict
|
||||
body_en: Optional[dict] = None
|
||||
# پرسشوپاسخهای پایان مقاله — [{question, answer}]؛ سطرهای قدیمی ممکن است NULL باشند
|
||||
faqs: Optional[list[dict]] = None
|
||||
faqs_en: Optional[list[dict]] = None
|
||||
status: str
|
||||
seo_keyphrase: str
|
||||
|
||||
@@ -66,28 +95,39 @@ class ArticleOut(ORMModel):
|
||||
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
|
||||
|
||||
|
||||
@@ -95,69 +135,101 @@ 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
|
||||
|
||||
|
||||
@@ -165,91 +237,137 @@ 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):
|
||||
@@ -275,10 +393,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)
|
||||
|
||||
|
||||
# ---------- ورودیهای پنل مدیریت ----------
|
||||
@@ -301,6 +423,7 @@ class ChangePasswordIn(BaseModel):
|
||||
|
||||
class BrandIn(BaseModel):
|
||||
name: str = ""
|
||||
name_en: str = ""
|
||||
logo: str = ""
|
||||
hero: str = ""
|
||||
contact_bg: str = ""
|
||||
@@ -308,228 +431,343 @@ 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) معتبر از نظر ساختار باشد."""
|
||||
"""بدنه مقاله باید یک سند 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 value
|
||||
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 = ""
|
||||
image_alt: str = ""
|
||||
image_title: str = ""
|
||||
read: str = ""
|
||||
read_en: str = ""
|
||||
excerpt: str = ""
|
||||
excerpt_en: str = ""
|
||||
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||
faqs: list[dict] = []
|
||||
faqs_en: list[dict] = []
|
||||
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):
|
||||
@@ -551,15 +789,26 @@ 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
|
||||
image_alt: Optional[str] = None
|
||||
image_title: 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
|
||||
faqs: Optional[list[dict]] = None
|
||||
faqs_en: Optional[list[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)
|
||||
|
||||
@@ -11,7 +11,7 @@ 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
|
||||
@@ -46,6 +46,35 @@ CONTENT: list[tuple[Any, list[dict], str]] = [
|
||||
(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:
|
||||
db = SessionLocal()
|
||||
@@ -57,16 +86,19 @@ def seed(force: bool = False) -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
for model, rows, label in CONTENT:
|
||||
for (model, rows, label), en_rows in zip(CONTENT, EN_CONTENT):
|
||||
if db.query(model).count() > 0:
|
||||
continue
|
||||
for idx, row in enumerate(rows):
|
||||
payload = dict(row)
|
||||
# فایل فارسی مرجع است؛ فقط فیلدهای *_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 and isinstance(payload.get("body"), list):
|
||||
payload["body"] = paragraphs_to_doc(payload["body"])
|
||||
# بدنه مقاله (فارسی و انگلیسی) در 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:
|
||||
|
||||
@@ -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."},
|
||||
]
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
فرانتاند یک SPA است؛ خزندههای اجتماعی (تلگرام، واتساپ و...) جاوااسکریپت اجرا نمیکنند.
|
||||
این ماژول همان index.html بیلد فرانتاند را میخواند و پیش از ارسال، تگهای
|
||||
title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
||||
title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
||||
بازدیدکنندههای عادی همان SPA همیشگی را میبینند و خزندهها متای درست را میخوانند.
|
||||
|
||||
دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو میشود؛
|
||||
هر دو در sitemap با hreflang متقابل و در head هر صفحه با link alternate معرفی میشوند.
|
||||
"""
|
||||
|
||||
import html
|
||||
@@ -22,6 +25,10 @@ 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:
|
||||
@@ -33,6 +40,29 @@ def _absolute(path: str) -> str:
|
||||
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")
|
||||
@@ -64,17 +94,19 @@ 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 "گروه ساختمانی آرامش"
|
||||
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 = [
|
||||
@@ -83,6 +115,7 @@ def _shell(
|
||||
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:
|
||||
@@ -95,59 +128,114 @@ def _shell(
|
||||
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="fa" dir="rtl"><head><meta charset="utf-8">'
|
||||
f"<title>{esc(title)}</title>{block}</head><body>{body}</body></html>"
|
||||
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" {block}\n </head>", 1)
|
||||
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) -> str:
|
||||
def _brand_name(db: Session, lang: str = "fa") -> str:
|
||||
brand = db.query(models.Brand).first()
|
||||
return brand.name if brand else "گروه ساختمانی آرامش"
|
||||
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)):
|
||||
# پیشنمایش خصوصی — هیچ خزندهای نباید چیزی ذخیره کند
|
||||
if settings.preview_mode:
|
||||
return Response(
|
||||
content="User-agent: *\nDisallow: /\n",
|
||||
media_type="text/plain; charset=utf-8",
|
||||
)
|
||||
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)):
|
||||
urls = list(STATIC_PATHS)
|
||||
urls += [
|
||||
# پیشنمایش خصوصی — نقشه سایت خالی تا هیچ نشانیای اعلام نشود
|
||||
if settings.preview_mode:
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n'
|
||||
)
|
||||
return Response(content=xml, media_type="application/xml; charset=utf-8")
|
||||
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()
|
||||
]
|
||||
urls += [
|
||||
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(
|
||||
f" <url><loc>{html.escape(_absolute(u))}</loc></url>" for u in urls
|
||||
)
|
||||
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">\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")
|
||||
@@ -163,10 +251,16 @@ def blog_list(db: Session = Depends(get_db)):
|
||||
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
|
||||
description=meta.description if meta else "",
|
||||
path="/blog",
|
||||
og_image=meta.image if meta else "",
|
||||
)
|
||||
|
||||
|
||||
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 = (
|
||||
@@ -179,22 +273,27 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
||||
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 = {
|
||||
jsonld: object = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": article.title,
|
||||
"description": article.excerpt,
|
||||
"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(article.title)}</h1><p>{html.escape(first_text)}</p>"
|
||||
# پرسشوپاسخ مقاله → اسکیمای FAQPage در همان بلوک (آرایهای از دو اسکیما)
|
||||
faq_schema = _faq_jsonld(article, "fa")
|
||||
if faq_schema:
|
||||
jsonld = [jsonld, faq_schema]
|
||||
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||
return _shell(
|
||||
title=f"{article.title} | {brand}",
|
||||
description=article.excerpt,
|
||||
title=f"{title} | {brand}",
|
||||
description=excerpt,
|
||||
path=f"/blog/{article.slug}",
|
||||
og_image=article.image,
|
||||
og_type="article",
|
||||
@@ -223,17 +322,158 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
|
||||
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={
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": project.title,
|
||||
"description": project.summary,
|
||||
"image": _absolute(project.image),
|
||||
"inLanguage": "fa",
|
||||
},
|
||||
jsonld=jsonld,
|
||||
)
|
||||
|
||||
|
||||
# ---------- صفحات انگلیسی (/en/...) ----------
|
||||
|
||||
|
||||
def _faq_jsonld(article: models.Article, lang: str) -> dict | None:
|
||||
"""اسکیمای FAQPage برای نتایج غنی گوگل — از پرسشوپاسخهای پایان مقاله.
|
||||
نسخه انگلیسی فقط وقتی ترجمه ثبت شده باشد جایگزین میشود."""
|
||||
faqs = article.faqs if isinstance(article.faqs, list) else []
|
||||
if lang == "en":
|
||||
faqs_en = article.faqs_en if isinstance(article.faqs_en, list) else None
|
||||
if faqs_en:
|
||||
faqs = faqs_en
|
||||
entities = [
|
||||
{"@type": "Question", "name": (f.get("question") or "").strip(),
|
||||
"acceptedAnswer": {"@type": "Answer", "text": (f.get("answer") or "").strip()}}
|
||||
for f in faqs
|
||||
if isinstance(f, dict) and (f.get("question") or "").strip()
|
||||
]
|
||||
if not entities:
|
||||
return None
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": entities,
|
||||
}
|
||||
|
||||
|
||||
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: object = {
|
||||
"@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}"),
|
||||
}
|
||||
faq_schema = _faq_jsonld(article, "en")
|
||||
if faq_schema:
|
||||
jsonld = [jsonld, faq_schema]
|
||||
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
|
||||
@@ -11,3 +11,11 @@ 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
|
||||
|
||||
@@ -74,8 +74,12 @@ 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()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
||||
|
||||
import re
|
||||
import socket
|
||||
|
||||
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
@@ -8,12 +9,24 @@ DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
# ---------- عمومی ----------
|
||||
|
||||
|
||||
def test_health(client):
|
||||
res = client.get("/api/health")
|
||||
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
|
||||
@@ -180,3 +193,387 @@ def test_head_not_injected_for_draft(client):
|
||||
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)
|
||||
|
||||
|
||||
# ---------- نامگذاری تصاویر، درونریزی و پرسشوپاسخ مقاله ----------
|
||||
|
||||
|
||||
class _FakeS3:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.objects = {}
|
||||
|
||||
def put_object(self, **kwargs):
|
||||
self.objects[kwargs["Key"]] = kwargs["Body"]
|
||||
|
||||
|
||||
def _mock_storage(monkeypatch):
|
||||
"""MinIO در تست در دسترس نیست — کلاینت S3 و DNS عمومی جعل میشوند."""
|
||||
fake = _FakeS3()
|
||||
monkeypatch.setattr("app.api.admin.boto3.client", lambda *a, **k: fake)
|
||||
monkeypatch.setattr(
|
||||
"app.api.admin.socket.getaddrinfo",
|
||||
lambda host, port, *a, **k: [(socket.AF_INET, None, None, "", ("93.184.216.34", 0))],
|
||||
)
|
||||
return fake
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), (200, 120, 60)).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_upload_with_readable_name(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("raw name.png", _png_bytes(), "image/png")},
|
||||
data={"name": "برج الهیه tower!"},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
key = res.json()["key"]
|
||||
# نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + پسوند تصویر
|
||||
assert "برج-الهیه-tower" in key
|
||||
assert key.endswith(".png")
|
||||
assert "!" not in key
|
||||
|
||||
|
||||
def test_upload_without_name_uses_filename(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("Elahieh Tower.jpg", _png_bytes(), "image/jpeg")},
|
||||
data={"name": ""},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert "Elahieh-Tower" in res.json()["key"]
|
||||
|
||||
|
||||
def test_import_image_rejects_internal_host(auth_client):
|
||||
# SSRF — میزبان حلقهمحلی/خصوصی رد میشود پیش از هر درخواست شبکه
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "http://127.0.0.1:9000/x.png"})
|
||||
assert res.status_code == 400
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "http://192.168.1.5/x.png"})
|
||||
assert res.status_code == 400
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "file:///etc/passwd"})
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_import_image_rejects_bad_content_type(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
import io as _io
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self):
|
||||
self.headers = {"Content-Type": "text/html"}
|
||||
|
||||
def read(self, n):
|
||||
return b"<html></html>"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", fake_urlopen)
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "https://example.com/page"})
|
||||
assert res.status_code == 415
|
||||
|
||||
|
||||
def test_import_image_happy_path(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
class FakeResponse:
|
||||
def __init__(self):
|
||||
self.headers = {"Content-Type": "image/png"}
|
||||
|
||||
def read(self, n):
|
||||
return _png_bytes()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", lambda request, timeout: FakeResponse())
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "https://wiki.example/Elahieh pic.png?token=1"})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["url"].startswith("/media/")
|
||||
assert "Elahieh-pic" in res.json()["key"]
|
||||
|
||||
|
||||
def test_article_persists_image_meta_and_faqs(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={"slug": "meta-article", "title": "متادیتا", "body": DOC},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
article_id = created.json()["id"]
|
||||
|
||||
faqs = [{"question": "هزینه چقدر است؟", "answer": "بستگی به پروژه دارد."}]
|
||||
res = auth_client.patch(
|
||||
f"/api/admin/articles/{article_id}",
|
||||
json={
|
||||
"image_alt": "نمای برج الهیه",
|
||||
"image_title": "برج آرامش",
|
||||
"faqs": faqs,
|
||||
"faqs_en": [{"question": "What is the cost?", "answer": "Depends on the project."}],
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["image_alt"] == "نمای برج الهیه"
|
||||
assert res.json()["image_title"] == "برج آرامش"
|
||||
assert res.json()["faqs"] == faqs
|
||||
|
||||
# انتشار و سپس خروجی عمومی — همین مقادیر برمیگردد
|
||||
assert auth_client.patch(f"/api/admin/articles/{article_id}", json={"status": "published"}).status_code == 200
|
||||
public = client.get("/api/articles/meta-article").json()
|
||||
assert public["image_alt"] == "نمای برج الهیه"
|
||||
assert public["faqs"] == faqs
|
||||
|
||||
assert auth_client.delete(f"/api/admin/articles/{article_id}").status_code == 204
|
||||
|
||||
|
||||
def test_blog_shell_includes_faqpage_jsonld(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={
|
||||
"slug": "faq-article",
|
||||
"title": "مقاله پرسشوپاسخ",
|
||||
"body": DOC,
|
||||
"status": "published",
|
||||
"faqs": [{"question": "زمان ساخت؟", "answer": "۳۰ ماه."}],
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
|
||||
res = client.get("/blog/faq-article")
|
||||
assert res.status_code == 200
|
||||
assert "FAQPage" in res.text
|
||||
assert "زمان ساخت؟" in res.text
|
||||
|
||||
# نسخه انگلیسی بدون ترجمه → همان فارسی؛ با ترجمه → انگلیسی
|
||||
en = client.get("/en/blog/faq-article").text
|
||||
assert "FAQPage" in en
|
||||
article_id = created.json()["id"]
|
||||
auth_client.patch(
|
||||
f"/api/admin/articles/{article_id}",
|
||||
json={"faqs_en": [{"question": "How long?", "answer": "Thirty months."}]},
|
||||
)
|
||||
en = client.get("/en/blog/faq-article").text
|
||||
assert "How long?" in en
|
||||
assert "زمان ساخت؟" not in en
|
||||
|
||||
auth_client.delete(f"/api/admin/articles/{article_id}")
|
||||
|
||||
|
||||
def test_blog_shell_without_faqs_has_no_faqpage(client):
|
||||
res = client.get("/blog/published-post")
|
||||
assert res.status_code == 200
|
||||
assert "FAQPage" not in res.text
|
||||
|
||||
|
||||
# ---------- پیشنمایش خصوصی (برای انتشار عمومی خاموش میشود) ----------
|
||||
|
||||
|
||||
def test_preview_mode_blocks_robots_and_sitemap(client, monkeypatch):
|
||||
from app.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "preview_mode", True)
|
||||
try:
|
||||
robots = client.get("/robots.txt")
|
||||
assert robots.status_code == 200
|
||||
assert "Disallow: /" in robots.text
|
||||
assert "Sitemap:" not in robots.text
|
||||
|
||||
sitemap = client.get("/sitemap.xml")
|
||||
assert sitemap.status_code == 200
|
||||
assert "published-post" not in sitemap.text
|
||||
|
||||
# لایه دوم: هدر noindex روی همه پاسخها
|
||||
assert "noindex" in client.get("/api/health").headers.get("X-Robots-Tag", "")
|
||||
finally:
|
||||
monkeypatch.setattr(settings, "preview_mode", False)
|
||||
|
||||
|
||||
def test_public_mode_allows_indexing(client):
|
||||
robots = client.get("/robots.txt").text
|
||||
assert "Disallow: /admin" in robots
|
||||
assert "Sitemap:" in robots
|
||||
assert "noindex" not in client.get("/api/health").headers.get("X-Robots-Tag", "")
|
||||
|
||||
@@ -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."""
|
||||
@@ -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:
|
||||
@@ -1,15 +1,28 @@
|
||||
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورتهای استاندارد ۸۰/۴۴۳.
|
||||
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورتهای استاندارد ۸۰/۴۳.
|
||||
# پیشنیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ بهعلاوه رکورد DNS دامنه به این سرور.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
# 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).
|
||||
#
|
||||
# ---- پیشنمایش خصوصی و انتشار (scripts/preview-mode.sh) ----
|
||||
# در حالت پیشنمایش (PREVIEW_MODE=on) کل سایت پشت Basic Auth است و noindex میگیرد:
|
||||
# تیم با یک نام کاربری/رمز مشترک وارد میشود، محتوا را ویرایش میکند و هیچکس دیگری —
|
||||
# از جمله گوگل — به سایت دسترسی ندارد. برای انتشار عمومی:
|
||||
# bash scripts/preview-mode.sh off → deploy مجدد → ثبت sitemap در Search Console.
|
||||
#
|
||||
# DOMAIN دامنه اصلی است و DOMAIN_ALIAS دامنه دوم (هر دو مسیر و گواهی میگیرند).
|
||||
# در زمان انتشار، برای جلوگیری از محتوای تکراری، دامنه دوم را به اصلی ریدایرکت کنید:
|
||||
# ALIAS_REDIRECT=on (در .env) — ریدایرکت ۳۰۱ دائمی به دامنه اصلی.
|
||||
|
||||
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
|
||||
@@ -18,6 +31,9 @@ services:
|
||||
- --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"
|
||||
@@ -25,26 +41,40 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- letsencrypt_data:/letsencrypt
|
||||
|
||||
backend:
|
||||
environment:
|
||||
# سایت هممبدأ است — CORS لازم نیست
|
||||
CORS_ORIGINS: '[]'
|
||||
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
||||
- traefik.http.routers.api.entrypoints=websecure
|
||||
- traefik.http.routers.api.tls.certresolver=le
|
||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
||||
- traefik.http.routers.seo.entrypoints=websecure
|
||||
- traefik.http.routers.seo.tls.certresolver=le
|
||||
|
||||
# MinIO کنسول (رمز 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=${PREVIEW_MIDDLEWARES:+$PREVIEW_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 تنظیم کنید}
|
||||
# پیشنمایش خصوصی — هدر noindex + robots مسدودکننده (لایه دوم؛ لایه اول گیت Traefik)
|
||||
PREVIEW_MODE: ${PREVIEW_MODE:-off}
|
||||
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=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
|
||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
||||
- traefik.http.routers.seo.entrypoints=websecure
|
||||
- traefik.http.routers.seo.tls.certresolver=le
|
||||
- traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
|
||||
|
||||
frontend:
|
||||
labels:
|
||||
@@ -52,6 +82,58 @@ services:
|
||||
- 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=${PREVIEW_MIDDLEWARES:+$PREVIEW_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
|
||||
# ---- گیت پیشنمایش خصوصی ----
|
||||
# ورود با نام کاربری/رمز مشترک (htpasswd) — تا وقتی PREVIEW_MIDDLEWARES تنظیم است فعال میشود
|
||||
- traefik.http.middlewares.preview-auth.basicauth.users=${PREVIEW_AUTH_USERS:-disabled:nope}
|
||||
- traefik.http.middlewares.preview-auth.basicauth.realm=Aramesh private preview
|
||||
# لایه دوم جلوگیری از ایندکس — حتی اگر گیت حذف شد، گوگل چیزی ذخیره نکند
|
||||
- traefik.http.middlewares.preview-noindex.headers.customresponseheaders.X-Robots-Tag=noindex, nofollow, noarchive
|
||||
# ---- ریدایرکت دامنه دوم به اصلی (زمان انتشار: ALIAS_REDIRECT=on) ----
|
||||
- traefik.http.middlewares.to-primary.redirectregex.regex=^https?://[^/]+(.*)
|
||||
- traefik.http.middlewares.to-primary.redirectregex.replacement=https://${DOMAIN}$$1
|
||||
- traefik.http.middlewares.to-primary.redirectregex.permanent=true
|
||||
- traefik.http.routers.alias-redirect.rule=Host(`${DOMAIN_ALIAS:-invalid.invalid}`)
|
||||
- traefik.http.routers.alias-redirect.entrypoints=websecure
|
||||
- traefik.http.routers.alias-redirect.tls.certresolver=le
|
||||
- traefik.http.routers.alias-redirect.priority=1000
|
||||
- traefik.http.routers.alias-redirect.middlewares=${ALIAS_REDIRECT_MIDDLEWARES:-sec-headers}
|
||||
- traefik.http.routers.alias-redirect.service=web
|
||||
|
||||
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=${PREVIEW_MIDDLEWARES:+$PREVIEW_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:
|
||||
|
||||
@@ -1,67 +1,121 @@
|
||||
# مقادیر محرمانه از فایل .env ریشه خوانده میشوند — از .env.example کپی کنید:
|
||||
# cp .env.example .env
|
||||
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README).
|
||||
# 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:
|
||||
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند) و /media (MinIO)
|
||||
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند)، /media (MinIO)،
|
||||
# /chat (چتبات)، / (آنالیتیکس روی analytics.localhost — پایین همین فایل)
|
||||
traefik:
|
||||
image: traefik:v3
|
||||
# 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:
|
||||
# پورت به 8080 تغییر کرد تا با پورت 5173 سرور لوکال Vite تداخل نکند
|
||||
- "${TRAEFIK_HTTP_PORT:-8080}:80"
|
||||
- "${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:
|
||||
image: postgres:17-alpine
|
||||
# pgvector/pgvector:pg17 = همان PostgreSQL 17 + افزونهٔ vector برای چتبات
|
||||
image: pgvector/pgvector:pg17
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
POSTGRES_USER: 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:latest
|
||||
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:
|
||||
- "9001:9001" # کنسول MinIO
|
||||
- "9000:9000" # دسترسی لوکال به تصاویر و مدیا
|
||||
# در تولید، 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.routers.media.middlewares=media-rewrite
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
||||
- traefik.http.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:latest
|
||||
image: minio/mc:RELEASE.2025-04-08T15-39-49Z
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
@@ -69,24 +123,58 @@ services:
|
||||
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 &&
|
||||
echo 'minio-setup: bucket ready'
|
||||
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
|
||||
ports:
|
||||
- "8000:8000" # اتصال مستقیم بکاند به ویندوز برای فرانتاند لوکال
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-'["http://localhost:5173","http://127.0.0.1:5173"]'}
|
||||
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
|
||||
@@ -100,11 +188,14 @@ services:
|
||||
FRONTEND_DIST: /srv/frontend
|
||||
volumes:
|
||||
- ./frontend/dist:/srv/frontend:ro
|
||||
networks: [edge, internal]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
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
|
||||
@@ -113,38 +204,129 @@ services:
|
||||
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
|
||||
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
|
||||
# مسیرهای /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: ./frontend
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
- backend
|
||||
- minio
|
||||
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:latest
|
||||
image: minio/mc:RELEASE.2025-04-08T15-39-49Z
|
||||
profiles: ["tools"]
|
||||
depends_on:
|
||||
minio:
|
||||
@@ -152,7 +334,42 @@ services:
|
||||
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:
|
||||
minio_data:
|
||||
chatbot_hf:
|
||||
backups:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# فقط Caddyfile و dist/ به کانتکست بیلد میروند — node_modules و سورس عظیماند و لازم نیستند
|
||||
*
|
||||
!Caddyfile
|
||||
!dist
|
||||
@@ -1,12 +1,21 @@
|
||||
{
|
||||
# پنل مدیریت Caddy لازم نیست — سطح حمله کمتر
|
||||
admin off
|
||||
}
|
||||
|
||||
:80 {
|
||||
root * /srv
|
||||
encode gzip
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,17 @@
|
||||
|
||||
FROM caddy:2-alpine
|
||||
|
||||
# نسخهٔ 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 Caddyfile /etc/caddy/Caddyfile
|
||||
COPY dist /srv
|
||||
COPY --chown=caddy:caddy dist /srv
|
||||
|
||||
# بدون root — فقط فایلهای استاتیک سرو میشوند و TLS را Traefik تمام میکند
|
||||
USER caddy
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -1,33 +1,76 @@
|
||||
# فرانتاند گروه ساختمانی آرامش
|
||||
|
||||
سایت React + Vite + Tailwind — تمام متنها و تصاویر از بکاند (`GET /api/site`) خوانده میشود.
|
||||
# Aramesh Construction Group — Frontend
|
||||
|
||||
## اجرا
|
||||
A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
|
||||
|
||||
## Typography & 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
|
||||
* **Projects Map Restoration**: Successfully re-integrated and mounted the interactive project overview map directly at the top of the Projects page to display all project markers.
|
||||
* **Project Details Update**: Revamped and updated the presentation of project details for better clarity and structure.
|
||||
* **Stats Section Relocation**: Moved the statistics (metrics) section higher up in the page layout for better visibility and impact.
|
||||
* **FAQ Section Redesign**: Refactored the FAQ layout into a centered vertical stack with full RTL/LTR responsiveness, featuring an integrated centered header block (title, description, and accent line) and direction-aware (`text-start`) accordion content.
|
||||
* **Newsletter Section Polish**: Updated the newsletter container to a clean solid `bg-copper` background while introducing high-contrast button styling (`bg-ink` default transitioning to white on hover) preserving site-wide kinetic animation logic.
|
||||
* **Article Cards Spacing**: Refined vertical layout spacing and responsive gaps between title, excerpt, and footer blocks without impacting typography scales.
|
||||
* **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
|
||||
|
||||
```
|
||||
|
||||
در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند —
|
||||
پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `src/config.ts` از `VITE_API_URL`
|
||||
خوانده میشود؛ پیشفرض خالی یعنی درخواستهای هممبدأ.
|
||||
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
|
||||
|
||||
بیلد روی هاست انجام میشود (کانتینر فقط با Caddy فایلهای `dist/` را سرو میکند):
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
> نکته: بکاند برای تزریق متای سئو در صفحات `/blog` و `/projects` همین پوشه `dist/` را
|
||||
> (در compose به `/srv/frontend` سوار است) میخواند — بعد از هر تغییر فرانت، `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.
|
||||
|
||||
```
|
||||
|
||||
حالا اگر بخواهید این تغییرات جدید را هم در پیام کامیت خود بیاورید، میتوانید از این کامیت یکخطی استفاده کنید:
|
||||
|
||||
```bash
|
||||
git commit -m "docs: update README with project details revamp, stats section relocation, FAQ layout, and map restoration"
|
||||
|
||||
- `src/api.tsx` — بارگذاری داده سایت و ادغام پیشنویس مقالهها برای ادمین
|
||||
- `src/admin/` — لایه ویرایش زنده (دوقلوی دیجیتال): نوار مدیریت، کشوی موجودیتها، صفحه نوشتن مقاله
|
||||
- `src/seo.ts` — تگهای title / description / OG / canonical / JSON-LD هر صفحه
|
||||
- `src/data/site.ts` — انواع داده مشترک
|
||||
|
||||
@@ -16,6 +16,24 @@
|
||||
</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>
|
||||
|
||||
@@ -17,9 +17,16 @@
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"maplibre-gl": "^6.10.0",
|
||||
"pmtiles": "^4.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-icons": "^5.7.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": {
|
||||
@@ -933,7 +940,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
@@ -943,7 +949,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
@@ -953,8 +958,7 @@
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
@@ -1040,6 +1044,92 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "26.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
|
||||
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
|
||||
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -1972,6 +2062,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -2020,6 +2116,15 @@
|
||||
"@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",
|
||||
@@ -2440,6 +2545,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -2763,6 +2877,22 @@
|
||||
"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/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.407",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
||||
@@ -3137,6 +3267,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -3290,6 +3426,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -3617,6 +3759,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -3630,6 +3778,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
||||
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -3733,6 +3887,45 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.10.0.tgz",
|
||||
"integrity": "sha512-pZIJ1/rsB1jDjT3eBTfMaeSY45lRKdCuZtqXqTnkUrn0UJO9IwtbywT5ym3/cz57MGEELwAwQSYjB/Fj9TlktQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.2.0",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@maplibre/geojson-vt": "^6.1.1",
|
||||
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
|
||||
"@maplibre/mlt": "^1.3.0",
|
||||
"@maplibre/vt-pbf": "^4.3.2",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"bidi-js": "^1.1.0",
|
||||
"earcut": "^3.2.3",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.1.0",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^5.1.2",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -3770,6 +3963,15 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "11.18.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
||||
@@ -3791,6 +3993,12 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -3860,7 +4068,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"
|
||||
@@ -4017,6 +4224,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -4056,6 +4275,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pmtiles": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.5.0.tgz",
|
||||
"integrity": "sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
@@ -4228,6 +4456,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -4238,6 +4472,17 @@
|
||||
"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",
|
||||
@@ -4377,6 +4622,12 @@
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -4408,6 +4659,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -4420,6 +4677,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",
|
||||
@@ -4433,12 +4696,61 @@
|
||||
"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-icons": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
|
||||
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -4481,6 +4793,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",
|
||||
@@ -4504,6 +4853,15 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -4534,6 +4892,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -4693,6 +5060,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",
|
||||
@@ -4918,6 +5294,12 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -5055,6 +5437,20 @@
|
||||
"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",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "npx vite",
|
||||
"build": "npx vite build",
|
||||
"build:map-worker": "esbuild src/map/pmtiles.worker.ts --bundle --format=esm --outfile=public/map/pmtiles-worker.mjs",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"preview": "npx vite preview"
|
||||
},
|
||||
@@ -19,9 +20,16 @@
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"maplibre-gl": "^6.10.0",
|
||||
"pmtiles": "^4.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-icons": "^5.7.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": {
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
// node_modules/fflate/esm/browser.js
|
||||
var u8 = Uint8Array;
|
||||
var u16 = Uint16Array;
|
||||
var i32 = Int32Array;
|
||||
var fleb = new u8([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
0,
|
||||
/* unused */
|
||||
0,
|
||||
0,
|
||||
/* impossible */
|
||||
0
|
||||
]);
|
||||
var fdeb = new u8([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
6,
|
||||
7,
|
||||
7,
|
||||
8,
|
||||
8,
|
||||
9,
|
||||
9,
|
||||
10,
|
||||
10,
|
||||
11,
|
||||
11,
|
||||
12,
|
||||
12,
|
||||
13,
|
||||
13,
|
||||
/* unused */
|
||||
0,
|
||||
0
|
||||
]);
|
||||
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
|
||||
var freb = function(eb, start) {
|
||||
var b2 = new u16(31);
|
||||
for (var i = 0; i < 31; ++i) {
|
||||
b2[i] = start += 1 << eb[i - 1];
|
||||
}
|
||||
var r = new i32(b2[30]);
|
||||
for (var i = 1; i < 30; ++i) {
|
||||
for (var j2 = b2[i]; j2 < b2[i + 1]; ++j2) {
|
||||
r[j2] = j2 - b2[i] << 5 | i;
|
||||
}
|
||||
}
|
||||
return { b: b2, r };
|
||||
};
|
||||
var _a = freb(fleb, 2);
|
||||
var fl = _a.b;
|
||||
var revfl = _a.r;
|
||||
fl[28] = 258, revfl[258] = 28;
|
||||
var _b = freb(fdeb, 0);
|
||||
var fd = _b.b;
|
||||
var revfd = _b.r;
|
||||
var rev = new u16(32768);
|
||||
for (i = 0; i < 32768; ++i) {
|
||||
x2 = (i & 43690) >> 1 | (i & 21845) << 1;
|
||||
x2 = (x2 & 52428) >> 2 | (x2 & 13107) << 2;
|
||||
x2 = (x2 & 61680) >> 4 | (x2 & 3855) << 4;
|
||||
rev[i] = ((x2 & 65280) >> 8 | (x2 & 255) << 8) >> 1;
|
||||
}
|
||||
var x2;
|
||||
var i;
|
||||
var hMap = function(cd, mb, r) {
|
||||
var s = cd.length;
|
||||
var i = 0;
|
||||
var l = new u16(mb);
|
||||
for (; i < s; ++i) {
|
||||
if (cd[i])
|
||||
++l[cd[i] - 1];
|
||||
}
|
||||
var le = new u16(mb);
|
||||
for (i = 1; i < mb; ++i) {
|
||||
le[i] = le[i - 1] + l[i - 1] << 1;
|
||||
}
|
||||
var co;
|
||||
if (r) {
|
||||
co = new u16(1 << mb);
|
||||
var rvb = 15 - mb;
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
var sv = i << 4 | cd[i];
|
||||
var r_1 = mb - cd[i];
|
||||
var v2 = le[cd[i] - 1]++ << r_1;
|
||||
for (var m = v2 | (1 << r_1) - 1; v2 <= m; ++v2) {
|
||||
co[rev[v2] >> rvb] = sv;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
co = new u16(s);
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return co;
|
||||
};
|
||||
var flt = new u8(288);
|
||||
for (i = 0; i < 144; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
for (i = 144; i < 256; ++i)
|
||||
flt[i] = 9;
|
||||
var i;
|
||||
for (i = 256; i < 280; ++i)
|
||||
flt[i] = 7;
|
||||
var i;
|
||||
for (i = 280; i < 288; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
var fdt = new u8(32);
|
||||
for (i = 0; i < 32; ++i)
|
||||
fdt[i] = 5;
|
||||
var i;
|
||||
var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
|
||||
var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
|
||||
var max = function(a) {
|
||||
var m = a[0];
|
||||
for (var i = 1; i < a.length; ++i) {
|
||||
if (a[i] > m)
|
||||
m = a[i];
|
||||
}
|
||||
return m;
|
||||
};
|
||||
var bits = function(d2, p, m) {
|
||||
var o = p / 8 | 0;
|
||||
return (d2[o] | d2[o + 1] << 8) >> (p & 7) & m;
|
||||
};
|
||||
var bits16 = function(d2, p) {
|
||||
var o = p / 8 | 0;
|
||||
return (d2[o] | d2[o + 1] << 8 | d2[o + 2] << 16) >> (p & 7);
|
||||
};
|
||||
var shft = function(p) {
|
||||
return (p + 7) / 8 | 0;
|
||||
};
|
||||
var slc = function(v2, s, e) {
|
||||
if (s == null || s < 0)
|
||||
s = 0;
|
||||
if (e == null || e > v2.length)
|
||||
e = v2.length;
|
||||
return new u8(v2.subarray(s, e));
|
||||
};
|
||||
var ec = [
|
||||
"unexpected EOF",
|
||||
"invalid block type",
|
||||
"invalid length/literal",
|
||||
"invalid distance",
|
||||
"stream finished",
|
||||
"no stream handler",
|
||||
,
|
||||
// determined by compression function
|
||||
"no callback",
|
||||
"invalid UTF-8 data",
|
||||
"extra field too long",
|
||||
"date not in range 1980-2099",
|
||||
"filename too long",
|
||||
"stream finishing",
|
||||
"invalid zip data"
|
||||
// determined by unknown compression method
|
||||
];
|
||||
var err = function(ind, msg, nt) {
|
||||
var e = new Error(msg || ec[ind]);
|
||||
e.code = ind;
|
||||
if (Error.captureStackTrace)
|
||||
Error.captureStackTrace(e, err);
|
||||
if (!nt)
|
||||
throw e;
|
||||
return e;
|
||||
};
|
||||
var inflt = function(dat, st, buf, dict) {
|
||||
var sl = dat.length, dl = dict ? dict.length : 0;
|
||||
if (!sl || st.f && !st.l)
|
||||
return buf || new u8(0);
|
||||
var noBuf = !buf;
|
||||
var resize = noBuf || st.i != 2;
|
||||
var noSt = st.i;
|
||||
if (noBuf)
|
||||
buf = new u8(sl * 3);
|
||||
var cbuf = function(l2) {
|
||||
var bl = buf.length;
|
||||
if (l2 > bl) {
|
||||
var nbuf = new u8(Math.max(bl * 2, l2));
|
||||
nbuf.set(buf);
|
||||
buf = nbuf;
|
||||
}
|
||||
};
|
||||
var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
|
||||
var tbts = sl * 8;
|
||||
do {
|
||||
if (!lm) {
|
||||
final = bits(dat, pos, 1);
|
||||
var type = bits(dat, pos + 1, 3);
|
||||
pos += 3;
|
||||
if (!type) {
|
||||
var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
|
||||
if (t > sl) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + l);
|
||||
buf.set(dat.subarray(s, t), bt);
|
||||
st.b = bt += l, st.p = pos = t * 8, st.f = final;
|
||||
continue;
|
||||
} else if (type == 1)
|
||||
lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
|
||||
else if (type == 2) {
|
||||
var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
|
||||
var tl = hLit + bits(dat, pos + 5, 31) + 1;
|
||||
pos += 14;
|
||||
var ldt = new u8(tl);
|
||||
var clt = new u8(19);
|
||||
for (var i = 0; i < hcLen; ++i) {
|
||||
clt[clim[i]] = bits(dat, pos + i * 3, 7);
|
||||
}
|
||||
pos += hcLen * 3;
|
||||
var clb = max(clt), clbmsk = (1 << clb) - 1;
|
||||
var clm = hMap(clt, clb, 1);
|
||||
for (var i = 0; i < tl; ) {
|
||||
var r = clm[bits(dat, pos, clbmsk)];
|
||||
pos += r & 15;
|
||||
var s = r >> 4;
|
||||
if (s < 16) {
|
||||
ldt[i++] = s;
|
||||
} else {
|
||||
var c = 0, n = 0;
|
||||
if (s == 16)
|
||||
n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
|
||||
else if (s == 17)
|
||||
n = 3 + bits(dat, pos, 7), pos += 3;
|
||||
else if (s == 18)
|
||||
n = 11 + bits(dat, pos, 127), pos += 7;
|
||||
while (n--)
|
||||
ldt[i++] = c;
|
||||
}
|
||||
}
|
||||
var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
|
||||
lbt = max(lt);
|
||||
dbt = max(dt);
|
||||
lm = hMap(lt, lbt, 1);
|
||||
dm = hMap(dt, dbt, 1);
|
||||
} else
|
||||
err(1);
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
|
||||
var lpos = pos;
|
||||
for (; ; lpos = pos) {
|
||||
var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
|
||||
pos += c & 15;
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (!c)
|
||||
err(2);
|
||||
if (sym < 256)
|
||||
buf[bt++] = sym;
|
||||
else if (sym == 256) {
|
||||
lpos = pos, lm = null;
|
||||
break;
|
||||
} else {
|
||||
var add = sym - 254;
|
||||
if (sym > 264) {
|
||||
var i = sym - 257, b2 = fleb[i];
|
||||
add = bits(dat, pos, (1 << b2) - 1) + fl[i];
|
||||
pos += b2;
|
||||
}
|
||||
var d2 = dm[bits16(dat, pos) & dms], dsym = d2 >> 4;
|
||||
if (!d2)
|
||||
err(3);
|
||||
pos += d2 & 15;
|
||||
var dt = fd[dsym];
|
||||
if (dsym > 3) {
|
||||
var b2 = fdeb[dsym];
|
||||
dt += bits16(dat, pos) & (1 << b2) - 1, pos += b2;
|
||||
}
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var end = bt + add;
|
||||
if (bt < dt) {
|
||||
var shift = dl - dt, dend = Math.min(dt, end);
|
||||
if (shift + bt < 0)
|
||||
err(3);
|
||||
for (; bt < dend; ++bt)
|
||||
buf[bt] = dict[shift + bt];
|
||||
}
|
||||
for (; bt < end; ++bt)
|
||||
buf[bt] = buf[bt - dt];
|
||||
}
|
||||
}
|
||||
st.l = lm, st.p = lpos, st.b = bt, st.f = final;
|
||||
if (lm)
|
||||
final = 1, st.m = lbt, st.d = dm, st.n = dbt;
|
||||
} while (!final);
|
||||
return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
|
||||
};
|
||||
var et = /* @__PURE__ */ new u8(0);
|
||||
var gzs = function(d2) {
|
||||
if (d2[0] != 31 || d2[1] != 139 || d2[2] != 8)
|
||||
err(6, "invalid gzip data");
|
||||
var flg = d2[3];
|
||||
var st = 10;
|
||||
if (flg & 4)
|
||||
st += (d2[10] | d2[11] << 8) + 2;
|
||||
for (var zs = (flg >> 3 & 1) + (flg >> 4 & 1); zs > 0; zs -= !d2[st++])
|
||||
;
|
||||
return st + (flg & 2);
|
||||
};
|
||||
var gzl = function(d2) {
|
||||
var l = d2.length;
|
||||
return (d2[l - 4] | d2[l - 3] << 8 | d2[l - 2] << 16 | d2[l - 1] << 24) >>> 0;
|
||||
};
|
||||
var zls = function(d2, dict) {
|
||||
if ((d2[0] & 15) != 8 || d2[0] >> 4 > 7 || (d2[0] << 8 | d2[1]) % 31)
|
||||
err(6, "invalid zlib data");
|
||||
if ((d2[1] >> 5 & 1) == +!dict)
|
||||
err(6, "invalid zlib data: " + (d2[1] & 32 ? "need" : "unexpected") + " dictionary");
|
||||
return (d2[1] >> 3 & 4) + 2;
|
||||
};
|
||||
function inflateSync(data, opts) {
|
||||
return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||
}
|
||||
function gunzipSync(data, opts) {
|
||||
var st = gzs(data);
|
||||
if (st + 8 > data.length)
|
||||
err(6, "invalid gzip data");
|
||||
return inflt(data.subarray(st, -8), { i: 2 }, opts && opts.out || new u8(gzl(data)), opts && opts.dictionary);
|
||||
}
|
||||
function unzlibSync(data, opts) {
|
||||
return inflt(data.subarray(zls(data, opts && opts.dictionary), -4), { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||
}
|
||||
function decompressSync(data, opts) {
|
||||
return data[0] == 31 && data[1] == 139 && data[2] == 8 ? gunzipSync(data, opts) : (data[0] & 15) != 8 || data[0] >> 4 > 7 || (data[0] << 8 | data[1]) % 31 ? inflateSync(data, opts) : unzlibSync(data, opts);
|
||||
}
|
||||
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
|
||||
var tds = 0;
|
||||
try {
|
||||
td.decode(et, { stream: true });
|
||||
tds = 1;
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
// node_modules/pmtiles/dist/esm/index.js
|
||||
var j = Object.defineProperty;
|
||||
var S = Math.pow;
|
||||
var d = (o, t) => j(o, "name", { value: t, configurable: true });
|
||||
var h = (o, t, e) => new Promise((r, n) => {
|
||||
var s = (c) => {
|
||||
try {
|
||||
a(e.next(c));
|
||||
} catch (l) {
|
||||
n(l);
|
||||
}
|
||||
}, i = (c) => {
|
||||
try {
|
||||
a(e.throw(c));
|
||||
} catch (l) {
|
||||
n(l);
|
||||
}
|
||||
}, a = (c) => c.done ? r(c.value) : Promise.resolve(c.value).then(s, i);
|
||||
a((e = e.apply(o, t)).next());
|
||||
});
|
||||
var re = d((o, t) => {
|
||||
let e = false, r = "", n = L.GridLayer.extend({ createTile: d((s, i) => {
|
||||
let a = document.createElement("img"), c = new AbortController(), l = c.signal;
|
||||
return a.cancel = () => {
|
||||
c.abort();
|
||||
}, e || (o.getHeader().then((u) => {
|
||||
u.tileType === 1 || u.tileType === 6 ? console.error("Error: archive contains vector tiles, but leafletRasterLayer is for displaying raster tiles. See https://github.com/protomaps/PMTiles/tree/main/js for details.") : u.tileType === 2 ? r = "image/png" : u.tileType === 3 ? r = "image/jpeg" : u.tileType === 4 ? r = "image/webp" : u.tileType === 5 && (r = "image/avif");
|
||||
}), e = true), o.getZxy(s.z, s.x, s.y, l).then((u) => {
|
||||
if (u) {
|
||||
let m = new Blob([u.data], { type: r }), g = window.URL.createObjectURL(m);
|
||||
a.src = g;
|
||||
} else a.style.display = "none";
|
||||
a.cancel = void 0, i(void 0, a);
|
||||
}).catch((u) => {
|
||||
if (u.name !== "AbortError") throw u;
|
||||
}), a;
|
||||
}, "createTile"), _removeTile: d(function(s) {
|
||||
let i = this._tiles[s];
|
||||
i && (i.el.cancel && i.el.cancel(), i.el.src && window.URL.revokeObjectURL(i.el.src), i.el.width = 0, i.el.height = 0, i.el.deleted = true, L.DomUtil.remove(i.el), delete this._tiles[s], this.fire("tileunload", { tile: i.el, coords: this._keyToTileCoords(s) }));
|
||||
}, "_removeTile") });
|
||||
return new n(t);
|
||||
}, "leafletRasterLayer");
|
||||
var z = d((o) => (t, e) => {
|
||||
if (e instanceof AbortController) return o(t, e);
|
||||
let r = new AbortController();
|
||||
return o(t, r).then((n) => e(void 0, n.data, n.cacheControl || "", n.expires || ""), (n) => e(n)).catch((n) => e(n)), { cancel: d(() => r.abort(), "cancel") };
|
||||
}, "v3compat");
|
||||
var A = class A2 {
|
||||
constructor(t) {
|
||||
this.tilev4 = d((t2, e) => h(this, null, function* () {
|
||||
if (t2.type === "json") {
|
||||
let g = t2.url.substr(10), p = this.tiles.get(g);
|
||||
if (p || (p = new w(g), this.tiles.set(g, p)), this.metadata) {
|
||||
let K = yield p.getTileJson(t2.url);
|
||||
return e.signal.throwIfAborted(), { data: K };
|
||||
}
|
||||
let f = yield p.getHeader();
|
||||
return e.signal.throwIfAborted(), (f.minLon >= f.maxLon || f.minLat >= f.maxLat) && console.error(`Bounds of PMTiles archive ${f.minLon},${f.minLat},${f.maxLon},${f.maxLat} are not valid.`), { data: { tiles: [`${t2.url}/{z}/{x}/{y}`], minzoom: f.minZoom, maxzoom: f.maxZoom, bounds: [f.minLon, f.minLat, f.maxLon, f.maxLat] } };
|
||||
}
|
||||
let r = new RegExp(/pmtiles:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/), n = t2.url.match(r);
|
||||
if (!n) throw new Error("Invalid PMTiles protocol URL");
|
||||
let s = n[1], i = this.tiles.get(s);
|
||||
i || (i = new w(s), this.tiles.set(s, i));
|
||||
let a = n[2], c = n[3], l = n[4], u = yield i == null ? void 0 : i.getZxy(+a, +c, +l, e.signal);
|
||||
if (e.signal.throwIfAborted(), u) return { data: new Uint8Array(u.data), cacheControl: u.cacheControl, expires: u.expires };
|
||||
let m = yield i.getHeader();
|
||||
if (m.tileType === 1 || m.tileType === 6) {
|
||||
if (this.errorOnMissingTile) throw new Error("Tile not found.");
|
||||
return { data: new Uint8Array() };
|
||||
}
|
||||
return { data: null };
|
||||
}), "tilev4");
|
||||
this.tile = z(this.tilev4);
|
||||
this.tiles = /* @__PURE__ */ new Map(), this.metadata = (t == null ? void 0 : t.metadata) || false, this.errorOnMissingTile = (t == null ? void 0 : t.errorOnMissingTile) || false;
|
||||
}
|
||||
add(t) {
|
||||
this.tiles.set(t.source.getKey(), t);
|
||||
}
|
||||
get(t) {
|
||||
return this.tiles.get(t);
|
||||
}
|
||||
};
|
||||
d(A, "Protocol");
|
||||
var B = A;
|
||||
function b(o, t) {
|
||||
return (t >>> 0) * 4294967296 + (o >>> 0);
|
||||
}
|
||||
d(b, "toNum");
|
||||
function N(o, t) {
|
||||
let e = t.buf, r = e[t.pos++], n = (r & 112) >> 4;
|
||||
if (r < 128 || (r = e[t.pos++], n |= (r & 127) << 3, r < 128) || (r = e[t.pos++], n |= (r & 127) << 10, r < 128) || (r = e[t.pos++], n |= (r & 127) << 17, r < 128) || (r = e[t.pos++], n |= (r & 127) << 24, r < 128) || (r = e[t.pos++], n |= (r & 1) << 31, r < 128)) return b(o, n);
|
||||
throw new Error("Expected varint not more than 10 bytes");
|
||||
}
|
||||
d(N, "readVarintRemainder");
|
||||
function x(o) {
|
||||
let t = o.buf, e = t[o.pos++], r = e & 127;
|
||||
return e < 128 || (e = t[o.pos++], r |= (e & 127) << 7, e < 128) || (e = t[o.pos++], r |= (e & 127) << 14, e < 128) || (e = t[o.pos++], r |= (e & 127) << 21, e < 128) ? r : (e = t[o.pos], r |= (e & 15) << 28, N(r, o));
|
||||
}
|
||||
d(x, "readVarint");
|
||||
function I(o, t, e, r, n) {
|
||||
return n === 0 ? r !== 0 ? [o - 1 - e, o - 1 - t] : [e, t] : [t, e];
|
||||
}
|
||||
d(I, "rotate");
|
||||
function q(o, t, e) {
|
||||
if (o > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
|
||||
if (t >= 1 << o || e >= 1 << o) throw new Error("tile x/y outside zoom level bounds");
|
||||
let r = ((1 << o) * (1 << o) - 1) / 3, n = o - 1, [s, i] = [t, e];
|
||||
for (let a = 1 << n; a > 0; a >>= 1) {
|
||||
let c = s & a, l = i & a;
|
||||
r += (3 * c ^ l) * (1 << n), [s, i] = I(a, s, i, c, l), n--;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
d(q, "zxyToTileId");
|
||||
function G(o) {
|
||||
let t = 3 * o + 1;
|
||||
return t < 4294967296 ? 31 - Math.clz32(t) : 63 - Math.clz32(t / 4294967296);
|
||||
}
|
||||
d(G, "tileIdToZ");
|
||||
function ie(o) {
|
||||
let t = G(o) >> 1;
|
||||
if (t > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
|
||||
let e = ((1 << t) * (1 << t) - 1) / 3, r = o - e, n = 0, s = 0, i = 1 << t;
|
||||
for (let a = 1; a < i; a <<= 1) {
|
||||
let c = a & r / 2, l = a & (r ^ c);
|
||||
[n, s] = I(a, n, s, c, l), r = r / 2, n += c, s += l;
|
||||
}
|
||||
return [t, n, s];
|
||||
}
|
||||
d(ie, "tileIdToZxy");
|
||||
var J = ((s) => (s[s.Unknown = 0] = "Unknown", s[s.None = 1] = "None", s[s.Gzip = 2] = "Gzip", s[s.Brotli = 3] = "Brotli", s[s.Zstd = 4] = "Zstd", s))(J || {});
|
||||
function P(o, t) {
|
||||
return h(this, null, function* () {
|
||||
if (t === 1 || t === 0) return o;
|
||||
if (t === 2) {
|
||||
if (typeof globalThis.DecompressionStream == "undefined") return decompressSync(new Uint8Array(o));
|
||||
let e = new Response(o).body;
|
||||
if (!e) throw new Error("Failed to read response stream");
|
||||
let r = e.pipeThrough(new globalThis.DecompressionStream("gzip"));
|
||||
return new Response(r).arrayBuffer();
|
||||
}
|
||||
throw new Error("Compression method not supported");
|
||||
});
|
||||
}
|
||||
d(P, "defaultDecompress");
|
||||
var O = ((a) => (a[a.Unknown = 0] = "Unknown", a[a.Mvt = 1] = "Mvt", a[a.Png = 2] = "Png", a[a.Jpeg = 3] = "Jpeg", a[a.Webp = 4] = "Webp", a[a.Avif = 5] = "Avif", a[a.Mlt = 6] = "Mlt", a))(O || {});
|
||||
function _(o) {
|
||||
return o === 1 ? ".mvt" : o === 2 ? ".png" : o === 3 ? ".jpg" : o === 4 ? ".webp" : o === 5 ? ".avif" : o === 6 ? ".mlt" : "";
|
||||
}
|
||||
d(_, "tileTypeExt");
|
||||
var Y = 127;
|
||||
function Q(o, t) {
|
||||
let e = 0, r = o.length - 1;
|
||||
for (; e <= r; ) {
|
||||
let n = r + e >> 1, s = t - o[n].tileId;
|
||||
if (s > 0) e = n + 1;
|
||||
else if (s < 0) r = n - 1;
|
||||
else return o[n];
|
||||
}
|
||||
return r >= 0 && (o[r].runLength === 0 || t - o[r].tileId < o[r].runLength) ? o[r] : null;
|
||||
}
|
||||
d(Q, "findTile");
|
||||
var T = class T2 {
|
||||
constructor(t) {
|
||||
this.file = t;
|
||||
}
|
||||
getKey() {
|
||||
return this.file.name;
|
||||
}
|
||||
getBytes(t, e) {
|
||||
return h(this, null, function* () {
|
||||
return { data: yield this.file.slice(t, t + e).arrayBuffer() };
|
||||
});
|
||||
}
|
||||
};
|
||||
d(T, "FileSource");
|
||||
var D = class D2 {
|
||||
constructor(t, e = new Headers(), r = void 0) {
|
||||
var a, c;
|
||||
this.url = t, this.customHeaders = e, this.credentials = r, this.mustReload = false;
|
||||
let n = "";
|
||||
"navigator" in globalThis && (n = (c = (a = globalThis.navigator) == null ? void 0 : a.userAgent) != null ? c : "");
|
||||
let s = n.indexOf("Windows") > -1, i = /Chrome|Chromium|Edg|OPR|Brave/.test(n);
|
||||
this.chromeWindowsNoCache = false, s && i && (this.chromeWindowsNoCache = true);
|
||||
}
|
||||
getKey() {
|
||||
return this.url;
|
||||
}
|
||||
setHeaders(t) {
|
||||
this.customHeaders = t;
|
||||
}
|
||||
getBytes(t, e, r, n) {
|
||||
return h(this, null, function* () {
|
||||
let s, i;
|
||||
r ? i = r : (s = new AbortController(), i = s.signal);
|
||||
let a = new Headers(this.customHeaders);
|
||||
a.set("range", `bytes=${t}-${t + e - 1}`);
|
||||
let c;
|
||||
this.mustReload ? c = "reload" : this.chromeWindowsNoCache && (c = "no-store");
|
||||
let l = yield fetch(this.url, { signal: i, cache: c, headers: a, credentials: this.credentials });
|
||||
if (t === 0 && l.status === 416) {
|
||||
let p = l.headers.get("Content-Range");
|
||||
if (!p || !p.startsWith("bytes */")) throw new Error("Missing content-length on 416 response");
|
||||
let f = +p.substr(8);
|
||||
a.set("range", `bytes=0-${f - 1}`), l = yield fetch(this.url, { signal: i, cache: "reload", headers: a, credentials: this.credentials });
|
||||
}
|
||||
let u = l.headers.get("Etag");
|
||||
if (u != null && u.startsWith("W/") && (u = null), l.status === 416 || n && u && u !== n) throw this.mustReload = true, new v(`Server returned non-matching ETag ${n} after one retry. Check browser extensions and servers for issues that may affect correct ETag headers.`);
|
||||
if (l.status >= 300) throw new Error(`Bad response code: ${l.status}`);
|
||||
let m = l.headers.get("Content-Length");
|
||||
if (l.status === 200 && (!m || +m > e)) throw s && s.abort(), new Error("Server returned no content-length header or content-length exceeding request. Check that your storage backend supports HTTP Byte Serving.");
|
||||
return { data: yield l.arrayBuffer(), etag: u || void 0, cacheControl: l.headers.get("Cache-Control") || void 0, expires: l.headers.get("Expires") || void 0 };
|
||||
});
|
||||
}
|
||||
};
|
||||
d(D, "FetchSource");
|
||||
var E = D;
|
||||
function y(o, t) {
|
||||
let e = o.getUint32(t + 4, true), r = o.getUint32(t + 0, true);
|
||||
return e * S(2, 32) + r;
|
||||
}
|
||||
d(y, "getUint64");
|
||||
function X(o, t) {
|
||||
let e = new DataView(o), r = e.getUint8(7);
|
||||
if (r > 3) throw new Error(`Archive is spec version ${r} but this library supports up to spec version 3`);
|
||||
return { specVersion: r, rootDirectoryOffset: y(e, 8), rootDirectoryLength: y(e, 16), jsonMetadataOffset: y(e, 24), jsonMetadataLength: y(e, 32), leafDirectoryOffset: y(e, 40), leafDirectoryLength: y(e, 48), tileDataOffset: y(e, 56), tileDataLength: y(e, 64), numAddressedTiles: y(e, 72), numTileEntries: y(e, 80), numTileContents: y(e, 88), clustered: e.getUint8(96) === 1, internalCompression: e.getUint8(97), tileCompression: e.getUint8(98), tileType: e.getUint8(99), minZoom: e.getUint8(100), maxZoom: e.getUint8(101), minLon: e.getInt32(102, true) / 1e7, minLat: e.getInt32(106, true) / 1e7, maxLon: e.getInt32(110, true) / 1e7, maxLat: e.getInt32(114, true) / 1e7, centerZoom: e.getUint8(118), centerLon: e.getInt32(119, true) / 1e7, centerLat: e.getInt32(123, true) / 1e7, etag: t };
|
||||
}
|
||||
d(X, "bytesToHeader");
|
||||
function Z(o) {
|
||||
let t = { buf: new Uint8Array(o), pos: 0 }, e = x(t), r = [], n = 0;
|
||||
for (let s = 0; s < e; s++) {
|
||||
let i = x(t);
|
||||
r.push({ tileId: n + i, offset: 0, length: 0, runLength: 1 }), n += i;
|
||||
}
|
||||
for (let s = 0; s < e; s++) r[s].runLength = x(t);
|
||||
for (let s = 0; s < e; s++) r[s].length = x(t);
|
||||
for (let s = 0; s < e; s++) {
|
||||
let i = x(t);
|
||||
i === 0 && s > 0 ? r[s].offset = r[s - 1].offset + r[s - 1].length : r[s].offset = i - 1;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
d(Z, "deserializeIndex");
|
||||
var R = class R2 extends Error {
|
||||
};
|
||||
d(R, "EtagMismatch");
|
||||
var v = R;
|
||||
function V(o, t) {
|
||||
return h(this, null, function* () {
|
||||
let e = yield o.getBytes(0, 16384);
|
||||
if (new DataView(e.data).getUint16(0, true) !== 19792) throw new Error("Wrong magic number for PMTiles archive");
|
||||
let n = e.data.slice(0, Y), s = X(n, e.etag), i = e.data.slice(s.rootDirectoryOffset, s.rootDirectoryOffset + s.rootDirectoryLength), a = `${o.getKey()}|${s.etag || ""}|${s.rootDirectoryOffset}|${s.rootDirectoryLength}`, c = Z(yield t(i, s.internalCompression));
|
||||
return [s, [a, c.length, c]];
|
||||
});
|
||||
}
|
||||
d(V, "getHeaderAndRoot");
|
||||
function F(o, t, e, r, n, s) {
|
||||
return h(this, null, function* () {
|
||||
let i = yield o.getBytes(e, r, s, n.etag), a = yield t(i.data, n.internalCompression), c = Z(a);
|
||||
if (c.length === 0) throw new Error("Empty directory is invalid");
|
||||
return c;
|
||||
});
|
||||
}
|
||||
d(F, "getDirectory");
|
||||
var U = class U2 {
|
||||
constructor(t = 100, e = true, r = P) {
|
||||
this.cache = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
|
||||
}
|
||||
getHeader(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = t.getKey(), r = this.cache.get(e);
|
||||
if (r) return r.lastUsed = this.counter++, r.data;
|
||||
let n = yield V(t, this.decompress);
|
||||
return n[1] && this.cache.set(n[1][0], { lastUsed: this.counter++, data: n[1][2] }), this.cache.set(e, { lastUsed: this.counter++, data: n[0] }), this.prune(), n[0];
|
||||
});
|
||||
}
|
||||
getDirectory(t, e, r, n, s) {
|
||||
return h(this, null, function* () {
|
||||
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
|
||||
if (a) return a.lastUsed = this.counter++, a.data;
|
||||
let c = yield F(t, this.decompress, e, r, n, s);
|
||||
return this.cache.set(i, { lastUsed: this.counter++, data: c }), this.prune(), c;
|
||||
});
|
||||
}
|
||||
prune() {
|
||||
if (this.cache.size > this.maxCacheEntries) {
|
||||
let t = 1 / 0, e;
|
||||
this.cache.forEach((r, n) => {
|
||||
r.lastUsed < t && (t = r.lastUsed, e = n);
|
||||
}), e && this.cache.delete(e);
|
||||
}
|
||||
}
|
||||
invalidate(t) {
|
||||
return h(this, null, function* () {
|
||||
this.cache.delete(t.getKey());
|
||||
});
|
||||
}
|
||||
};
|
||||
d(U, "ResolvedValueCache");
|
||||
var M = class M2 {
|
||||
constructor(t = 100, e = true, r = P) {
|
||||
this.cache = /* @__PURE__ */ new Map(), this.invalidations = /* @__PURE__ */ new Map(), this.pendingFetches = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
|
||||
}
|
||||
getHeader(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = t.getKey(), r = this.cache.get(e);
|
||||
if (r) return r.lastUsed = this.counter++, yield r.data;
|
||||
let n = new Promise((s, i) => {
|
||||
V(t, this.decompress).then((a) => {
|
||||
a[1] && this.cache.set(a[1][0], { lastUsed: this.counter++, data: Promise.resolve(a[1][2]) }), s(a[0]), this.prune();
|
||||
}).catch((a) => {
|
||||
i(a);
|
||||
});
|
||||
});
|
||||
return this.cache.set(e, { lastUsed: this.counter++, data: n }), n;
|
||||
});
|
||||
}
|
||||
trackSignal(t, e, r) {
|
||||
e.refs++, r.addEventListener("abort", () => {
|
||||
--e.refs <= 0 && this.pendingFetches.get(t) === e && (e.controller.abort(), this.cache.delete(t), this.pendingFetches.delete(t));
|
||||
}, { once: true });
|
||||
}
|
||||
getDirectory(t, e, r, n, s) {
|
||||
return h(this, null, function* () {
|
||||
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
|
||||
if (a) {
|
||||
a.lastUsed = this.counter++;
|
||||
let m = this.pendingFetches.get(i);
|
||||
return m && this.trackSignal(i, m, s != null ? s : new AbortController().signal), yield a.data;
|
||||
}
|
||||
let c = new AbortController(), l = { controller: c, refs: 0 };
|
||||
this.trackSignal(i, l, s != null ? s : new AbortController().signal), this.pendingFetches.set(i, l);
|
||||
let u = new Promise((m, g) => {
|
||||
F(t, this.decompress, e, r, n, c.signal).then((p) => {
|
||||
this.pendingFetches.delete(i), m(p), this.prune();
|
||||
}).catch((p) => {
|
||||
g(p);
|
||||
});
|
||||
});
|
||||
return this.cache.set(i, { lastUsed: this.counter++, data: u }), u;
|
||||
});
|
||||
}
|
||||
prune() {
|
||||
if (this.cache.size >= this.maxCacheEntries) {
|
||||
let t = 1 / 0, e;
|
||||
this.cache.forEach((r, n) => {
|
||||
r.lastUsed < t && (t = r.lastUsed, e = n);
|
||||
}), e && this.cache.delete(e);
|
||||
}
|
||||
}
|
||||
invalidate(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = t.getKey();
|
||||
if (this.invalidations.get(e)) return yield this.invalidations.get(e);
|
||||
this.cache.delete(t.getKey());
|
||||
let r = new Promise((n, s) => {
|
||||
this.getHeader(t).then((i) => {
|
||||
n(), this.invalidations.delete(e);
|
||||
}).catch((i) => {
|
||||
s(i);
|
||||
});
|
||||
});
|
||||
this.invalidations.set(e, r);
|
||||
});
|
||||
}
|
||||
};
|
||||
d(M, "SharedPromiseCache");
|
||||
var C = M;
|
||||
var H = class H2 {
|
||||
constructor(t, e, r) {
|
||||
typeof t == "string" ? this.source = new E(t) : this.source = t, r ? this.decompress = r : this.decompress = P, e ? this.cache = e : this.cache = new C();
|
||||
}
|
||||
getHeader() {
|
||||
return h(this, null, function* () {
|
||||
return yield this.cache.getHeader(this.source);
|
||||
});
|
||||
}
|
||||
getZxyAttempt(t, e, r, n) {
|
||||
return h(this, null, function* () {
|
||||
let s = q(t, e, r), i = yield this.cache.getHeader(this.source);
|
||||
if (n == null || n.throwIfAborted(), t < i.minZoom || t > i.maxZoom) return;
|
||||
let a = i.rootDirectoryOffset, c = i.rootDirectoryLength;
|
||||
for (let l = 0; l <= 3; l++) {
|
||||
let u = yield this.cache.getDirectory(this.source, a, c, i, n);
|
||||
n == null || n.throwIfAborted();
|
||||
let m = Q(u, s);
|
||||
if (m) {
|
||||
if (m.runLength > 0) {
|
||||
let g = yield this.source.getBytes(i.tileDataOffset + m.offset, m.length, n, i.etag);
|
||||
return { data: yield this.decompress(g.data, i.tileCompression), cacheControl: g.cacheControl, expires: g.expires };
|
||||
}
|
||||
a = i.leafDirectoryOffset + m.offset, c = m.length;
|
||||
} else return;
|
||||
}
|
||||
throw new Error("Maximum directory depth exceeded");
|
||||
});
|
||||
}
|
||||
getZxy(t, e, r, n) {
|
||||
return h(this, null, function* () {
|
||||
try {
|
||||
return yield this.getZxyAttempt(t, e, r, n);
|
||||
} catch (s) {
|
||||
if (s instanceof v) return this.cache.invalidate(this.source), yield this.getZxyAttempt(t, e, r, n);
|
||||
throw s;
|
||||
}
|
||||
});
|
||||
}
|
||||
getMetadataAttempt() {
|
||||
return h(this, null, function* () {
|
||||
let t = yield this.cache.getHeader(this.source), e = yield this.source.getBytes(t.jsonMetadataOffset, t.jsonMetadataLength, void 0, t.etag), r = yield this.decompress(e.data, t.internalCompression), n = new TextDecoder("utf-8");
|
||||
return JSON.parse(n.decode(r));
|
||||
});
|
||||
}
|
||||
getMetadata() {
|
||||
return h(this, null, function* () {
|
||||
try {
|
||||
return yield this.getMetadataAttempt();
|
||||
} catch (t) {
|
||||
if (t instanceof v) return this.cache.invalidate(this.source), yield this.getMetadataAttempt();
|
||||
throw t;
|
||||
}
|
||||
});
|
||||
}
|
||||
getTileJson(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = yield this.getHeader(), r = yield this.getMetadata(), n = _(e.tileType);
|
||||
return { tilejson: "3.0.0", scheme: "xyz", tiles: [`${t}/{z}/{x}/{y}${n}`], vector_layers: r.vector_layers, attribution: r.attribution, description: r.description, name: r.name, version: r.version, bounds: [e.minLon, e.minLat, e.maxLon, e.maxLat], center: [e.centerLon, e.centerLat, e.centerZoom], minzoom: e.minZoom, maxzoom: e.maxZoom };
|
||||
});
|
||||
}
|
||||
};
|
||||
d(H, "PMTiles");
|
||||
var w = H;
|
||||
|
||||
// src/map/pmtiles.worker.ts
|
||||
var workerScope = self;
|
||||
workerScope.addProtocol("pmtiles", new B().tile);
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 283 KiB |
@@ -0,0 +1,584 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* بهروزرسانی یکبارهٔ پالت `style.json` نقشهٔ آفلاین روی MinIO.
|
||||
*
|
||||
* ترتیب کار (مهم): اول نسخهٔ فعلی خوانده و «پشتیبان» میشود — هم روی دیسک
|
||||
* (style.backup.json) و هم روی MinIO (maps/style.backup.json) — و تنها پس از
|
||||
* موفقیت پشتیبان، پالت تازه نوشته و آپلود میشود.
|
||||
*
|
||||
* چرا از S3 API و نه فایل روی دیسک؟ شیءهای MinIO با فرمت داخلی xl.meta ذخیره
|
||||
* میشوند، نه JSON خام؛ پس تنها راه درست بهروزرسانی، PUT روی همان API است.
|
||||
*
|
||||
* اجرا (از پوشهٔ frontend) — هر کار با پرچم مشخص، پیشفرض هیچ تغییری نمیدهد:
|
||||
* node --experimental-strip-types scripts/update-map-palette.ts --fix-glyphs
|
||||
* node --experimental-strip-types scripts/update-map-palette.ts --palette
|
||||
* node --experimental-strip-types scripts/update-map-palette.ts --restore
|
||||
* ... [--dry-run] برای دیدن گزارش بدون نوشتن روی MinIO
|
||||
*
|
||||
* در همهٔ حالتها (جز restore) ترتیب امن رعایت میشود: خواندن و اعتبارسنجی →
|
||||
* پشتیبان روی دیسک و MinIO → تغییر → اعتبارسنجی دوباره → آپلود.
|
||||
*
|
||||
* کلیدهای MinIO از فایل .env ریشهٔ پروژه خوانده میشوند (MINIO_ROOT_USER /
|
||||
* MINIO_ROOT_PASSWORD) و در نبودشان از متغیرهای محیطی همان نامها.
|
||||
*/
|
||||
|
||||
import { createHash, createHmac } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* پالت تازه */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** نگاشت دستهها به رنگهای تازهٔ برند — تنها منبع حقیقت این اسکریپت */
|
||||
const PALETTE = {
|
||||
background: '#181512',
|
||||
urban: '#27221c',
|
||||
highway: '#6a5544',
|
||||
street: '#4a3a2d',
|
||||
alley: '#352c25',
|
||||
water: '#172c32',
|
||||
parks: '#20312b',
|
||||
nature: '#314238',
|
||||
mainText: '#ead9c6',
|
||||
subText: '#a99584',
|
||||
borders: '#3e362e',
|
||||
} as const
|
||||
|
||||
type Category = keyof typeof PALETTE
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* قواعد دستهبندی لایهها */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type Layer = {
|
||||
id: string
|
||||
type: string
|
||||
'source-layer'?: string
|
||||
paint?: Record<string, unknown>
|
||||
layout?: Record<string, unknown>
|
||||
}
|
||||
type StyleDocument = {
|
||||
version?: number
|
||||
layers?: Layer[]
|
||||
sources?: Record<string, unknown>
|
||||
/** الگوی نشانی گلیفها — باید به پوشهٔ v2.0 باکت اشاره کند */
|
||||
glyphs?: string
|
||||
}
|
||||
|
||||
/** پوشهٔ فونتهای آفلاین در باکت MinIO */
|
||||
const GLYPHS_FOLDER = 'v2.0'
|
||||
/** الگوی استاندارد گلیف — جانگهدارها دستنخورده میمانند */
|
||||
const GLYPH_TEMPLATE = '{fontstack}/{range}.pbf'
|
||||
|
||||
/**
|
||||
* اصلاح مسیر گلیفها: هر شکل نشانی (خالی، نسبی، یا با پوشهٔ اشتباه) به
|
||||
* «v2.0/{fontstack}/{range}.pbf» تبدیل میشود تا با ساختار پوشههای باکت
|
||||
* بخواند. تابع خالص و idempotent است؛ اگر مسیر از قبل درست باشد چیز تغییر نمیکند.
|
||||
*/
|
||||
function fixGlyphsPath(style: StyleDocument): { from: string; to: string } | null {
|
||||
const current = (style.glyphs ?? '').trim()
|
||||
const marker = current.indexOf('{fontstack}')
|
||||
const next = `${GLYPHS_FOLDER}/${marker >= 0 ? current.slice(marker) : GLYPH_TEMPLATE}`
|
||||
if (next === current) return null
|
||||
style.glyphs = next
|
||||
return { from: current || '(خالی)', to: next }
|
||||
}
|
||||
|
||||
/** آیا مقدار text-field به یک فیلد «نام» اشاره میکند؟ (name، name_en، name:latin و…) */
|
||||
const NAME_REFERENCE = /name(:[a-z-]+)?/i
|
||||
|
||||
/**
|
||||
* فونتهای محلی با پوشش فارسی — با اجرای «--audit-fonts» قابل بازبینی است.
|
||||
* تنها پوشههای Noto Sans در باکت گلیف عربی/فارسی دارند؛ Metropolis و بقیه
|
||||
* فقط لاتیناند.
|
||||
*/
|
||||
const PERSIAN_CAPABLE_FONTS = ['Noto Sans Regular', 'Noto Sans Italic', 'Noto Sans Bold']
|
||||
|
||||
/**
|
||||
* بازگردانی متن برچسبها به قالب دوزبانهٔ اصلی، بهتفکیک لایه.
|
||||
* (لاتین خط اول، فارسی خط دوم — یا کنار هم برای نام راهها)
|
||||
*/
|
||||
const TEXT_FIELD_RESTORE: Record<string, string> = {
|
||||
water_name: '{name:latin}\n{name:nonlatin}',
|
||||
place_other: '{name:latin}\n{name:nonlatin}',
|
||||
place_suburb: '{name:latin}\n{name:nonlatin}',
|
||||
place_village: '{name:latin}\n{name:nonlatin}',
|
||||
place_town: '{name:latin}\n{name:nonlatin}',
|
||||
place_city: '{name:latin}\n{name:nonlatin}',
|
||||
place_city_large: '{name:latin}\n{name:nonlatin}',
|
||||
place_state: '{name:latin}\n{name:nonlatin}',
|
||||
highway_name_other: '{name:latin} {name:nonlatin}',
|
||||
place_country_other: '{name:latin}',
|
||||
place_country_minor: '{name:latin}',
|
||||
place_country_major: '{name:latin}',
|
||||
}
|
||||
|
||||
/** قالب پیشفرض برای هر لایهٔ برچسبِ دیگری که بعداً اضافه شود */
|
||||
const DEFAULT_BILINGUAL_FIELD = '{name:latin}\n{name:nonlatin}'
|
||||
|
||||
function referencesName(value: unknown): boolean {
|
||||
if (typeof value === 'string') return NAME_REFERENCE.test(value)
|
||||
if (Array.isArray(value)) return value.some(referencesName)
|
||||
if (value && typeof value === 'object') return Object.values(value).some(referencesName)
|
||||
return false
|
||||
}
|
||||
|
||||
/** فونت فارسی متناسب با پشتهٔ همان لایه — ایتالیک بماند ایتالیک */
|
||||
function persianFontFor(stack: string[]): string {
|
||||
const local = stack.find((font) => PERSIAN_CAPABLE_FONTS.includes(font))
|
||||
if (local) return local
|
||||
return stack.some((font) => /italic/i.test(font)) ? 'Noto Sans Italic' : 'Noto Sans Regular'
|
||||
}
|
||||
|
||||
type LabelChange = { id: string; field?: { from: string; to: string }; font: { from: string; to: string } }
|
||||
|
||||
/**
|
||||
* آمادهسازی برچسبها برای فارسی:
|
||||
* ۱) متن به قالب دوزبانهٔ اصلی برمیگردد (لاتین + nonlatin که برای دادهٔ ایران فارسی است).
|
||||
* ۲) پشتهٔ فونت بازچینی میشود تا فونت دارای گلیف فارسی (Noto Sans محلی) اول بیفتد؛
|
||||
* چون مپلیبر برای هر پشته فقط یک فایل گلیف میگیرد، همان فونت اول باید هر دو خط
|
||||
* را پوشش دهد و Noto Sans لاتین را هم دارد.
|
||||
*/
|
||||
function fixLabels(style: StyleDocument): { changed: LabelChange[]; skipped: string[] } {
|
||||
const changed: LabelChange[] = []
|
||||
const skipped: string[] = []
|
||||
|
||||
for (const layer of style.layers ?? []) {
|
||||
const layout = layer.layout
|
||||
if (layer.type !== 'symbol' || !layout) continue
|
||||
|
||||
const stack = Array.isArray(layout['text-font']) ? (layout['text-font'] as unknown[]).map(String) : []
|
||||
if (!stack.length) continue
|
||||
|
||||
const persianFont = persianFontFor(stack)
|
||||
const restacked = [persianFont, ...stack.filter((font) => font !== persianFont)]
|
||||
const fontChange = { from: stack.join(', '), to: restacked.join(', ') }
|
||||
|
||||
if (!('text-field' in layout)) {
|
||||
// لایهٔ برچسب بدون فیلد نام (مثل شمارهٔ راه) — فقط پشتهٔ فونت اصلاح میشود
|
||||
layout['text-font'] = restacked
|
||||
changed.push({ id: layer.id, font: fontChange })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!referencesName(layout['text-field'])) {
|
||||
layout['text-font'] = restacked
|
||||
skipped.push(layer.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const from = JSON.stringify(layout['text-field']).slice(0, 48)
|
||||
const to = TEXT_FIELD_RESTORE[layer.id] ?? DEFAULT_BILINGUAL_FIELD
|
||||
layout['text-field'] = to
|
||||
layout['text-font'] = restacked
|
||||
changed.push({ id: layer.id, field: { from, to }, font: fontChange })
|
||||
}
|
||||
|
||||
return { changed, skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* بررسی فونتها: هر فونتی که در text-font لایههای برچسب استفاده شده باید در
|
||||
* پوشهٔ v2.0 باکت موجود باشد — هیچ نشانی بیرونی و هیچ فونت غایبی مجاز نیست.
|
||||
*/
|
||||
async function verifyFonts(style: StyleDocument): Promise<void> {
|
||||
const fonts = new Set<string>()
|
||||
for (const layer of style.layers ?? []) {
|
||||
if (layer.type !== 'symbol') continue
|
||||
const stack = layer.layout?.['text-font']
|
||||
if (Array.isArray(stack)) for (const font of stack) if (typeof font === 'string') fonts.add(font)
|
||||
}
|
||||
|
||||
if (!fonts.size) {
|
||||
console.log('• فونتی در لایههای برچسب پیدا نشد')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`\nبررسی ${fonts.size} فونت در پوشهٔ ${GLYPHS_FOLDER}:`)
|
||||
const missing: string[] = []
|
||||
for (const font of [...fonts].sort()) {
|
||||
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/0-255.pbf`, ENDPOINT)
|
||||
const ok = (await fetch(url, { method: 'HEAD' })).ok
|
||||
if (!ok) missing.push(font)
|
||||
console.log(` ${ok ? '✓' : '✗'} ${font}`)
|
||||
}
|
||||
|
||||
console.log(
|
||||
missing.length
|
||||
? `✗ فونتهای ناموجود در ${GLYPHS_FOLDER}: ${missing.join(', ')}`
|
||||
: `✓ همهٔ فونتها در ${GLYPHS_FOLDER} باکت موجودند`,
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* بررسی پوشش فونتهای محلی (کدام فونت فارسی را رندر میکند؟) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** بازههای کلیدی گلیف: لاتین پایه، عربی/فارسی، فرمهای نمایشی عربی */
|
||||
const COVERAGE_RANGES = [
|
||||
{ label: 'لاتین', range: '0-255' },
|
||||
{ label: 'فارسی', range: '1536-1791' },
|
||||
{ label: 'فرم فارسی', range: '65024-65279' },
|
||||
] as const
|
||||
|
||||
/** فهرست پوشههای فونت داخل v2.0 باکت */
|
||||
async function listFontFolders(): Promise<string[]> {
|
||||
const response = await fetch(
|
||||
new URL(`/maps/?list-type=2&prefix=${GLYPHS_FOLDER}/&delimiter=/&max-keys=1000`, ENDPOINT),
|
||||
)
|
||||
const xml = await response.text()
|
||||
const pattern = new RegExp(`<Prefix>${GLYPHS_FOLDER}/([^<]+)/</Prefix>`, 'g')
|
||||
return [...xml.matchAll(pattern)].map((match) => match[1])
|
||||
}
|
||||
|
||||
/** وضعیت یک بازهٔ گلیف برای یک فونت: «نیست» / «خالی» / حجم واقعی */
|
||||
async function rangeCoverage(font: string, range: string): Promise<string> {
|
||||
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/${range}.pbf`, ENDPOINT)
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (!response.ok) return 'نیست'
|
||||
const bytes = Number(response.headers.get('content-length') ?? 0)
|
||||
// بازهٔ بدون گلیف فقط سرصفحهٔ پروتوباف است (چند ده بایت)
|
||||
return bytes > 2000 ? `${Math.round(bytes / 1024)}KB` : 'خالی'
|
||||
}
|
||||
|
||||
/** گزارش پوشش خطها برای همهٔ فونتهای باکت — ورودی تصمیم انتخاب فونت برچسبها */
|
||||
async function auditFonts(): Promise<void> {
|
||||
const fonts = await listFontFolders()
|
||||
console.log(`${fonts.length} پوشهٔ فونت در ${GLYPHS_FOLDER}:\n`)
|
||||
for (const font of fonts) {
|
||||
const cells: string[] = []
|
||||
for (const { label, range } of COVERAGE_RANGES) cells.push(`${label}=${await rangeCoverage(font, range)}`)
|
||||
console.log(` ${font.padEnd(26)} ${cells.join(' ')}`)
|
||||
}
|
||||
console.log('\nفونتی که «فارسی» و «فرم فارسی» آن خالی/نیست نباشد، برچسب فارسی را رندر میکند.')
|
||||
}
|
||||
|
||||
/**
|
||||
* اعتبارسنجی ساختار سبک — پیش و پس از تغییر اجرا میشود تا مطمئن شویم سند
|
||||
* سالم است و اسکیمای سبک (نسخه، لایهها، منابع) نشکسته است.
|
||||
*/
|
||||
function validateStyle(style: StyleDocument, label: string): void {
|
||||
const problems: string[] = []
|
||||
if (style.version !== 8) problems.push(`version باید ۸ باشد (مقدار فعلی: ${String(style.version)})`)
|
||||
if (!Array.isArray(style.layers) || style.layers.length === 0) problems.push('layers باید آرایهٔ ناتهی باشد')
|
||||
else {
|
||||
const broken = style.layers.filter((layer) => !layer || typeof layer.id !== 'string' || typeof layer.type !== 'string')
|
||||
if (broken.length) problems.push(`${broken.length} لایه بدون id/type`)
|
||||
}
|
||||
if (!style.sources || typeof style.sources !== 'object' || Array.isArray(style.sources)) {
|
||||
problems.push('sources باید یک شیء باشد')
|
||||
}
|
||||
|
||||
console.log(
|
||||
problems.length
|
||||
? `✗ اعتبارسنجی ${label}: ${problems.join(' | ')}`
|
||||
: `✓ اعتبارسنجی ${label}: ساختار سالم است (${style.layers?.length ?? 0} لایه، ${Object.keys(style.sources ?? {}).length} منبع)`,
|
||||
)
|
||||
if (problems.length) throw new Error(`ساختار سبک نامعتبر است (${label})`)
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
category: Category
|
||||
/** با این الگو روی شناسهٔ لایه تطبیق داده میشود */
|
||||
test: RegExp
|
||||
/** کدام ویژگیهای paint رنگآمیزی شوند */
|
||||
properties: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* جدول قواعد — به ترتیب بررسی میشود و نخستین تطبیق برنده است.
|
||||
* رنگهای داخل عبارتها (interpolate/match) هم بازنویسی میشوند تا پالت
|
||||
* یکدست بماند.
|
||||
*/
|
||||
const RULES: Rule[] = [
|
||||
{ category: 'background', test: /^background$/, properties: ['background-color'] },
|
||||
|
||||
// آب: خود لایههای آب و راههای آبی (برچسبها قاعدهٔ جدا دارند)
|
||||
{ category: 'water', test: /^(water|waterway)(_|$)/, properties: ['fill-color', 'line-color'] },
|
||||
|
||||
// پارکها پیش از طبیعت بررسی میشوند تا چمن/پارک زیر «طبیعت» نرود
|
||||
{ category: 'parks', test: /^(park|landcover_grass|landuse_(park|cemetery|pitch|grass))/, properties: ['fill-color', 'line-color'] },
|
||||
|
||||
// طبیعت: پوشش زمینِ جنگلی/علفزارِ بیرون از شهر
|
||||
{ category: 'nature', test: /^landcover_/, properties: ['fill-color', 'line-color'] },
|
||||
|
||||
// کاربری شهری و ساختمانها
|
||||
{ category: 'urban', test: /^landuse_/, properties: ['fill-color', 'line-color'] },
|
||||
{ category: 'urban', test: /^building/, properties: ['fill-color'] },
|
||||
|
||||
// سه ردهٔ راه — بزرگراه، خیابان، کوچه/ریل/باند فرودگاه
|
||||
{ category: 'highway', test: /^highway_motorway/, properties: ['line-color', 'fill-color'] },
|
||||
{ category: 'street', test: /^(highway_(major|minor)|road_)/, properties: ['line-color', 'fill-color'] },
|
||||
{ category: 'alley', test: /^(highway_(path|track)|railway|aeroway)/, properties: ['line-color', 'fill-color'] },
|
||||
|
||||
{ category: 'borders', test: /^boundary/, properties: ['line-color', 'fill-color'] },
|
||||
|
||||
// برچسبهای اصلی: شهر/شهرک و نام بزرگراهها
|
||||
{ category: 'mainText', test: /^(place_(city|town)|highway_name_motorway)/, properties: ['text-color'] },
|
||||
|
||||
// بقیهٔ برچسبها (روستا، محله، نام خیابان، نام آب، نقاط) — متن فرعی
|
||||
{ category: 'subText', test: /(_name|^place_|_label)/, properties: ['text-color'] },
|
||||
]
|
||||
|
||||
/** ویژگیهای فرعی که همیشه با رنگ پسزمینه هماهنگ میشوند تا خوانایی بماند */
|
||||
const HALO_PROPERTIES = ['text-halo-color', 'icon-halo-color']
|
||||
/** ویژگیهای آیکون برچسبها که با متن همرنگ میشوند */
|
||||
const ICON_PROPERTIES = ['icon-color']
|
||||
|
||||
const COLOR_PATTERN = /^(#|rgb|hsl)/i
|
||||
const isColor = (value: unknown): value is string => typeof value === 'string' && COLOR_PATTERN.test(value.trim())
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* بازنویسی رنگها (توابع خالص) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** هر رشتهٔ رنگی داخل یک مقدار paint — حتی داخل عبارتها — را با رنگ تازه عوض میکند */
|
||||
function paintColor(value: unknown, color: string, stats: { replaced: number; seen: Set<string> }): unknown {
|
||||
if (isColor(value)) {
|
||||
stats.seen.add(value.trim())
|
||||
stats.replaced += 1
|
||||
return color
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((item) => paintColor(item, color, stats))
|
||||
if (value && typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, inner] of Object.entries(value)) result[key] = paintColor(inner, color, stats)
|
||||
return result
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type LayerReport = { id: string; category: Category | null; replaced: number; colors: string[] }
|
||||
|
||||
/** یک لایه را بر پایهٔ قواعد رنگآمیزی میکند و گزارش تغییرها را برمیگرداند */
|
||||
function recolorLayer(layer: Layer): LayerReport {
|
||||
const rule = RULES.find((candidate) => candidate.test.test(layer.id))
|
||||
const stats = { replaced: 0, seen: new Set<string>() }
|
||||
|
||||
if (!layer.paint) return { id: layer.id, category: rule?.category ?? null, replaced: 0, colors: [] }
|
||||
|
||||
if (rule) {
|
||||
for (const property of rule.properties) {
|
||||
if (!(property in layer.paint)) continue
|
||||
layer.paint[property] = paintColor(layer.paint[property], PALETTE[rule.category], stats)
|
||||
}
|
||||
}
|
||||
|
||||
// لایههای برچسب: هاله و آیکونها با پالت هماهنگ میشوند
|
||||
if (layer.type === 'symbol') {
|
||||
const textColor = rule?.category === 'mainText' || rule?.category === 'subText'
|
||||
? PALETTE[rule.category]
|
||||
: PALETTE.subText
|
||||
if (rule?.category !== 'mainText' && rule?.category !== 'subText' && 'text-color' in layer.paint) {
|
||||
layer.paint['text-color'] = paintColor(layer.paint['text-color'], textColor, stats)
|
||||
}
|
||||
for (const property of [...HALO_PROPERTIES, ...ICON_PROPERTIES]) {
|
||||
if (!(property in layer.paint)) continue
|
||||
const target = property === 'icon-color' ? textColor : PALETTE.background
|
||||
layer.paint[property] = paintColor(layer.paint[property], target, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// لبهٔ ساختمانها با رنگ مرزها همخانواده میشود
|
||||
if (layer.paint['fill-outline-color'] && /^building/.test(layer.id)) {
|
||||
layer.paint['fill-outline-color'] = paintColor(layer.paint['fill-outline-color'], PALETTE.borders, stats)
|
||||
}
|
||||
|
||||
return { id: layer.id, category: rule?.category ?? null, replaced: stats.replaced, colors: [...stats.seen] }
|
||||
}
|
||||
|
||||
/** کل سند سبک را بازنویسی میکند (بدون دستزدن به ساختار و ویژگیهای غیررنگی) */
|
||||
function recolorStyle(style: StyleDocument): LayerReport[] {
|
||||
const layers = style.layers ?? []
|
||||
return layers.map(recolorLayer)
|
||||
}
|
||||
|
||||
/** گزارش فشردهٔ تغییرهای پالت — بهتفکیک دسته و لایههای دستنخورده */
|
||||
function reportPalette(reports: LayerReport[]): void {
|
||||
const byCategory = new Map<string, number>()
|
||||
for (const report of reports) {
|
||||
if (!report.replaced) continue
|
||||
const category = report.category ?? 'بدوندسته'
|
||||
byCategory.set(category, (byCategory.get(category) ?? 0) + report.replaced)
|
||||
}
|
||||
|
||||
console.log('\nگزارش تغییرها:')
|
||||
for (const [category, count] of [...byCategory].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${category.padEnd(12)} ${String(count).padStart(3)} مقدار رنگ`)
|
||||
}
|
||||
|
||||
const untouched = reports.filter((report) => !report.replaced)
|
||||
if (untouched.length) {
|
||||
console.log(`\nلایههای بدون تغییر (${untouched.length}): ${untouched.map((report) => report.id).join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* دسترسی به MinIO (S3 Signature V4 — بدون وابستگی تازه) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type Credentials = { user: string; secret: string }
|
||||
|
||||
async function readCredentials(): Promise<Credentials> {
|
||||
const envPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env')
|
||||
const fromFile: Record<string, string> = {}
|
||||
try {
|
||||
for (const line of (await readFile(envPath, 'utf8')).split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Z0-9_]+)=(.*)$/)
|
||||
if (match) fromFile[match[1]] = match[2].trim()
|
||||
}
|
||||
} catch {
|
||||
/* فایل .env اختیاری است — متغیرهای محیطی جایگزین میشوند */
|
||||
}
|
||||
|
||||
const user = process.env.MINIO_ROOT_USER || fromFile.MINIO_ROOT_USER || 'aramesh'
|
||||
const secret = process.env.MINIO_ROOT_PASSWORD || fromFile.MINIO_ROOT_PASSWORD
|
||||
if (!secret) throw new Error('MINIO_ROOT_PASSWORD پیدا نشد (نه در .env و نه در متغیرهای محیطی)')
|
||||
return { user, secret }
|
||||
}
|
||||
|
||||
const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex')
|
||||
const hmac = (key: string | Buffer, value: string) => createHmac('sha256', key).update(value).digest()
|
||||
const hmacHex = (key: Buffer, value: string) => createHmac('sha256', key).update(value).digest('hex')
|
||||
|
||||
/** PUT امضاشدهٔ S3 — همان چیزی که کنسول MinIO و پنل ادمین هم انجام میدهند */
|
||||
async function putObject(endpoint: string, objectPath: string, body: string, credentials: Credentials): Promise<void> {
|
||||
const url = new URL(objectPath, endpoint)
|
||||
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
||||
const dateStamp = amzDate.slice(0, 8)
|
||||
const payloadHash = sha256(body)
|
||||
const canonicalHeaders = `host:${url.host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
|
||||
const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
|
||||
const canonicalRequest = ['PUT', url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
|
||||
const scope = `${dateStamp}/us-east-1/s3/aws4_request`
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonicalRequest)].join('\n')
|
||||
const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.secret}`, dateStamp), 'us-east-1'), 's3'), 'aws4_request')
|
||||
const authorization =
|
||||
`AWS4-HMAC-SHA256 Credential=${credentials.user}/${scope}, ` +
|
||||
`SignedHeaders=${signedHeaders}, Signature=${hmacHex(signingKey, stringToSign)}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
'x-amz-content-sha256': payloadHash,
|
||||
'x-amz-date': amzDate,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
if (!response.ok) throw new Error(`PUT ${objectPath} → HTTP ${response.status} ${await response.text()}`)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* اجرا */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ENDPOINT = process.env.MAP_ENDPOINT ?? 'http://localhost:19000'
|
||||
const STYLE_PATH = '/maps/style.json'
|
||||
const BACKUP_PATH = '/maps/style.backup.json'
|
||||
const OUTPUT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'map-palette')
|
||||
const LOCAL_BACKUP = join(OUTPUT_DIR, 'style.backup.json')
|
||||
|
||||
/** بازگردانی: پشتیبان دقیقاً بهجای style.json نوشته میشود */
|
||||
async function restore(): Promise<void> {
|
||||
const backup = await readFile(LOCAL_BACKUP, 'utf8')
|
||||
validateStyle(JSON.parse(backup) as StyleDocument, 'نسخهٔ پشتیبان')
|
||||
await putObject(ENDPOINT, STYLE_PATH, backup, await readCredentials())
|
||||
|
||||
const restored = await (await fetch(new URL(STYLE_PATH, ENDPOINT))).text()
|
||||
console.log(
|
||||
restored === backup
|
||||
? '✓ style.json دقیقاً به نسخهٔ پشتیبان برگشت'
|
||||
: '✗ محتوای بازگرداندهشده با پشتیبان یکسان نیست — دستی بررسی کنید',
|
||||
)
|
||||
}
|
||||
|
||||
const ACTIONS = ['--audit-fonts', '--fix-labels', '--fix-glyphs', '--palette', '--restore'] as const
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const dryRun = process.argv.includes('--dry-run')
|
||||
const action = ACTIONS.find((flag) => process.argv.includes(flag))
|
||||
|
||||
// پیشفرض امن: بدون پرچم هیچ نوشتنی انجام نمیشود
|
||||
if (!action) {
|
||||
console.log(
|
||||
'کاری انجام نشد — یکی از پرچمها را بدهید:\n' +
|
||||
' --audit-fonts گزارش پوشش خطهای هر فونت در v2.0 (فقط خواندن)\n' +
|
||||
' --fix-labels برچسبهای دوزبانه + پشتهٔ فونت محلی\n' +
|
||||
' --fix-glyphs اصلاح مسیر گلیفها به v2.0\n' +
|
||||
' --palette اعمال پالت تازه\n' +
|
||||
' --restore بازگردانی از پشتیبان\n' +
|
||||
' [--dry-run] فقط گزارش، بدون نوشتن روی MinIO',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === '--audit-fonts') {
|
||||
await auditFonts()
|
||||
return
|
||||
}
|
||||
|
||||
if (action === '--restore') {
|
||||
await restore()
|
||||
return
|
||||
}
|
||||
|
||||
// ۱) خواندن سبک فعلی
|
||||
const response = await fetch(new URL(STYLE_PATH, ENDPOINT))
|
||||
if (!response.ok) throw new Error(`GET ${STYLE_PATH} → HTTP ${response.status}`)
|
||||
const original = await response.text()
|
||||
const style = JSON.parse(original) as StyleDocument
|
||||
console.log(`سبک فعلی خوانده شد — ${(original.length / 1024).toFixed(1)} کیلوبایت`)
|
||||
validateStyle(style, 'نسخهٔ فعلی')
|
||||
|
||||
// ۲) پشتیبانگیری، پیش از هر تغییر
|
||||
await mkdir(OUTPUT_DIR, { recursive: true })
|
||||
await writeFile(LOCAL_BACKUP, original, 'utf8')
|
||||
console.log(`پشتیبان روی دیسک: scripts/map-palette/style.backup.json`)
|
||||
if (!dryRun) {
|
||||
await putObject(ENDPOINT, BACKUP_PATH, original, await readCredentials())
|
||||
console.log(`پشتیبان روی MinIO: ${BACKUP_PATH}`)
|
||||
}
|
||||
|
||||
// ۳) تغییر درخواستی، سپس اعتبارسنجی دوباره
|
||||
if (action === '--fix-labels') {
|
||||
const { changed, skipped } = fixLabels(style)
|
||||
console.log(`\nلایههای برچسب اصلاحشده (${changed.length}):`)
|
||||
for (const layer of changed) {
|
||||
if (layer.field) console.log(` ${layer.id}\n متن: ${layer.field.from} → ${JSON.stringify(layer.field.to)}`)
|
||||
console.log(` فونت: ${layer.font.from} → ${layer.font.to}`)
|
||||
}
|
||||
if (skipped.length) {
|
||||
console.log(`\nلایههای بدون فیلد نام (فقط فونت اصلاح شد): ${skipped.join(', ')}`)
|
||||
}
|
||||
await verifyFonts(style)
|
||||
} else if (action === '--fix-glyphs') {
|
||||
const change = fixGlyphsPath(style)
|
||||
console.log(
|
||||
change ? `\nمسیر گلیفها: ${change.from} → ${change.to}` : '\nمسیر گلیفها از قبل به v2.0 اشاره میکند',
|
||||
)
|
||||
await verifyFonts(style)
|
||||
} else {
|
||||
reportPalette(recolorStyle(style))
|
||||
}
|
||||
validateStyle(style, 'نسخهٔ تازه')
|
||||
|
||||
// ۴) نوشتن و آپلود نسخهٔ تازه
|
||||
const updated = JSON.stringify(style, null, 2)
|
||||
await writeFile(join(OUTPUT_DIR, 'style.json'), updated, 'utf8')
|
||||
if (dryRun) {
|
||||
console.log('\nحالت آزمایشی — هیچ چیزی روی MinIO نوشته نشد.')
|
||||
return
|
||||
}
|
||||
|
||||
await putObject(ENDPOINT, STYLE_PATH, updated, await readCredentials())
|
||||
console.log(`\nسبک تازه روی MinIO نوشته شد: ${STYLE_PATH}`)
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('\nخطا:', error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -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,12 +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();
|
||||
@@ -45,17 +69,9 @@ function AnimatedRoutes() {
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
<Route path="/admin/write" element={<ArticleWritePage />} />
|
||||
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<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 />} />
|
||||
{FA_ROUTES}
|
||||
<Route path="*" element={<Home />} />
|
||||
{EN_ROUTES}
|
||||
</Routes>
|
||||
</motion.main>
|
||||
</AnimatePresence>);
|
||||
@@ -65,12 +81,13 @@ function AnimatedRoutes() {
|
||||
function AppChrome() {
|
||||
const { pathname } = useLocation();
|
||||
const { isAuthenticated } = useAdmin();
|
||||
const { data } = useSiteData();
|
||||
const data = useSite();
|
||||
const { lang } = useLang();
|
||||
const isAdminPage = pathname.startsWith('/admin');
|
||||
|
||||
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحههای جزئیات که خودشان ست میکنند
|
||||
useEffect(() => {
|
||||
if (isAdminPage || !data) return;
|
||||
if (isAdminPage) return;
|
||||
const key = seoKeyForPath(pathname);
|
||||
if (!key) return;
|
||||
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
||||
@@ -80,8 +97,9 @@ function AppChrome() {
|
||||
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
||||
description: meta?.description ?? '',
|
||||
path: pathname,
|
||||
lang,
|
||||
});
|
||||
}, [pathname, data, isAdminPage]);
|
||||
}, [pathname, data, isAdminPage, lang]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -89,6 +107,7 @@ function AppChrome() {
|
||||
<AnimatedRoutes />
|
||||
{!isAdminPage && <Footer />}
|
||||
{!isAdminPage && <FloatingContact />}
|
||||
{!isAdminPage && <ChatWidget />}
|
||||
{isAuthenticated && !isAdminPage && <AdminBar />}
|
||||
</>
|
||||
);
|
||||
@@ -96,44 +115,46 @@ function AppChrome() {
|
||||
|
||||
function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
const { t, dir, lang } = useLang();
|
||||
|
||||
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
||||
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
||||
return (
|
||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
|
||||
<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 />
|
||||
<AppChrome />
|
||||
</BrowserRouter>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('dir', 'rtl');
|
||||
document.documentElement.setAttribute('lang', 'fa');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SiteProvider>
|
||||
<AdminProvider>
|
||||
<AppShell />
|
||||
<BrowserRouter>
|
||||
<LangProvider>
|
||||
<AppShell />
|
||||
</LangProvider>
|
||||
</BrowserRouter>
|
||||
</AdminProvider>
|
||||
</SiteProvider>
|
||||
);
|
||||
|
||||
@@ -126,7 +126,7 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||
export function AdminBar() {
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
|
||||
const [pwOpen, setPwOpen] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -161,6 +161,19 @@ export function AdminBar() {
|
||||
{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]}
|
||||
@@ -175,6 +188,28 @@ export function AdminBar() {
|
||||
رمز
|
||||
</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}
|
||||
|
||||
@@ -3,9 +3,12 @@ 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 { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
|
||||
import { ENTITIES, isTranslatable } from './entities'
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
||||
export type EditLang = 'fa' | 'en'
|
||||
|
||||
interface AdminContextValue {
|
||||
isAuthenticated: boolean
|
||||
@@ -16,6 +19,9 @@ interface AdminContextValue {
|
||||
saveStatus: SaveStatus
|
||||
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||
runSave: (action: () => Promise<void>) => Promise<boolean>
|
||||
/** زبان محتوایی که در حالت ویرایش ذخیره میشود (فارسی یا انگلیسی) */
|
||||
editLang: EditLang
|
||||
toggleEditLang: () => void
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextValue | null>(null)
|
||||
@@ -54,6 +60,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
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()
|
||||
|
||||
@@ -64,6 +73,14 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
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)
|
||||
@@ -112,6 +129,18 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
return () => document.removeEventListener('click', onClick, true)
|
||||
}, [isEditMode])
|
||||
|
||||
// توکن بیرون از این provider هم پاک میشود (پاسخ 401 بارگذاری سایت، خروج در زبانه
|
||||
// دیگر) — وضعیت ورود همگام میماند و نوار ادمین برای نشست مرده باقی نمیماند
|
||||
useEffect(() => {
|
||||
const syncAuth = () => setIsAuthenticated(!!getToken())
|
||||
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||
window.addEventListener('storage', syncAuth)
|
||||
return () => {
|
||||
window.removeEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||
window.removeEventListener('storage', syncAuth)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// در حین ذخیرهسازی، از بستهشدن تصادفی صفحه جلوگیری میشود
|
||||
useEffect(() => {
|
||||
if (saveStatus !== 'saving') return
|
||||
@@ -131,8 +160,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
logout,
|
||||
saveStatus,
|
||||
runSave,
|
||||
editLang,
|
||||
toggleEditLang,
|
||||
}),
|
||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave],
|
||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
|
||||
)
|
||||
|
||||
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
||||
@@ -141,9 +172,14 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
/**
|
||||
* پرکاربردترین کمکساز ویرایش: یک فیلد واحد را PATCH میکند و پس از موفقیت
|
||||
* همان تغییر را بهصورت خوشبینانه در درخت داده سایت اعمال میکند.
|
||||
*
|
||||
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت میشوند؛ چون
|
||||
* برخی mutatorهای کامپوننتها نام فیلد را جداگانه مینویسند، مقدار قبلی فیلدهای
|
||||
* فارسی اسنپشات میشود و پس از اجرای mutator بازگردانده میشود تا متن انگلیسی
|
||||
* هرگز جای متن فارسی در درخت خام ننشیند.
|
||||
*/
|
||||
export function useFieldSaver() {
|
||||
const { runSave } = useAdmin()
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { updateSite } = useSiteData()
|
||||
|
||||
return useCallback(
|
||||
@@ -154,9 +190,50 @@ export function useFieldSaver() {
|
||||
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(apply)
|
||||
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],
|
||||
[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],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { ArrowRightIcon, CheckIcon, EyeIcon, PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
@@ -8,22 +9,118 @@ import { adminApi } from './adminClient'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import type { ArticleBodyDoc } from '../data/site'
|
||||
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
|
||||
|
||||
type Draft = {
|
||||
id: number
|
||||
slug: string
|
||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
|
||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
|
||||
|
||||
/**
|
||||
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت میشوند:
|
||||
* عنوان (مبنای Alt پویا)، دستهبندی و متادیتای تصویر شاخص.
|
||||
* `category` در فرم یک شیء تکمقداری {value,label} یا null است
|
||||
* که هنگام ذخیره به رشته (ستون category در بکاند) نگاشت میشود.
|
||||
*/
|
||||
type ArticleMetaForm = {
|
||||
title: string
|
||||
date: string
|
||||
category: string
|
||||
image: string
|
||||
read: string
|
||||
excerpt: string
|
||||
body: ArticleBodyDoc
|
||||
status: 'draft' | 'published'
|
||||
seo_keyphrase: 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 []
|
||||
}
|
||||
|
||||
/** یک ردیف پرسشوپاسخ مقاله */
|
||||
type ArticleFaq = { question: string; answer: string }
|
||||
|
||||
/**
|
||||
* پنل پرسشوپاسخهای پایان مقاله — مثل بخش متا دیسکریپشن زیر ویرایشگر.
|
||||
* در زبان فارسی ستون faqs و در زبان انگلیسی faqs_en را ویرایش میکند؛
|
||||
* نسخه انگلیسیِ خالی در سایت به فارسی برمیگردد. ذخیرهسازی خودکار است.
|
||||
*/
|
||||
function FaqPanel({
|
||||
faqs,
|
||||
onChange,
|
||||
}: {
|
||||
faqs: ArticleFaq[]
|
||||
onChange: (next: ArticleFaq[]) => void
|
||||
}) {
|
||||
const update = (index: number, patch: Partial<ArticleFaq>) => {
|
||||
const next = faqs.map((f, i) => (i === index ? { ...f, ...patch } : f))
|
||||
onChange(next)
|
||||
}
|
||||
const remove = (index: number) => onChange(faqs.filter((_, i) => i !== index))
|
||||
const add = () => onChange([...faqs, { question: '', answer: '' }])
|
||||
|
||||
return (
|
||||
<div className="mt-4 grid gap-4">
|
||||
{faqs.map((faq, i) => (
|
||||
<div key={i} className="grid gap-3 border border-ivory/10 bg-ink/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-light text-copper">پرسش {i + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(i)}
|
||||
aria-label={`حذف پرسش ${i + 1}`}
|
||||
className="flex h-7 w-7 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={faq.question}
|
||||
onChange={(e) => update(i, { question: e.target.value })}
|
||||
placeholder="پرسش مخاطب…"
|
||||
aria-label={`متن پرسش ${i + 1}`}
|
||||
className="w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
rows={2}
|
||||
dir="auto"
|
||||
value={faq.answer}
|
||||
onChange={(e) => update(i, { answer: e.target.value })}
|
||||
placeholder="پاسخ کوتاه و روشن…"
|
||||
aria-label={`پاسخ پرسش ${i + 1}`}
|
||||
className="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>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="flex w-fit items-center gap-2 border border-dashed border-ivory/25 px-4 py-2 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
افزودن پرسشوپاسخ
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
@@ -37,7 +134,7 @@ const STATUS_TEXT: Record<string, string> = {
|
||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||
*/
|
||||
export function ArticleWritePage() {
|
||||
const { isAuthenticated, saveStatus, runSave } = useAdmin()
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { data, updateSite } = useSiteData()
|
||||
@@ -50,16 +147,51 @@ export function ArticleWritePage() {
|
||||
id: 0,
|
||||
slug: `article-${Date.now()}`,
|
||||
title: '',
|
||||
date: new Date().toLocaleDateString('fa-IR'),
|
||||
category: 'یادداشت',
|
||||
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
|
||||
@@ -90,11 +222,27 @@ export function ArticleWritePage() {
|
||||
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساختهشده یکبار مقداردهی میشود
|
||||
useEffect(() => {
|
||||
if (!article || createdRef.current) return
|
||||
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
|
||||
setDraft({
|
||||
...article,
|
||||
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
|
||||
...(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])
|
||||
|
||||
@@ -190,6 +338,15 @@ export function ArticleWritePage() {
|
||||
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">
|
||||
{/* نوار بالا */}
|
||||
@@ -205,6 +362,18 @@ export function ArticleWritePage() {
|
||||
</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]}
|
||||
@@ -239,7 +408,7 @@ export function ArticleWritePage() {
|
||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||
</button>
|
||||
<Link
|
||||
to={`/blog/${draft.slug}`}
|
||||
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"
|
||||
>
|
||||
@@ -270,49 +439,121 @@ export function ArticleWritePage() {
|
||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||
</p>
|
||||
|
||||
{/* عنوان */}
|
||||
<input
|
||||
type="text"
|
||||
{...textField('title')}
|
||||
placeholder="عنوان مقاله…"
|
||||
aria-label="عنوان مقاله"
|
||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||
{/* عنوان — با 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
|
||||
doc={draft.body as unknown as Record<string, unknown>}
|
||||
onChange={(json) => queueSave({ body: json })}
|
||||
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>
|
||||
|
||||
{/* پرسشوپاسخهای پایان مقاله — سئوی FAQPage */}
|
||||
<section className="mt-8 border border-ivory/15 bg-char p-6">
|
||||
<h2 className="text-sm font-bold text-copper">پرسشهای متداول مقاله</h2>
|
||||
<p className="mt-1 text-xs font-light text-ivory/50">
|
||||
در پایان مقاله با ظاهر آکاردئونی سایت نمایش داده میشود و با اسکیمای FAQPage
|
||||
شانس نمایش در نتایج غنی گوگل را بالا میبرد.
|
||||
{editLang === 'en' && ' — ویرایش نسخه انگلیسی (خالی = نمایش متن فارسی)'}
|
||||
</p>
|
||||
<FaqPanel
|
||||
faqs={(editLang === 'en' ? draft.faqs_en : draft.faqs) ?? []}
|
||||
onChange={(next) => queueSave({ [suf('faqs')]: next })}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* چکیده و متادیتا */}
|
||||
<SeoChecklist
|
||||
input={{
|
||||
title: draft.title,
|
||||
slug: draft.slug,
|
||||
excerpt: draft.excerpt,
|
||||
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>
|
||||
<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>
|
||||
@@ -324,37 +565,83 @@ export function ArticleWritePage() {
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
{...textField('excerpt')}
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
|
||||
<div 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-2 gap-5 lg:grid-cols-4">
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">دسته</label>
|
||||
<input
|
||||
type="text"
|
||||
{...textField('category')}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
{...textField('date')}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<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>
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
|
||||
<input
|
||||
type="text"
|
||||
{...textField('read')}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
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>
|
||||
|
||||
@@ -5,8 +5,12 @@ 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 = {
|
||||
@@ -20,11 +24,16 @@ type EntityDrawerProps = {
|
||||
|
||||
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
|
||||
const save = useFieldSaver()
|
||||
const { runSave } = useAdmin()
|
||||
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()
|
||||
@@ -111,21 +120,46 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="grid gap-6">
|
||||
{config.fields.map((field) => (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
? (fn) => {
|
||||
flushRef.current = fn
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
|
||||
@@ -150,16 +184,36 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
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
|
||||
@@ -167,15 +221,81 @@ function FieldControl({
|
||||
}
|
||||
|
||||
if (field.type === 'image') {
|
||||
return <ImageField label={field.label} value={String(value ?? '')} onSave={(url) => onSave(url)} />
|
||||
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} />
|
||||
return (
|
||||
<WysiwygField
|
||||
field={field}
|
||||
value={value}
|
||||
onSave={onSave}
|
||||
registerFlush={registerFlush}
|
||||
hint={placeholder ? 'خالی = بازدیدکننده متن فارسی را میبیند' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
return <ListField field={field} value={value} onSave={onSave} />
|
||||
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') {
|
||||
@@ -185,6 +305,7 @@ function FieldControl({
|
||||
<textarea
|
||||
dir="auto"
|
||||
rows={4}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
@@ -201,6 +322,7 @@ function FieldControl({
|
||||
<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}
|
||||
@@ -210,8 +332,8 @@ function FieldControl({
|
||||
)
|
||||
}
|
||||
|
||||
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
||||
function ListField({
|
||||
/** فیلد هشتگها — react-select قابل ساخت (Creatable) با ذخیره فوری */
|
||||
function TagsField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
@@ -219,6 +341,36 @@ function ListField({
|
||||
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[]) : [])
|
||||
|
||||
@@ -231,6 +383,7 @@ function ListField({
|
||||
<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">
|
||||
@@ -278,16 +431,17 @@ function WysiwygField({
|
||||
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>>(() =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
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)
|
||||
@@ -328,6 +482,7 @@ function WysiwygField({
|
||||
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>
|
||||
|
||||
@@ -3,10 +3,14 @@ import { API_URL } from '../config'
|
||||
|
||||
const TOKEN_KEY = 'aramesh_admin_token'
|
||||
|
||||
/** رویداد تغییر وضعیت ورود — لایههای دیگر (مثل نوار ادمین) با آن همگام میمانند */
|
||||
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
|
||||
|
||||
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)
|
||||
window.dispatchEvent(new CustomEvent(AUTH_CHANGE_EVENT))
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
@@ -83,15 +87,40 @@ export const adminApi = {
|
||||
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 }> => {
|
||||
upload: async (file: File, name?: string): Promise<{ url: string; key: string }> => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (name && name.trim()) fd.append('name', name.trim())
|
||||
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
|
||||
},
|
||||
|
||||
/** درونریزی تصویر از نشانی خارجی — دانلود و ذخیره در Object Storage خود سایت */
|
||||
importImage: async (url: string): Promise<{ url: string; key: string }> =>
|
||||
authFetch('/api/admin/import-image', { method: 'POST', body: JSON.stringify({ url }) }),
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,51 +1,74 @@
|
||||
import type { SiteData } from '../api'
|
||||
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg'
|
||||
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 = {
|
||||
/** مسیر منبع در پنل مدیریت، مثل services یا why-us */
|
||||
resource: string
|
||||
/** کلید همان منبع در پاسخ GET /api/site */
|
||||
dataKey: string
|
||||
title: string
|
||||
/** مجموعه یا رکورد یگانه */
|
||||
kind: 'collection' | 'singleton'
|
||||
/** فیلدی که عنوان آیتم را نشان میدهد */
|
||||
titleKey: string
|
||||
fields: FieldConfig[]
|
||||
/** مقادیر پیشفرض برای «افزودن» */
|
||||
defaults: Record<string, unknown>
|
||||
}
|
||||
|
||||
const F = (name: string, label: string, type: FieldType = 'text', hint?: string): FieldConfig => ({
|
||||
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')],
|
||||
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)'), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'),
|
||||
F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'),
|
||||
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'),
|
||||
],
|
||||
@@ -57,14 +80,32 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
fields: [
|
||||
F('title', 'عنوان'),
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده', 'textarea'),
|
||||
F('image', 'تصویر شاخص', 'image'),
|
||||
F('category', 'دسته'),
|
||||
F('date', 'تاریخ'),
|
||||
F('read', 'زمان مطالعه'),
|
||||
F('slug', 'نشانی (slug)'),
|
||||
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: '', category: '', image: '', read: '', excerpt: '', body: { type: 'doc', content: [{ type: 'paragraph' }] } },
|
||||
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',
|
||||
@@ -87,13 +128,13 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
timeline: {
|
||||
resource: 'timeline', dataKey: 'timeline', title: 'تایملاین', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image')],
|
||||
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'), F('suffix', 'پسوند (مثل + یا هزار)')],
|
||||
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number', undefined, true), F('suffix', 'پسوند (مثل + یا هزار)')],
|
||||
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
|
||||
},
|
||||
why_us: {
|
||||
@@ -123,50 +164,47 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
gallery: {
|
||||
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
|
||||
titleKey: 'alt',
|
||||
fields: [F('src', 'تصویر', 'image'), F('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)')],
|
||||
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'),
|
||||
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'), F('hero', 'تصویر هیرو', 'image'), F('contact_bg', 'پسزمینه بخش تماس', 'image'), F('phone', 'تلفن نمایشی'), F('phone_href', 'لینک تلفن'), F('email', 'ایمیل'), F('address', 'نشانی', 'textarea')],
|
||||
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', 'لینک دکمه اصلی'), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم'), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||
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('quote', 'نقلقول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقلقول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه')],
|
||||
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'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||
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: {
|
||||
@@ -197,6 +235,21 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
},
|
||||
}
|
||||
|
||||
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
|
||||
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 {
|
||||
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
|
||||
}
|
||||
}
|
||||
@@ -2,12 +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 { getToken, setToken } 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
|
||||
@@ -15,105 +18,157 @@ 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; href: 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; lead: string; image: 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; desc: string; points: string[] }
|
||||
export type ProcessStepItem = { id: number; step: string; title: string; desc: string }
|
||||
export type OfficeItem = { id: number; city: string; address: string; hours: string }
|
||||
export type GalleryImageItem = { id: number; src: string; alt: string }
|
||||
export type StatusLabelItem = { id: number; key: string; label: string }
|
||||
export type 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 }
|
||||
@@ -121,12 +176,19 @@ 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 {
|
||||
@@ -154,7 +216,7 @@ export interface SiteData {
|
||||
contact_section: ContactSectionContent
|
||||
newsletter: NewsletterContent
|
||||
footer: FooterContent
|
||||
seo_meta: { id: number; page_key: string; title: string; description: string }[]
|
||||
seo_meta: { id: number; page_key: string; title: string; title_en?: string; description: string; description_en?: string }[]
|
||||
}
|
||||
|
||||
interface SiteContextValue {
|
||||
@@ -168,10 +230,16 @@ interface SiteContextValue {
|
||||
const SiteContext = createContext<SiteContextValue>({
|
||||
data: null,
|
||||
error: false,
|
||||
// پیشفرضهای بیاثر — مصرفکننده بیرون از Provider هم بدون خطا رندر میشود
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
reload: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
updateSite: () => {},
|
||||
})
|
||||
|
||||
/** سقف انتظار داده سایت — سرور بیپاسخ یعنی صفحه خطا با دکمه تلاش دوباره، نه انتظار بیپایان */
|
||||
const SITE_TIMEOUT_MS = 15_000
|
||||
|
||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const [data, setData] = useState<SiteData | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
@@ -179,7 +247,9 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const load = useCallback(() => {
|
||||
let cancelled = false
|
||||
setError(false)
|
||||
fetch(`${API_URL}/api/site`)
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
|
||||
fetch(`${API_URL}/api/site`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<SiteData>
|
||||
@@ -193,14 +263,17 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
if (res.status === 401) {
|
||||
// توکن منقضی/نامعتبر — پاک میشود تا بارگذاریهای بعدی درخواست بیفایده نفرستند
|
||||
setToken(null)
|
||||
} else if (res.ok) {
|
||||
const all = (await res.json()) as Article[]
|
||||
const published = new Set(json.articles.map((a) => a.id))
|
||||
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
||||
json.articles = [...json.articles, ...drafts]
|
||||
}
|
||||
} catch {
|
||||
/* بدون پیشنویسها ادامه بده */
|
||||
/* بدون پیشنویسها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
@@ -213,6 +286,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -244,13 +318,16 @@ 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 = {
|
||||
@@ -267,19 +344,25 @@ const EMPTY_HEADING: SectionHeadingContent = {
|
||||
/** تیتر و توضیح یک بخش بر اساس key تعریفشده در پنل مدیریت */
|
||||
export function useSectionHeading(key: string): SectionHeadingContent {
|
||||
const { data } = useContext(SiteContext)
|
||||
return data?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
|
||||
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 (
|
||||
data?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
|
||||
localized?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
|
||||
)
|
||||
}
|
||||
|
||||
/** برچسب فارسی وضعیت پروژه (done | progress) از پنل مدیریت */
|
||||
/** برچسب وضعیت پروژه (done | progress) به زبان جاری — از پنل مدیریت */
|
||||
export function useStatusLabel(key: string): string {
|
||||
const { data } = useContext(SiteContext)
|
||||
return data?.status_labels.find((s) => s.key === key)?.label ?? key
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return localized?.status_labels.find((s) => s.key === key)?.label ?? key
|
||||
}
|
||||
|
||||
@@ -1,49 +1,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 overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`خواندن مقاله ${article.title}`}
|
||||
to={href(`/blog/${article.slug}`)}
|
||||
className="group flex h-full flex-col overflow-hidden rounded-xl md: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="relative overflow-hidden">
|
||||
<div className="relative overflow-hidden aspect-[16/10] w-full">
|
||||
<img
|
||||
src={article.image}
|
||||
alt={`تصویر مقاله ${article.title}`}
|
||||
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
alt={`${t('articleImagePrefix')} ${article.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 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)]">
|
||||
<div className="absolute top-3 ltr:left-3 rtl:right-3 md:top-4 md:ltr:left-4 md:rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-2.5 py-0.5 md:px-3.5 md:py-1 text-[11px] md:text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||
{article.category}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
<div className="flex flex-1 flex-col p-3.5 md:p-8">
|
||||
<h3 className="mb-1.5 md:mb-6 text-xs md:text-xl font-bold leading-snug md:leading-relaxed text-ivory line-clamp-2 transition-colors duration-300 group-hover:text-copper">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
|
||||
<p className="mb-2.5 md:mb-10 text-[11px] md:text-[15px] font-light leading-relaxed md:leading-loose text-ivory/60 line-clamp-2 md:line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-3">
|
||||
<span>{article.date}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||
{article.read}
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-2.5 md:pt-8 text-[11px] md:text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-2.5 md:gap-4">
|
||||
<span>{formatJalali(article.date)}</span>
|
||||
<span className="flex items-center gap-1.5 md:gap-2">
|
||||
<ClockIcon className="h-3 w-3 md:h-3.5 md:w-3.5 text-copper/80" />
|
||||
{toPersianDigits(article.read)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
|
||||
ادامه مطلب
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
<span className="inline-flex items-center gap-1.5 md:gap-2 rounded-full border border-copper/40 bg-copper/5 px-2.5 py-1 md:px-3.5 md:py-1.5 text-[11px] md: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)] rtl:group-hover:[&>svg]:-translate-x-1 ltr:group-hover:[&>svg]:translate-x-1">
|
||||
{t('readMore')}
|
||||
<ArrowLeftIcon className="h-3 w-3 md:h-3.5 md:w-3.5 ltr:-scale-x-100 transition-transform duration-300" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
|
||||
/** یک پرسشوپاسخ مقاله — همان شکل داده ستون faqs مقاله در دیتابیس */
|
||||
export type ArticleFaq = { question: string; answer: string }
|
||||
|
||||
/**
|
||||
* پرسشوپاسخهای پایان مقاله — همان رفتار آکاردئونی بخش FAQ سایت، بدون ویرایش مدیریتی.
|
||||
* در سرچ کنسول/نتایج غنی گوگل، اسکیمای FAQPage آن جداگانه تزریق میشود.
|
||||
*/
|
||||
export function ArticleFaq({ faqs }: { faqs: ArticleFaq[] }) {
|
||||
const [open, setOpen] = useState<number | null>(0)
|
||||
|
||||
if (!faqs.length) return null
|
||||
|
||||
return (
|
||||
<section className="mt-16" aria-label="پرسشهای متداول مقاله">
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
<ul className="mt-2">
|
||||
{faqs.map((faq, i) => {
|
||||
const isOpen = open === i
|
||||
return (
|
||||
<li key={i} className="border-b border-ivory/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<span
|
||||
className={`text-base font-bold md:text-lg ${
|
||||
isOpen ? 'text-copper' : 'text-ivory'
|
||||
}`}
|
||||
>
|
||||
{faq.question}
|
||||
</span>
|
||||
<PlusIcon
|
||||
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
||||
isOpen ? 'rotate-45' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pb-7 ps-2 text-sm font-light leading-9 text-ivory/60">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
|
||||
|
||||
type BaseProps = {
|
||||
children: React.ReactNode
|
||||
variant?: Variant
|
||||
className?: string
|
||||
}
|
||||
|
||||
// استایلهای فوقلوکس شیشهای (Glassmorphic) با رنگهای اختصاصی پروژه
|
||||
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
|
||||
copper: {
|
||||
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
|
||||
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
|
||||
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
|
||||
text: 'group-hover:text-char font-semibold',
|
||||
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
|
||||
},
|
||||
outline: {
|
||||
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبهها
|
||||
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
|
||||
fill: 'bg-white/10',
|
||||
text: 'group-hover:text-copper',
|
||||
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
|
||||
},
|
||||
dark: {
|
||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||
fill: 'bg-copper',
|
||||
text: 'group-hover:text-char',
|
||||
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
|
||||
},
|
||||
'dark-ivory': {
|
||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||
fill: 'bg-ivory',
|
||||
text: 'group-hover:text-char',
|
||||
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
|
||||
},
|
||||
}
|
||||
|
||||
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<>
|
||||
{/* خط انعکاس نوری شیشه در بالای دکمه */}
|
||||
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
|
||||
|
||||
{/* انیمیشن پر شدن موجی و نرم */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
|
||||
/>
|
||||
|
||||
{/* متن و آیکون دکمه */}
|
||||
<span
|
||||
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشهای
|
||||
const SHELL =
|
||||
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
|
||||
|
||||
export function ButtonLink({
|
||||
to,
|
||||
children,
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
}: BaseProps & { to: string }) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function ButtonAnchor({
|
||||
href,
|
||||
children,
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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,18 +1,21 @@
|
||||
import React, { useState } from 'react'
|
||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from './Reveal'
|
||||
import { Button } from './Button'
|
||||
import { PremiumButton } from './PremiumButton'
|
||||
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, contact_section: content } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '', website: '' })
|
||||
const [sent, setSent] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
@@ -34,7 +37,7 @@ export function ContactSection() {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonText: t('understood'),
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
@@ -62,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
|
||||
}
|
||||
|
||||
@@ -77,37 +80,159 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanOptionalLabel = `(${content.note_optional_label?.replace(/^\(+|\)+$/g, '') || 'اختیاری'})`
|
||||
|
||||
return (
|
||||
<section id="contact" className="relative overflow-hidden py-24 lg:py-32" aria-labelledby="contact-title">
|
||||
<section id="contact" className="relative w-full max-w-full overflow-x-clip border-t border-copper/15 py-16 md:py-24 lg:py-32" aria-labelledby="contact-title">
|
||||
<img
|
||||
src={brand.contact_bg}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-ink/90 via-ink/75 to-ink backdrop-blur-[2px]" />
|
||||
|
||||
<div className="relative mx-auto max-w-site px-6 lg:px-10">
|
||||
<div className="mx-auto grid max-w-6xl items-stretch gap-8 lg:grid-cols-12">
|
||||
<div className="relative mx-auto max-w-site px-4 md:px-6 lg:px-10">
|
||||
|
||||
{/* Mobile View: High-Conversion Streamlined Layout */}
|
||||
<div className="block md:hidden px-1 py-1">
|
||||
{/* Header */}
|
||||
<div className="mb-4 text-center">
|
||||
<p className="text-xs font-light tracking-wide text-copper">
|
||||
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
|
||||
</p>
|
||||
<h2 className="mt-1.5 text-xl font-bold text-ivory">
|
||||
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
|
||||
</h2>
|
||||
<p className="mt-1.5 text-xs font-light leading-relaxed text-ivory/70 max-w-sm mx-auto">
|
||||
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* High-Conversion Form Box */}
|
||||
<div className="rounded-2xl border border-white/10 bg-char/60 p-4 backdrop-blur-xl shadow-lg space-y-3">
|
||||
{sent ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-copper">
|
||||
<CheckCircle2Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-base font-bold text-ivory">
|
||||
{content.success_title}
|
||||
</h3>
|
||||
<p className="mt-2 text-xs font-light leading-relaxed text-ivory/70">
|
||||
{content.success_text}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSent(false)
|
||||
setForm({ name: '', phone: '', note: '', website: '' })
|
||||
}}
|
||||
className="mt-4 rounded-xl bg-copper px-6 py-2.5 text-xs font-bold text-char transition-all cursor-pointer"
|
||||
>
|
||||
{content.success_button_label}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
name="website_mobile"
|
||||
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-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
|
||||
{content.name_label}
|
||||
</label>
|
||||
<input
|
||||
id="contact-name-mobile"
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={content.name_placeholder}
|
||||
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="contact-phone-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
|
||||
{content.phone_field_label} <span className="text-copper">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="contact-phone-mobile"
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
required
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder={content.phone_placeholder}
|
||||
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none text-left font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="contact-note-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
|
||||
{content.note_label} <span className="font-light text-ivory/50">{cleanOptionalLabel}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-note-mobile"
|
||||
rows={2}
|
||||
value={form.note}
|
||||
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||
placeholder={content.note_placeholder}
|
||||
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none resize-none leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending}
|
||||
className="w-full py-3 rounded-xl bg-copper text-char font-bold text-xs shadow-md hover:brightness-110 active:scale-95 transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
{sending ? content.sending_label : content.submit_label}
|
||||
</button>
|
||||
|
||||
{/* Direct Call Fallback */}
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
onClick={() => track('phone-click', { from: 'contact-section-mobile-hotline' })}
|
||||
className="flex items-center justify-center gap-1.5 pt-2 text-[11px] text-ivory/60 hover:text-copper transition-colors"
|
||||
>
|
||||
<span>نیاز به گفتگوی فوری دارید؟</span>
|
||||
<span className="font-bold text-copper dir-ltr" dir="ltr">{brand.phone}</span>
|
||||
</a>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop View */}
|
||||
<div className="hidden md:grid mx-auto 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">
|
||||
@@ -150,6 +275,7 @@ export function ContactSection() {
|
||||
<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"
|
||||
>
|
||||
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
|
||||
@@ -192,20 +318,29 @@ export function ContactSection() {
|
||||
<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"
|
||||
variant="outline"
|
||||
className="mt-8 font-light"
|
||||
<PremiumButton
|
||||
onClick={() => {
|
||||
setSent(false)
|
||||
setForm({ name: '', phone: '', note: '' })
|
||||
setForm({ name: '', phone: '', note: '', website: '' })
|
||||
}}
|
||||
className="mt-8 font-light"
|
||||
>
|
||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||
</Button>
|
||||
</PremiumButton>
|
||||
</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-medium text-ivory/90">
|
||||
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
|
||||
@@ -232,13 +367,13 @@ export function ContactSection() {
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder={content.phone_placeholder}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-right text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
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-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>
|
||||
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{cleanOptionalLabel}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-note"
|
||||
@@ -251,26 +386,23 @@ export function ContactSection() {
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-start">
|
||||
<Button
|
||||
<PremiumButton
|
||||
type="submit"
|
||||
variant="copper"
|
||||
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? (
|
||||
content.sending_label
|
||||
) : (
|
||||
<>
|
||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
</>
|
||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||
)}
|
||||
</Button>
|
||||
</PremiumButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
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 border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.3),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
|
||||
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
|
||||
@@ -17,12 +21,6 @@ export function FloatingContact() {
|
||||
className="pointer-events-none absolute inset-0 rounded-full border border-copper/60 animate-ping opacity-30"
|
||||
/>
|
||||
|
||||
{/* خط انعکاس نوری کریستال در بالای گوی */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/25 to-transparent"
|
||||
/>
|
||||
|
||||
{/* آیکون تلفن با انیمیشن چرخش ظریف در زمان هاور */}
|
||||
<PhoneIcon className="relative z-10 h-6 w-6 transition-transform duration-300 ease-lux group-hover:rotate-12" />
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
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';
|
||||
@@ -14,7 +15,9 @@ const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
||||
|
||||
export function Footer() {
|
||||
const { brand, services, nav, footer } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
const [openSection, setOpenSection] = useState<string | null>(null);
|
||||
|
||||
const saveFooter = (field: string, value: string) =>
|
||||
save('footer', footer.id, { [field]: value }, (d) => {
|
||||
@@ -32,19 +35,22 @@ export function Footer() {
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<footer className="relative w-full max-w-full overflow-x-clip border-t border-white/10 bg-char/95 backdrop-blur-2xl">
|
||||
<div className="mx-auto max-w-site px-4 py-8 md:px-6 md:py-16 lg:px-10">
|
||||
|
||||
{/* Mobile View: Collapsible Luxury Accordions */}
|
||||
<div className="block md:hidden">
|
||||
{/* Top Brand Block */}
|
||||
<div className="mb-6 flex flex-col items-center text-center">
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
<img src={brand.logo} alt={brand.name} className="h-16 w-auto object-contain" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند" className="absolute top-1/2 -translate-y-1/2 ltr:-right-8 rtl:-left-8" />
|
||||
</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 className="mt-4 text-xs font-light leading-relaxed text-ivory/60 max-w-xs">
|
||||
{footer.about_text}
|
||||
</p>
|
||||
{/* آیکونهای شبکههای اجتماعی: گویهای شیشهای شناور */}
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
{/* Social icons */}
|
||||
<div className="mt-4 flex items-center justify-center gap-3">
|
||||
{footer.socials.map(({ label, href, icon }) => {
|
||||
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
||||
return (
|
||||
@@ -52,100 +58,234 @@ export function Footer() {
|
||||
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"
|
||||
className="group flex h-10 w-10 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:border-copper/70 hover:bg-copper/10 hover:text-copper active:scale-95"
|
||||
>
|
||||
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
|
||||
<Icon className="h-3.5 w-3.5 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">
|
||||
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<Link
|
||||
to={item.href}
|
||||
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
</Link>
|
||||
{/* Collapsible Link Groups */}
|
||||
<div className="border-t border-white/10">
|
||||
{/* Pages Accordion */}
|
||||
<div className="border-b border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenSection(openSection === 'pages' ? null : 'pages')}
|
||||
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
|
||||
>
|
||||
<span>{footer.pages_heading}</span>
|
||||
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'pages' ? 'rotate-180' : ''}`}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'pages' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<ul className="space-y-2 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
|
||||
{nav.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
to={href(item.href)}
|
||||
className="block py-1 transition-colors hover:text-copper"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services Accordion */}
|
||||
<div className="border-b border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenSection(openSection === 'services' ? null : 'services')}
|
||||
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
|
||||
>
|
||||
<span>{footer.services_heading}</span>
|
||||
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'services' ? 'rotate-180' : ''}`}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'services' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<ul className="space-y-2 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
|
||||
{services.map((service) => (
|
||||
<li key={service.id} className="py-1">
|
||||
{service.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Accordion */}
|
||||
<div className="border-b border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenSection(openSection === 'contact' ? null : 'contact')}
|
||||
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
|
||||
>
|
||||
<span>{footer.contact_heading}</span>
|
||||
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'contact' ? 'rotate-180' : ''}`}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'contact' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<ul className="space-y-3 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
|
||||
<li className="flex items-start gap-2.5">
|
||||
<MapPinIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-copper" />
|
||||
<span className="leading-relaxed">{brand.address}</span>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4">
|
||||
<AddItemButton resource="nav" />
|
||||
<li className="flex items-center gap-2.5">
|
||||
<PhoneIcon className="h-3.5 w-3.5 shrink-0 text-copper" />
|
||||
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.phone}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-2.5">
|
||||
<MailIcon className="h-3.5 w-3.5 shrink-0 text-copper" />
|
||||
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.email}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{services.map((service) => (
|
||||
<li key={service.id} className="text-sm font-light text-ivory/60">
|
||||
{service.title}
|
||||
{/* Bottom Copyright & Meta for Mobile */}
|
||||
<div className="mt-6 flex flex-col items-center gap-3 border-t border-white/10 pt-4 text-[11px] text-ivory/50 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-3.5 py-1.5 text-xs font-medium text-ivory/70 backdrop-blur-xl transition-all hover:border-copper hover:bg-copper/10 hover:text-copper active:scale-95 cursor-pointer mb-1"
|
||||
>
|
||||
<span>{t('backToTop')}</span>
|
||||
<ArrowUpIcon className="h-3 w-3 text-copper transition-transform duration-300 group-hover:-translate-y-0.5" />
|
||||
</button>
|
||||
<p>{footer.copyright}</p>
|
||||
<p className="text-[10px] text-ivory/40">{footer.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop View: Multi-Column Layout */}
|
||||
<div className="hidden md:block">
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
<div className="lg:col-span-4">
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute 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-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">
|
||||
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{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">
|
||||
<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.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">
|
||||
<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" />
|
||||
<span className="leading-7">{brand.address}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<li className="flex items-center gap-3">
|
||||
<PhoneIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.phone}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<MailIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.email}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
|
||||
</h3>
|
||||
<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" />
|
||||
<span className="leading-7">{brand.address}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<PhoneIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.phone}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<MailIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.email}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
|
||||
<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>
|
||||
|
||||
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
|
||||
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||
<p>
|
||||
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>بازگشت به بالا</span>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p>
|
||||
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,15 @@ import React, { useEffect, useState } from "react";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
||||
import { useSite } from "../api";
|
||||
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, nav } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveNav = (id: number, changes: Record<string, unknown>) =>
|
||||
@@ -40,7 +43,7 @@ export function Header() {
|
||||
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
|
||||
<div className="relative flex items-center">
|
||||
<Link
|
||||
to="/"
|
||||
to={href("/")}
|
||||
className="flex items-center gap-3"
|
||||
aria-label={brand.name}
|
||||
>
|
||||
@@ -52,16 +55,16 @@ export function Header() {
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
<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) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
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"
|
||||
@@ -102,12 +105,15 @@ export function Header() {
|
||||
<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 rounded-full border border-white/15 bg-white/5 text-ivory backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 xl:hidden"
|
||||
aria-label={open ? "بستن منو" : "باز کردن منو"}
|
||||
aria-label={open ? t("closeMenu") : t("openMenu")}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? (
|
||||
@@ -121,7 +127,7 @@ export function Header() {
|
||||
|
||||
{open && (
|
||||
<nav
|
||||
aria-label="ناوبری موبایل"
|
||||
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">
|
||||
@@ -129,7 +135,7 @@ export function Header() {
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
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"
|
||||
@@ -149,4 +155,4 @@ export function Header() {
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
||||
import { track } from '../analytics'
|
||||
|
||||
/**
|
||||
* تنظیمات ثابت هر زبان.
|
||||
* ترتیب اینجا = ترتیب نمایش در کپسول (فارسی سمت چپ، English سمت راست).
|
||||
* بجای مقادیر پیکسلی، از translate-x-full استفاده شده تا دقیقاً به اندازه عرض خودش (50%) جابجا شود.
|
||||
*/
|
||||
const LANGS = {
|
||||
fa: { label: 'فارسی', sliderTranslate: 'translate-x-0' },
|
||||
en: { label: 'English', sliderTranslate: 'translate-x-full' },
|
||||
} as const
|
||||
|
||||
type LangKey = keyof typeof LANGS
|
||||
|
||||
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
||||
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')}
|
||||
dir="ltr"
|
||||
className={[
|
||||
// ── Capsule shell ──────────────────────────────────────────────
|
||||
'relative flex items-center',
|
||||
'w-[152px] h-[42px] rounded-full p-1', // استفاده از p-1 (4px) به جای [4px]
|
||||
'bg-[#1a1715]',
|
||||
// deep inset shadow → recessed track feel
|
||||
'shadow-[inset_0_3px_7px_rgba(0,0,0,0.75),inset_0_-1px_2px_rgba(255,255,255,0.04)]',
|
||||
// interaction
|
||||
'cursor-pointer select-none',
|
||||
'active:scale-[0.975] transition-transform duration-150 ease-lux',
|
||||
className,
|
||||
].join(' ')}
|
||||
>
|
||||
{/* ── Inner Track ───────────────────────────────────────────────
|
||||
این دیو داخلی باعث میشود پدینگ دکمه اصلی روی محاسبات 50 درصدی تاثیر نگذارد
|
||||
*/}
|
||||
<div className="relative w-full h-full flex items-center">
|
||||
|
||||
{/* ── Slider pill ─────────────────────────────────────────────── */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
'absolute top-0 left-0', // چسبیده به لبههای Track داخلی
|
||||
'w-1/2 h-full rounded-full', // دقیقاً 50 درصد عرض و کل ارتفاع
|
||||
'bg-[#27221c] border border-[#d5874c]',
|
||||
// subtle raised shadow — tactile without being flashy
|
||||
'shadow-[0_2px_6px_rgba(0,0,0,0.55),0_0_0_1px_rgba(213,135,76,0.18),inset_0_1px_1px_rgba(255,255,255,0.06)]',
|
||||
'transition-transform duration-[380ms] ease-lux',
|
||||
LANGS[lang as LangKey].sliderTranslate,
|
||||
].join(' ')}
|
||||
/>
|
||||
|
||||
{/* ── Labels ──────────────────────────────────────────────────── */}
|
||||
<span className="relative z-10 flex w-full h-full">
|
||||
{(Object.keys(LANGS) as LangKey[]).map((key) => {
|
||||
const isActive = lang === key
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className={[
|
||||
// each label occupies exactly half the capsule
|
||||
'w-1/2 h-full flex justify-center items-center',
|
||||
'text-[11.5px] font-semibold tracking-wide leading-none',
|
||||
'transition-colors duration-[380ms] ease-lux',
|
||||
isActive ? 'text-[#d5874c]' : 'text-[#6a6662]',
|
||||
].join(' ')}
|
||||
>
|
||||
{LANGS[key].label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||