Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d25db9367c | ||
|
|
1d29e50852 | ||
|
|
851e3fc2dc | ||
|
|
74770cd99d | ||
|
|
ce0735d775 | ||
|
|
805f4594d4 | ||
|
|
7a0defc3d4 | ||
|
|
fe6957bf83 | ||
|
|
049291c109 | ||
|
|
53673d0f9b | ||
|
|
9d55f521a0 | ||
|
|
35c03ef35e | ||
|
|
bb6c586d99 | ||
|
|
20c95e73b3 |
@@ -1,4 +1,5 @@
|
|||||||
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
|
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
|
||||||
|
# راه سادهتر: bash scripts/gen-env.sh — همهٔ رمزها را تصادفی و قوی میسازد.
|
||||||
# docker compose بهطور خودکار این فایل را میخواند؛ بدون آن استک بالا نمیآید.
|
# docker compose بهطور خودکار این فایل را میخواند؛ بدون آن استک بالا نمیآید.
|
||||||
|
|
||||||
# --- رمزهای اصلی (اجباری) ---
|
# --- رمزهای اصلی (اجباری) ---
|
||||||
@@ -14,10 +15,57 @@ ADMIN_PASSWORD=change-me-admin-password
|
|||||||
# پورت ورود Traefik در توسعه (پیشفرض 5173)
|
# پورت ورود Traefik در توسعه (پیشفرض 5173)
|
||||||
TRAEFIK_HTTP_PORT=5173
|
TRAEFIK_HTTP_PORT=5173
|
||||||
# نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example
|
# نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example
|
||||||
|
# در تولید این مقدار هم پایهٔ پروب بیروننگر مانیتورینگ است.
|
||||||
SITE_URL=
|
SITE_URL=
|
||||||
# آدرسهای مجاز CORS (لیست JSON) — پیشفرض فقط لوکالهاست
|
# آدرسهای مجاز CORS (لیست JSON) — پیشفرض فقط لوکالهاست
|
||||||
# CORS_ORIGINS=["http://localhost:5173"]
|
# 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) ---
|
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
|
||||||
# DOMAIN=aramesh.example
|
# DOMAIN=aramesh.example
|
||||||
# ACME_EMAIL=admin@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
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# مانیتورینگ — کانفیگ رندرشدهٔ alertmanager (حاوی توکن بله است)
|
||||||
|
monitoring/alertmanager/alertmanager.rendered.yml
|
||||||
|
|
||||||
|
# تنظیمات محلی هر ماشین
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
# پشتیبانگیری محلی
|
# پشتیبانگیری محلی
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
@@ -22,6 +28,7 @@ dist/
|
|||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
.zcode/
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|||||||
@@ -3,15 +3,20 @@
|
|||||||
مونوریپوی سایت گروه ساختمانی آرامش شامل:
|
مونوریپوی سایت گروه ساختمانی آرامش شامل:
|
||||||
|
|
||||||
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متنها و تصاویر از API خوانده میشود)
|
- **`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 (پورت ۵۱۷۳ — تنها نقطه ورود)
|
Traefik (تنها نقطه ورود — 80/443 در تولید)
|
||||||
├── / → Caddy (فایلهای استاتیک SPA)
|
├── / → Caddy (فایلهای استاتیک SPA)
|
||||||
├── /api → FastAPI (بکاند)
|
├── /api → FastAPI (بکاند) + متریک /metrics فقط داخلی
|
||||||
└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی)
|
├── /media → MinIO (باکت aramesh-media)
|
||||||
|
├── /chat → چتبات (SSE استریم پاسخ)
|
||||||
|
├── grafana.* → Grafana (مانیتورینگ — لایه monitoring)
|
||||||
|
└── analytics.* → Umami (آنالیتیکس محصول)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانتاند هاردکد نشده؛
|
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانتاند هاردکد نشده؛
|
||||||
@@ -19,107 +24,213 @@ Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
|
|||||||
- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره میشوند و با مسیر نسبی
|
- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره میشوند و با مسیر نسبی
|
||||||
`/media/...` برگردانده میشوند — بکاند ذخیرهسازی بدون تغییر محتوای دیتابیس قابل تعویض است
|
`/media/...` برگردانده میشوند — بکاند ذخیرهسازی بدون تغییر محتوای دیتابیس قابل تعویض است
|
||||||
- مسیریابی همهچیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
|
- مسیریابی همهچیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
|
||||||
|
- شبکههای compose تفکیک شدهاند: `edge` (سرویسهای پشت Traefik) و `internal`
|
||||||
|
(دیتابیس/ذخیرهسازی) — فرانتاند هیچ راه مستقیمی به دیتابیس ندارد
|
||||||
|
- **لاگها**: json-file با سقف ۱۰MB×۳ برای هر سرویس؛ علاوه بر آن Promtail همه را به
|
||||||
|
Loki میفرستد تا در Grafana جستوجو شوند
|
||||||
|
|
||||||
## ساختار
|
## ساختار
|
||||||
|
|
||||||
```
|
```
|
||||||
├── frontend/ # سایت React
|
├── frontend/ # سایت React (بیلد روی هاست — ایمیج فقط سرو میکند)
|
||||||
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
|
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
|
||||||
│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy
|
│ ├── Caddyfile # سرو استاتیک + کش immutable برای /assets/*
|
||||||
│ └── Caddyfile # فقط سرو فایلهای استاتیک (مسیریابی روی Traefik است)
|
│ └── Dockerfile # Caddy — بدون root
|
||||||
├── backend/ # بکاند FastAPI
|
├── backend/ # بکاند FastAPI
|
||||||
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
|
│ ├── app/ # کد منبع (آپلود: app/api/admin.py — سئو: app/seo_pages.py)
|
||||||
│ ├── tests/ # تستهای API (pytest)
|
│ ├── migrations/ # مهاجرتهای نسخهدار Alembic
|
||||||
│ ├── requirements.txt
|
│ ├── tests/ # تستهای API (pytest)
|
||||||
│ ├── Dockerfile
|
│ ├── alembic.ini
|
||||||
│ └── .env.example
|
│ └── Dockerfile # بدون root — خاموشی تمیز با exec
|
||||||
├── scripts/backup.sh # پشتیبانگیری دیتابیس و تصاویر
|
├── chatbot/ # دستیار هوشمند (RAG + pgvector)
|
||||||
├── docker-compose.yml # اجرای کامل با یک دستور
|
├── monitoring/ # پیکربندی Prometheus/Alertmanager/Loki/Promtail/Grafana
|
||||||
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
|
├── 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. بیلد فرانتاند روی هاست انجام میشود (کانتینر فقط فایلهای
|
پیشنیاز: Docker Desktop. بیلد فرانتاند روی هاست انجام میشود (کانتینر فقط فایلهای
|
||||||
`dist/` را سرو میکند)، پس اولین بار باید یکبار `npm run build` بزنید:
|
`dist/` را سرو میکند) — `scripts/stack.sh` همین را به یک دستور تبدیل میکند:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمیآید
|
cp .env.example .env # یا: bash scripts/gen-env.sh (رمزهای قوی تصادفی)
|
||||||
cd frontend && npm install && npm run build && cd ..
|
cd frontend && npm install && cd ..
|
||||||
docker compose up -d --build
|
bash scripts/stack.sh deploy # بیلد فرانت + بیلد و بالا آوردن استک + انتظار برای سلامت
|
||||||
```
|
```
|
||||||
|
|
||||||
سایت: http://localhost:5173 — ورود مدیریت: `/admin/login` (کاربر/رمز از `ADMIN_*` در `.env`)
|
- سایت: http://localhost:18081 (پورت از `TRAEFIK_HTTP_PORT` در `.env`) — ورود مدیریت: `/admin/login`
|
||||||
|
- دستورهای عملیات: `bash scripts/stack.sh` — `logs`، `ps`، `health`، `backup`، `restore`، `update`، `seed-force`
|
||||||
- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی)
|
- مستندات API: http://localhost:19001/docs (با `docker-compose.override.yml` محلی)
|
||||||
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env`
|
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env`
|
||||||
- دیتابیس PostgreSQL و آبجکتاستور MinIO هر دو در کانتینر جدا با volume دائمی
|
- دیتابیس PostgreSQL (تصویر pgvector)، آبجکتاستور MinIO و کش مدلهای چتبات هر یک
|
||||||
- بکاند هنگام شروع، خودش جداول را میسازد و **فقط جدولهای خالی** را seed میکند —
|
در کانتینر جدا با volume دائمی
|
||||||
ارتقای نسخه دادههای فعلی را از بین نمیبرد
|
- بکاند هنگام شروع، جداول پایه را میسازد، **مهاجرتهای Alembic** را اعمال میکند و
|
||||||
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را میسازد، خواندن عمومی را
|
**فقط جدولهای خالی** را seed میکند — ارتقای نسخه دادههای فعلی را از بین نمیبرد
|
||||||
فعال میکند و تصاویر داخل `seed-media/` را در باکت میریزد — پس یک کلون تازه هم
|
- `minio-setup` باکت `aramesh-media` را میسازد و فقط **وقتی باکت خالی است** تصاویر
|
||||||
همان تصاویر سایت را دارد
|
`seed-media/` را میریزد — `db-init` هم دیتابیسهای `umami` و `aramesh_chatbot` را
|
||||||
|
روی همان نمونهٔ Postgres میسازد
|
||||||
|
|
||||||
> نکته برای تازهواردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
|
> نکته برای تازهواردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
|
||||||
> فایلهای `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
|
> فایلهای `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
|
||||||
|
>
|
||||||
|
> پایهٔ API فرانتاند در زمان بیلد از `VITE_API_URL` در `.env` خوانده میشود —
|
||||||
|
> در توسعه ویندوزیِ این ماشین `VITE_API_URL=http://localhost:19001` بگذارید.
|
||||||
|
|
||||||
## انتشار واقعی (TLS)
|
## انتشار واقعی (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
|
```bash
|
||||||
# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com
|
bash scripts/stack.sh deploy-prod
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Traefik گواهی Let's Encrypt میگیرد و تمدید میکند؛ همه درخواستهای HTTP به HTTPS هدایت میشوند
|
- 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
|
```bash
|
||||||
cd backend
|
bash scripts/preview-mode.sh on # فعالسازی (بار اول نام کاربری/رمز میسازد و چاپ میکند)
|
||||||
python -m venv .venv
|
bash scripts/stack.sh deploy-prod # اعمال
|
||||||
.venv\Scripts\activate # ویندوز — در لینوکس: source .venv/bin/activate
|
bash scripts/preview-mode.sh status # وضعیت فعلی
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# فایل .env از روی .env.example بسازید و DATABASE_URL را تنظیم کنید
|
|
||||||
python -m app.seed # ساخت جداول + محتوای اولیه
|
|
||||||
uvicorn app.main:app --reload
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### فرانتاند
|
- دامنه اصلی (`DOMAIN`) و دامنه دوم (`DOMAIN_ALIAS`) هر دو سرو میشوند و هر دو گیت دارند
|
||||||
|
- برای دادن دسترسی به کارفرما، همان نام کاربری/رمز مشترک را بدهید
|
||||||
|
- رمز مشترک در `.env` (`PREVIEW_AUTH_USERS`) میماند — فقط یکبار چاپ میشود
|
||||||
|
|
||||||
|
### انتشار عمومی (برداشتن پیشنمایش)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
bash scripts/preview-mode.sh off # حذف گیت ورود + اجازه ایندکس
|
||||||
npm install
|
bash scripts/stack.sh deploy-prod
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
در حالت توسعه، 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 عمومی**: برند، منو، هیرو، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم،
|
- **API عمومی**: برند، منو، هیرو، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم،
|
||||||
تایملاین، آمار، دفاتر، مدلهای سرمایهگذاری و... — همه در دیتابیس
|
تایملاین، آمار، دفاتر، مدلهای سرمایهگذاری و... — همه در دیتابیس
|
||||||
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
|
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter` — هر دو با
|
||||||
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`)
|
محدودسازی نرخ (تماس: ۵ در ۱۵ دقیقه، خبرنامه: ۳ در ساعت برای هر IP) + دامنهٔ پنهان
|
||||||
و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
(honeypot) ضدربات + میانافزار محدودسازی سیلاب روی Traefik
|
||||||
|
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (مقادیر از `ADMIN_*` در `.env` —
|
||||||
|
هیچ رمز پیشفرضی در کد وجود ندارد) و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
||||||
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
|
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
|
||||||
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد میشود
|
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد میشود
|
||||||
|
(IP واقعی کلاینت با `--proxy-headers` از پشت Traefik درست خوانده میشود)
|
||||||
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
|
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
|
||||||
ذخیره و URL نسبی `/media/...` برمیگردد
|
ذخیره و URL نسبی `/media/...` برمیگردد؛ در همان لحظه تا ۲۰۰۰ پیکسل کوچک و دوباره
|
||||||
|
فشرده میشود (Pillow) تا سرعت صفحه حفظ شود
|
||||||
|
- **خروجی CSV** از نوار مدیریت: پیامهای تماس و مشترکان خبرنامه (با BOM برای Excel)
|
||||||
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسالشده عوض میشوند
|
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسالشده عوض میشوند
|
||||||
- **ترتیببندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
|
- **ترتیببندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
|
||||||
- همه محتوا در یک درخواست: `GET /api/site` (مقالههای پیشنویس در آن نمیآیند)
|
- همه محتوا در یک درخواست: `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` را تغییر دهید
|
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را قوی
|
||||||
> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
|
> بگیرید (`bash scripts/gen-env.sh`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
|
||||||
|
|
||||||
## ویرایش زنده سایت (دوقلوی دیجیتال)
|
## ویرایش زنده سایت (دوقلوی دیجیتال)
|
||||||
|
|
||||||
@@ -157,7 +268,9 @@ npm run dev
|
|||||||
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
|
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
|
||||||
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل میکند، پس فقط تگهای شناختهشده
|
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل میکند، پس فقط تگهای شناختهشده
|
||||||
(پاراگراف، تیتر، لیست، نقلقول، تصویر، لینک) تولید میشوند. مقالات قدیمی (لیست پاراگراف)
|
(پاراگراف، تیتر، لیست، نقلقول، تصویر، لینک) تولید میشوند. مقالات قدیمی (لیست پاراگراف)
|
||||||
در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند.
|
در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند. در زمان ذخیره، آدرسهای
|
||||||
|
`href/src` سند در سمت سرور پاکسازی میشوند (`app/sanitize.py`) تا اسکیمهای خطرناک
|
||||||
|
مثل `javascript:` هرگز ذخیره نشوند.
|
||||||
|
|
||||||
## سئو
|
## سئو
|
||||||
|
|
||||||
@@ -179,24 +292,39 @@ npm run dev
|
|||||||
دیتابیس داخل آن تزریق میکند (`app/seo_pages.py`) — بازدیدکنندههای عادی همان SPA
|
دیتابیس داخل آن تزریق میکند (`app/seo_pages.py`) — بازدیدکنندههای عادی همان SPA
|
||||||
همیشگی را میبینند
|
همیشگی را میبینند
|
||||||
|
|
||||||
## پشتیبانگیری
|
## پشتیبانگیری و بازگردانی
|
||||||
|
|
||||||
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است — هر دو را با یک دستور پشتیبان بگیرید:
|
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است:
|
||||||
|
|
||||||
```bash
|
```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-<زمان>/` (آینه باکت)
|
- خروجی: `backups/db-<زمان>.sql.gz` (فشرده + فایل `.sha256`) و `backups/images-<زمان>/`
|
||||||
- زمانبندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
|
- اسکریپت پشتیبان، خروجی خالی/شکسته را خطا میگیرد، سنجهٔ تازگی برای هشدار Grafana
|
||||||
- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza`
|
مینویسد و اگر `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`
|
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در compose با سینتکس
|
||||||
با سینتکس `${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید
|
`${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید؛ هیچ رمز پیشفرضی در کد نیست
|
||||||
- رمز عبور ادمین با bcrypt هش میشود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
|
- رمز عبور ادمین با bcrypt هش میشود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
|
||||||
- ورود با محدودیت تلاش محافظت میشود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
|
- توکنهای JWT ۸ ساعت اعتبار دارند (stateless — تغییر `SECRET_KEY` همه را خارج میکند)
|
||||||
- توکنهای JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
|
- همهٔ کانتینرها **بدون root** اجرا میشوند؛ کانتکست بیلد هر ایمیج با `.dockerignore`
|
||||||
|
باریک است و لاگها سقف دارند
|
||||||
|
- فرمهای عمومی ضدرباتاند (honeypot + محدودسازی نرخ در بکاند + سقف سیلاب در Traefik)
|
||||||
|
- کنسول MinIO و API مستقیمش در تولید فقط از `127.0.0.1` در دسترساند (تونل SSH)
|
||||||
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
|
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
|
||||||
- مقالههای پیشنویس در هیچ انتهای عمومی نمیآیند و در HTML تزریقشده سئو هم افشا نمیشوند
|
- مقالههای پیشنویس در هیچ انتهای عمومی نمیآیند و در HTML تزریقشده سئو هم افشا نمیشوند
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# کانتکست بیلد باریک — .venv و کشهای تست محلی جایی در تصویر ندارند
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
tests/
|
||||||
|
.pytest_cache/
|
||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
+12
-3
@@ -1,3 +1,4 @@
|
|||||||
|
# بکاند FastAPI — تصویر باریک و بدون root
|
||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -9,8 +10,16 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY app ./app
|
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
|
EXPOSE 8000
|
||||||
|
# ساخت جداول پایه، مهاجرتهای Alembic و seed (بدون حذف دادههای موجود) و سپس
|
||||||
# ساخت جداول و seed (بدون حذف دادههای موجود) و سپس اجرای سرور
|
# اجرای سرور با exec — تا SIGTERM (docker stop) مستقیم به uvicorn برسد و خاموشی
|
||||||
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
# تمیز انجام شود؛ --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
|
||||||
+198
-16
@@ -1,10 +1,19 @@
|
|||||||
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import ipaddress
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import boto3
|
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 import func
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -263,10 +272,69 @@ ALLOWED_IMAGE_TYPES = {
|
|||||||
"image/avif": "avif",
|
"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")
|
@router.post("/upload")
|
||||||
async def upload_media(
|
async def upload_media(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
name: str = Form(""),
|
||||||
_admin: models.AdminUser = Depends(get_current_admin),
|
_admin: models.AdminUser = Depends(get_current_admin),
|
||||||
):
|
):
|
||||||
content_type = (file.content_type or "").lower()
|
content_type = (file.content_type or "").lower()
|
||||||
@@ -287,29 +355,143 @@ async def upload_media(
|
|||||||
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
|
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
width = height = None
|
||||||
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
|
if content_type in REENCODE_TYPES:
|
||||||
|
try:
|
||||||
|
data, content_type, width, height = _optimize_image(data, content_type)
|
||||||
|
except Exception:
|
||||||
|
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره میشود
|
||||||
|
pass
|
||||||
|
|
||||||
s3 = boto3.client(
|
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
|
||||||
"s3",
|
display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
|
||||||
endpoint_url=settings.s3_endpoint,
|
url = _store_image(data, content_type, display_name)
|
||||||
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 {
|
return {
|
||||||
"url": f"{settings.media_public_base}/{key}",
|
"url": url,
|
||||||
"key": key,
|
"key": url.removeprefix(f"{settings.media_public_base}/"),
|
||||||
"size": len(data),
|
"size": len(data),
|
||||||
"content_type": content_type,
|
"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])
|
@router.get("/messages", response_model=list[ContactMessageOut])
|
||||||
def list_messages(unread_only: bool = False, db: Session = Depends(get_db)):
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
|
||||||
from .. import models
|
from .. import models
|
||||||
|
from ..database import get_db
|
||||||
|
from ..rate_limit import contact_limiter, newsletter_limiter
|
||||||
from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut
|
from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut
|
||||||
|
|
||||||
router = APIRouter()
|
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)
|
@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(
|
message = models.ContactMessage(
|
||||||
name=payload.name.strip(),
|
name=payload.name.strip(),
|
||||||
phone=payload.phone.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)
|
@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()
|
email = str(payload.email).lower()
|
||||||
subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first()
|
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 import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from .. import models
|
from .. import models
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
@@ -22,9 +27,44 @@ from ..schemas import (
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/live")
|
||||||
|
def live():
|
||||||
|
"""زندهبودن پروسه — بدون وابستگی؛ برای restart gate استفاده نمیشود."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
def health():
|
def health(db: Session = Depends(get_db)):
|
||||||
return {"status": "ok", "app": "Aramesh Building Group API"}
|
"""بررسی عمق سلامت — اتصال 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)
|
@router.get("/brand", response_model=BrandOut)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ class Settings(BaseSettings):
|
|||||||
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api"
|
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api"
|
||||||
secret_key: str = "dev-secret-change-me-in-production"
|
secret_key: str = "dev-secret-change-me-in-production"
|
||||||
algorithm: str = "HS256"
|
algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 1440
|
# ۸ ساعت — توکن stateless است و امکان ابطال ندارد؛ عمر کوتاهتر ریسک نشت را کم میکند
|
||||||
|
access_token_expire_minutes: int = 480
|
||||||
cors_origins: list[str] = ["*"]
|
cors_origins: list[str] = ["*"]
|
||||||
admin_username: str = "admin"
|
admin_username: str = "admin"
|
||||||
admin_password: str = "aramesh123"
|
admin_password: str = "aramesh123"
|
||||||
@@ -31,6 +32,10 @@ class Settings(BaseSettings):
|
|||||||
site_url: str = ""
|
site_url: str = ""
|
||||||
# مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
|
# مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
|
||||||
frontend_dist: str = "../frontend/dist"
|
frontend_dist: str = "../frontend/dist"
|
||||||
|
# پیشنمایش خصوصی: سایت روی اینترنت است اما نه عمومی و نه ایندکسشدنی.
|
||||||
|
# گیت اصلی ورود در Traefik است (Basic Auth)؛ این پرچم لایه دوم است:
|
||||||
|
# هدر noindex روی همه پاسخها + robots.txt مسدودکننده.
|
||||||
|
preview_mode: bool = False
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
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)
|
||||||
+23
-2
@@ -1,15 +1,17 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
|
|
||||||
from . import models # noqa: F401 — ثبت مدلها پیش از ساخت جداول
|
from . import models # noqa: F401 — ثبت مدلها پیش از ساخت جداول
|
||||||
from .api import admin, forms, public
|
from .api import admin, forms, public
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import Base, engine
|
from .database import Base, engine
|
||||||
from .migrations import run_all as run_migrations
|
|
||||||
from .seo_pages import router as seo_router
|
from .seo_pages import router as seo_router
|
||||||
|
|
||||||
|
# ساخت جداول پایه — تغییرات بعدی اسکیما فقط با revisionهای Alembic اعمال میشوند
|
||||||
|
# (مigrations/versions). اجرای اینجا برای اجرای مستقیم uvicorn در محیط توسعه است؛
|
||||||
|
# در کانتینر، CMD همین کار را قبل از alembic upgrade head انجام میدهد.
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
run_migrations()
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Aramesh Building Group API",
|
title="Aramesh Building Group API",
|
||||||
@@ -29,6 +31,19 @@ app.add_middleware(
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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(public.router, prefix="/api", tags=["عمومی"])
|
||||||
app.include_router(forms.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 صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
|
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
|
||||||
app.include_router(seo_router, tags=["سئو"])
|
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("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
|
|||||||
@@ -145,6 +145,17 @@ def run_ddl_migrations() -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
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 = بدون ترجمه)
|
# ستونهای انگلیسی — رشتهها با پیشفرض خالی، JSON بدون پیشفرض (NULL = بدون ترجمه)
|
||||||
for table, column, col_type in EN_COLUMNS:
|
for table, column, col_type in EN_COLUMNS:
|
||||||
if col_type == "JSON":
|
if col_type == "JSON":
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
|
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
@@ -95,12 +96,18 @@ class Article(Base):
|
|||||||
category: Mapped[str] = mapped_column(String(64), default="")
|
category: Mapped[str] = mapped_column(String(64), default="")
|
||||||
category_en: Mapped[str] = mapped_column(String(64), default="")
|
category_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
image: Mapped[str] = mapped_column(Text, 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: Mapped[str] = mapped_column(String(32), default="")
|
||||||
read_en: Mapped[str] = mapped_column(String(32), default="")
|
read_en: Mapped[str] = mapped_column(String(32), default="")
|
||||||
excerpt: Mapped[str] = mapped_column(Text, default="")
|
excerpt: Mapped[str] = mapped_column(Text, default="")
|
||||||
excerpt_en: Mapped[str] = mapped_column(Text, default="")
|
excerpt_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
|
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
|
||||||
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند 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 — پیشنویس فقط برای ادمین دیده میشود
|
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||||
|
|||||||
+66
-15
@@ -1,38 +1,74 @@
|
|||||||
"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login.
|
"""محدودسازی نرخ درخواست — پنجرهٔ لغزان درونحافظهای.
|
||||||
|
|
||||||
پیشفرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک میکند.
|
برای ورود ادمین (بر پایه تلاش ناموفق) و فرمهای عمومی (بر پایه هر ارسال)
|
||||||
شمارنده در حافظه همین پروسه است — با ریاستارت بکاند پاک میشود و همین کافی است.
|
استفاده میشود. شمارندهها در حافظهٔ همین پروسهاند — با ریاستارت پاک میشوند؛
|
||||||
|
به همین دلیل uvicorn تککارگر نگه داشته شده است.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import HTTPException, Request, status
|
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:
|
def client_ip(request: Request) -> str:
|
||||||
|
"""IP واقعی کلاینت — پشت Traefik از X-Forwarded-For خوانده میشود
|
||||||
|
(uvicorn با --proxy-headers هم client.host را درست میکند)."""
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
if forwarded:
|
if forwarded:
|
||||||
return forwarded.split(",")[0].strip()
|
return forwarded.split(",")[0].strip()
|
||||||
return request.client.host if request.client else "unknown"
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
def _prune(key: str, now: float) -> list[float]:
|
class RateLimiter:
|
||||||
recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS]
|
"""پنجرهٔ لغزان بر پایه کلید (عملاً IP) — رویدادها پس از پنجره منقضی میشوند."""
|
||||||
_failed[key] = recent
|
|
||||||
return recent
|
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:
|
def ensure_allowed(request: Request) -> None:
|
||||||
"""اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد میشود."""
|
"""اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد میشود."""
|
||||||
key = client_ip(request)
|
key = client_ip(request)
|
||||||
recent = _prune(key, time.monotonic())
|
now = time.monotonic()
|
||||||
if len(recent) >= MAX_FAILED_ATTEMPTS:
|
recent = [t for t in _failed.get(key, []) if now - t < LOGIN_WINDOW_SECONDS]
|
||||||
retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0])))
|
_failed[key] = recent
|
||||||
|
if len(recent) >= LOGIN_MAX_FAILED_ATTEMPTS:
|
||||||
|
retry_after = max(1, int(LOGIN_WINDOW_SECONDS - (now - recent[0])))
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
detail="تلاشهای ناموفق زیاد است — بعداً دوباره امتحان کنید",
|
detail="تلاشهای ناموفق زیاد است — بعداً دوباره امتحان کنید",
|
||||||
@@ -47,3 +83,18 @@ def record_failure(request: Request) -> None:
|
|||||||
def clear(request: Request) -> None:
|
def clear(request: Request) -> None:
|
||||||
"""با ورود موفق، تاریخچه تلاشهای ناموفق IP پاک میشود."""
|
"""با ورود موفق، تاریخچه تلاشهای ناموفق IP پاک میشود."""
|
||||||
_failed.pop(client_ip(request), None)
|
_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)
|
||||||
+22
-2
@@ -3,6 +3,8 @@ from typing import Literal, Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
|
||||||
|
|
||||||
|
from .sanitize import sanitize_doc
|
||||||
|
|
||||||
# ---------- پایه ----------
|
# ---------- پایه ----------
|
||||||
|
|
||||||
|
|
||||||
@@ -75,12 +77,17 @@ class ArticleOut(ORMModel):
|
|||||||
category: str
|
category: str
|
||||||
category_en: str = ""
|
category_en: str = ""
|
||||||
image: str
|
image: str
|
||||||
|
image_alt: str = ""
|
||||||
|
image_title: str = ""
|
||||||
read: str
|
read: str
|
||||||
read_en: str = ""
|
read_en: str = ""
|
||||||
excerpt: str
|
excerpt: str
|
||||||
excerpt_en: str = ""
|
excerpt_en: str = ""
|
||||||
body: dict
|
body: dict
|
||||||
body_en: Optional[dict] = None
|
body_en: Optional[dict] = None
|
||||||
|
# پرسشوپاسخهای پایان مقاله — [{question, answer}]؛ سطرهای قدیمی ممکن است NULL باشند
|
||||||
|
faqs: Optional[list[dict]] = None
|
||||||
|
faqs_en: Optional[list[dict]] = None
|
||||||
status: str
|
status: str
|
||||||
seo_keyphrase: str
|
seo_keyphrase: str
|
||||||
|
|
||||||
@@ -386,10 +393,14 @@ class ContactIn(BaseModel):
|
|||||||
name: str = Field(min_length=3, max_length=128)
|
name: str = Field(min_length=3, max_length=128)
|
||||||
phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$")
|
phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$")
|
||||||
note: str = Field(default="", max_length=2000)
|
note: str = Field(default="", max_length=2000)
|
||||||
|
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمیکند
|
||||||
|
website: str = Field(default="", max_length=128)
|
||||||
|
|
||||||
|
|
||||||
class NewsletterIn(BaseModel):
|
class NewsletterIn(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمیکند
|
||||||
|
website: str = Field(default="", max_length=128)
|
||||||
|
|
||||||
|
|
||||||
# ---------- ورودیهای پنل مدیریت ----------
|
# ---------- ورودیهای پنل مدیریت ----------
|
||||||
@@ -460,12 +471,13 @@ EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
|||||||
|
|
||||||
|
|
||||||
def validate_doc_body(value: object) -> dict:
|
def validate_doc_body(value: object) -> dict:
|
||||||
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد."""
|
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد و
|
||||||
|
آدرسهای href/src آن به اسکیمهای امن محدود شوند."""
|
||||||
if not value:
|
if not value:
|
||||||
return dict(EMPTY_DOC)
|
return dict(EMPTY_DOC)
|
||||||
if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list):
|
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: [...]}")
|
raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}")
|
||||||
return value
|
return sanitize_doc(value)
|
||||||
|
|
||||||
|
|
||||||
class ArticleIn(BaseModel):
|
class ArticleIn(BaseModel):
|
||||||
@@ -477,12 +489,16 @@ class ArticleIn(BaseModel):
|
|||||||
category: str = ""
|
category: str = ""
|
||||||
category_en: str = ""
|
category_en: str = ""
|
||||||
image: str = ""
|
image: str = ""
|
||||||
|
image_alt: str = ""
|
||||||
|
image_title: str = ""
|
||||||
read: str = ""
|
read: str = ""
|
||||||
read_en: str = ""
|
read_en: str = ""
|
||||||
excerpt: str = ""
|
excerpt: str = ""
|
||||||
excerpt_en: str = ""
|
excerpt_en: str = ""
|
||||||
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||||
body_en: 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"
|
status: Literal["draft", "published"] = "draft"
|
||||||
seo_keyphrase: str = Field(default="", max_length=128)
|
seo_keyphrase: str = Field(default="", max_length=128)
|
||||||
_validate_body = field_validator("body")(validate_doc_body)
|
_validate_body = field_validator("body")(validate_doc_body)
|
||||||
@@ -779,12 +795,16 @@ class ArticlePatch(BaseModel):
|
|||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
category_en: Optional[str] = None
|
category_en: Optional[str] = None
|
||||||
image: Optional[str] = None
|
image: Optional[str] = None
|
||||||
|
image_alt: Optional[str] = None
|
||||||
|
image_title: Optional[str] = None
|
||||||
read: Optional[str] = None
|
read: Optional[str] = None
|
||||||
read_en: Optional[str] = None
|
read_en: Optional[str] = None
|
||||||
excerpt: Optional[str] = None
|
excerpt: Optional[str] = None
|
||||||
excerpt_en: Optional[str] = None
|
excerpt_en: Optional[str] = None
|
||||||
body: Optional[dict] = None
|
body: Optional[dict] = None
|
||||||
body_en: 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
|
status: Optional[Literal["draft", "published"]] = None
|
||||||
seo_keyphrase: Optional[str] = None
|
seo_keyphrase: Optional[str] = None
|
||||||
_validate_body = field_validator("body")(validate_doc_body)
|
_validate_body = field_validator("body")(validate_doc_body)
|
||||||
|
|||||||
@@ -188,6 +188,12 @@ def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
@router.get("/robots.txt", response_class=Response)
|
@router.get("/robots.txt", response_class=Response)
|
||||||
def robots(db: Session = Depends(get_db)):
|
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", ""]
|
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
|
||||||
if settings.site_url:
|
if settings.site_url:
|
||||||
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
|
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
|
||||||
@@ -206,6 +212,13 @@ def _sitemap_entry(loc: str, fa: str, en: str) -> str:
|
|||||||
|
|
||||||
@router.get("/sitemap.xml", response_class=Response)
|
@router.get("/sitemap.xml", response_class=Response)
|
||||||
def sitemap(db: Session = Depends(get_db)):
|
def sitemap(db: Session = Depends(get_db)):
|
||||||
|
# پیشنمایش خصوصی — نقشه سایت خالی تا هیچ نشانیای اعلام نشود
|
||||||
|
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 = list(STATIC_PATHS)
|
||||||
fa_urls += [
|
fa_urls += [
|
||||||
f"/blog/{a.slug}"
|
f"/blog/{a.slug}"
|
||||||
@@ -262,7 +275,7 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
brand = _brand_name(db)
|
brand = _brand_name(db)
|
||||||
title, excerpt = _article_pair(article, "fa")
|
title, excerpt = _article_pair(article, "fa")
|
||||||
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
||||||
jsonld = {
|
jsonld: object = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
"headline": title,
|
"headline": title,
|
||||||
@@ -273,6 +286,10 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
"publisher": {"@type": "Organization", "name": brand},
|
"publisher": {"@type": "Organization", "name": brand},
|
||||||
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
||||||
}
|
}
|
||||||
|
# پرسشوپاسخ مقاله → اسکیمای 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>"
|
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||||
return _shell(
|
return _shell(
|
||||||
title=f"{title} | {brand}",
|
title=f"{title} | {brand}",
|
||||||
@@ -325,12 +342,35 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
# ---------- صفحات انگلیسی (/en/...) ----------
|
# ---------- صفحات انگلیسی (/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:
|
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
||||||
brand = _brand_name(db, "en")
|
brand = _brand_name(db, "en")
|
||||||
title, excerpt = _article_pair(article, "en")
|
title, excerpt = _article_pair(article, "en")
|
||||||
body = article.body_en if isinstance(article.body_en, dict) else article.body
|
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])
|
first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2])
|
||||||
jsonld = {
|
jsonld: object = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
"headline": title,
|
"headline": title,
|
||||||
@@ -341,6 +381,9 @@ def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
|||||||
"publisher": {"@type": "Organization", "name": brand},
|
"publisher": {"@type": "Organization", "name": brand},
|
||||||
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
|
"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>"
|
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||||
return _shell(
|
return _shell(
|
||||||
title=f"{title} | {brand}",
|
title=f"{title} | {brand}",
|
||||||
|
|||||||
@@ -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
|
email-validator==2.2.0
|
||||||
boto3==1.35.0
|
boto3==1.35.0
|
||||||
python-multipart==0.0.18
|
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
|
from app import rate_limit
|
||||||
|
|
||||||
rate_limit._failed.clear()
|
rate_limit._failed.clear()
|
||||||
|
rate_limit.contact_limiter._events.clear()
|
||||||
|
rate_limit.newsletter_limiter._events.clear()
|
||||||
yield
|
yield
|
||||||
rate_limit._failed.clear()
|
rate_limit._failed.clear()
|
||||||
|
rate_limit.contact_limiter._events.clear()
|
||||||
|
rate_limit.newsletter_limiter._events.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|||||||
+311
-2
@@ -1,6 +1,7 @@
|
|||||||
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
|
|
||||||
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||||
|
|
||||||
@@ -8,12 +9,24 @@ DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
|||||||
# ---------- عمومی ----------
|
# ---------- عمومی ----------
|
||||||
|
|
||||||
|
|
||||||
def test_health(client):
|
def test_live(client):
|
||||||
res = client.get("/api/health")
|
"""liveness — پروسه زنده است، بدون وابستگی."""
|
||||||
|
res = client.get("/api/live")
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert res.json()["status"] == "ok"
|
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):
|
def test_site_hides_draft_articles(client):
|
||||||
res = client.get("/api/site")
|
res = client.get("/api/site")
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
@@ -268,3 +281,299 @@ def test_en_backfill_fills_only_empty_fields(auth_client, client):
|
|||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
backfill_en_content()
|
backfill_en_content()
|
||||||
assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name"
|
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."""
|
||||||
+284
@@ -0,0 +1,284 @@
|
|||||||
|
"""سرویس چتبات سایت آرامش — FastAPI با استریم SSE و بازیابی ترکیبی روی محتوای دیتابیس."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Query, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
|
|
||||||
|
from . import content as content_mod
|
||||||
|
from . import pipeline
|
||||||
|
from . import vectorstore
|
||||||
|
from .config import settings
|
||||||
|
from .indexing import INDEX, sync_index
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("chatbot")
|
||||||
|
|
||||||
|
|
||||||
|
# --- چرخه حیات: ساخت ایندکس در شروع + بازسازی خودکار وقتی محتوا عوض شود ---
|
||||||
|
|
||||||
|
_refresh_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(sync_index)
|
||||||
|
except Exception as e:
|
||||||
|
log.critical("Initial index sync failed: %s — chat will be unavailable until reindex", e)
|
||||||
|
global _refresh_task
|
||||||
|
_refresh_task = asyncio.create_task(_refresh_loop())
|
||||||
|
yield
|
||||||
|
_refresh_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_loop() -> None:
|
||||||
|
interval = settings.index_refresh_seconds
|
||||||
|
if interval <= 0:
|
||||||
|
return
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
try:
|
||||||
|
# همگامسازی افزایشی است — فقط سطرهای جدید/تغییریافته امبد میشوند
|
||||||
|
await asyncio.to_thread(sync_index)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Background index refresh failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
api = FastAPI(
|
||||||
|
title="Aramesh Site Chatbot",
|
||||||
|
version="1.0.0",
|
||||||
|
description="دستیار گفتگوی محتوای سایت — بازیابی ترکیبی (pgvector + BM25) با ریرنکر BGE",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
api.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["GET", "POST"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- محدودسازی نرخ درخواست سبک در حافظه (پنجره لغزان per-IP) ---
|
||||||
|
|
||||||
|
_hits: dict[str, deque] = defaultdict(deque)
|
||||||
|
_last_sweep = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limited(ip: str) -> bool:
|
||||||
|
global _last_sweep
|
||||||
|
now = time.time()
|
||||||
|
# پاکسازی دورهای کلیدهای بیمصرف تا دیکشنری بینهایت بزرگ نشود
|
||||||
|
if now - _last_sweep > settings.rate_limit_window:
|
||||||
|
stale = [k for k, q in _hits.items() if not q or now - q[-1] > settings.rate_limit_window]
|
||||||
|
for k in stale:
|
||||||
|
_hits.pop(k, None)
|
||||||
|
_last_sweep = now
|
||||||
|
window = _hits[ip]
|
||||||
|
while window and now - window[0] > settings.rate_limit_window:
|
||||||
|
window.popleft()
|
||||||
|
if len(window) >= settings.rate_limit_messages:
|
||||||
|
return True
|
||||||
|
window.append(now)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request | None) -> str:
|
||||||
|
if request is None:
|
||||||
|
return "?"
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
return forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "?")
|
||||||
|
|
||||||
|
|
||||||
|
# --- کمکیهای SSE ---
|
||||||
|
|
||||||
|
def _sse(payload: dict) -> str:
|
||||||
|
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_text(chunk) -> str:
|
||||||
|
"""استخراج متن از قطعه استریم LLM (رشته یا لیست بخشها)."""
|
||||||
|
content = getattr(chunk, "content", "")
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
return "".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_answer(message: str, thread_id: str):
|
||||||
|
if not INDEX.ready:
|
||||||
|
yield _sse({"error": "ایندکس محتوا هنوز آماده نیست؛ چند لحظه بعد دوباره تلاش کنید.", "done": True})
|
||||||
|
return
|
||||||
|
if pipeline.get_llm() is None:
|
||||||
|
yield _sse({"error": pipeline.LLM_UNAVAILABLE, "done": True})
|
||||||
|
return
|
||||||
|
|
||||||
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
|
inputs = {"messages": [HumanMessage(content=message)]}
|
||||||
|
try:
|
||||||
|
async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
|
||||||
|
# فقط توکنهای پاسخ نهایی — نه فراخوانیهای کمکی (بازنویسی/گسترش پرسش)
|
||||||
|
if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
|
||||||
|
text = _chunk_text(event["data"]["chunk"])
|
||||||
|
if text:
|
||||||
|
yield _sse({"content": text, "done": False})
|
||||||
|
yield _sse({"content": "", "done": True})
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Streaming failed")
|
||||||
|
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
|
||||||
|
|
||||||
|
|
||||||
|
# --- مسیرها ---
|
||||||
|
|
||||||
|
@api.get("/chat/healthz")
|
||||||
|
async def healthz():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"index_ready": INDEX.ready,
|
||||||
|
"documents": len(INDEX.docs),
|
||||||
|
"vectors": await asyncio.to_thread(vectorstore.vector_count),
|
||||||
|
"llm_configured": bool(settings.llm_base_url),
|
||||||
|
"reranker_enabled": settings.reranker_enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/chat/suggestions")
|
||||||
|
async def suggestions():
|
||||||
|
return {"questions": _get_suggestions()}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/chat/eval")
|
||||||
|
async def eval_question(message: str = Query(...)):
|
||||||
|
"""مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابیشده، با thread تازه."""
|
||||||
|
if not INDEX.ready:
|
||||||
|
return {"error": "index not ready", "question": message, "answer": "", "contexts": []}
|
||||||
|
if pipeline.get_llm() is None:
|
||||||
|
return {"error": "llm not configured", "question": message, "answer": "", "contexts": []}
|
||||||
|
|
||||||
|
thread_id = f"eval_{uuid.uuid4().hex[:10]}"
|
||||||
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
|
inputs = {"messages": [HumanMessage(content=message)]}
|
||||||
|
final_state = await pipeline.graph.ainvoke(inputs, config=config)
|
||||||
|
|
||||||
|
answer = ""
|
||||||
|
for msg in reversed(final_state.get("messages", [])):
|
||||||
|
if isinstance(msg, AIMessage):
|
||||||
|
answer = msg.content if isinstance(msg.content, str) else str(msg.content)
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"question": message,
|
||||||
|
"contextualized_query": final_state.get("contextualized_query") or message,
|
||||||
|
"answer": answer,
|
||||||
|
"contexts": final_state.get("documents", []),
|
||||||
|
"thread_id": thread_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/chat/reindex")
|
||||||
|
async def reindex(request: Request):
|
||||||
|
"""همگامسازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت میشود."""
|
||||||
|
if settings.reindex_token and request.headers.get("x-reindex-token") != settings.reindex_token:
|
||||||
|
raise HTTPException(status_code=403, detail="invalid reindex token")
|
||||||
|
stats = await asyncio.to_thread(sync_index, True)
|
||||||
|
return {"status": "ok", "documents": len(INDEX.docs), "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
# سازگاری با eval_ragas.py قدیمی که آدرس را /chat/{username}/eval میسازد
|
||||||
|
|
||||||
|
@api.get("/chat/{scope}/stream")
|
||||||
|
async def stream_chat_scoped(scope: str, message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
|
||||||
|
return await stream_chat(message=message, thread_id=thread_id, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/chat/{scope}/eval")
|
||||||
|
async def eval_scoped(scope: str, message: str = Query(...)):
|
||||||
|
return await eval_question(message=message)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/chat/{scope}/suggestions")
|
||||||
|
async def suggestions_scoped(scope: str):
|
||||||
|
return {"questions": _get_suggestions()}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/chat/stream")
|
||||||
|
async def stream_chat(message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
|
||||||
|
"""استریم توکنبهتوکن پاسخ با SSE."""
|
||||||
|
if _rate_limited(_client_ip(request)):
|
||||||
|
raise HTTPException(status_code=429, detail="تعداد پیامهای شما زیاد است؛ کمی بعد دوباره تلاش کنید.")
|
||||||
|
|
||||||
|
thread_id = thread_id or f"t_{uuid.uuid4().hex[:10]}"
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
async for chunk in _stream_answer(message, thread_id):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- پیشنهاد پرسشها (کششده) ---
|
||||||
|
|
||||||
|
_suggestions_cache: tuple[float, list[str]] = (0.0, [])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_suggestions() -> list[str]:
|
||||||
|
global _suggestions_cache
|
||||||
|
now = time.time()
|
||||||
|
if _suggestions_cache[1] and now - _suggestions_cache[0] < settings.suggestions_ttl:
|
||||||
|
return _suggestions_cache[1]
|
||||||
|
|
||||||
|
questions: list[str] = []
|
||||||
|
if INDEX.ready:
|
||||||
|
faqs = [d for d in INDEX.docs if d["type"] == "faq"][:8]
|
||||||
|
titles = [d["title"] for d in INDEX.docs if d["type"] == "article"][:10]
|
||||||
|
model = pipeline.get_llm()
|
||||||
|
if model and (faqs or titles):
|
||||||
|
material = "\n".join([d["text"][:200] for d in faqs] + titles)
|
||||||
|
try:
|
||||||
|
prompt = (
|
||||||
|
"بر اساس محتوای سایت «گروه ساختمانی آرامش» ۴ پرسش کوتاه و جذاب بنویس که بازدیدکننده "
|
||||||
|
"احتمالاً میپرسد. هر پرسش حداکثر ۱۰ کلمه و به فارسی.\n"
|
||||||
|
'فقط آرایه JSON رشتهای برگردان: ["پرسش ۱", "پرسش ۲", ...]\n\n'
|
||||||
|
f"محتوا:\n{material[:2000]}"
|
||||||
|
)
|
||||||
|
resp = model.invoke([HumanMessage(content=prompt)])
|
||||||
|
text = resp.content if isinstance(resp.content, str) else ""
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.split("```")[1]
|
||||||
|
text = text[4:] if text.startswith("json") else text
|
||||||
|
import re
|
||||||
|
match = re.search(r"\[.*\]", text, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
questions = [q.strip() for q in json.loads(match.group()) if isinstance(q, str) and q.strip()][:4]
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Suggestion generation failed: %s", e)
|
||||||
|
if not questions and faqs:
|
||||||
|
# جایگزین: خود پرسشهای متداول
|
||||||
|
questions = [d["text"].split("\n")[0].removeprefix("پرسش: ") for d in faqs[:4]]
|
||||||
|
|
||||||
|
_suggestions_cache = (now, questions)
|
||||||
|
return questions
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(api, host="0.0.0.0", port=8000)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""تنظیمات سرویس چتبات — از متغیرهای محیطی یا فایل .env خوانده میشود."""
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
# دیتابیس سایت (فقط خواندن) — همان DATABASE_URL بکاند
|
||||||
|
database_url: str = "postgresql+psycopg://aramesh:aramesh@localhost:5432/arameshplaza"
|
||||||
|
# دیتابیس اختصاصی چتبات روی همان نمونه Postgres — بردارها اینجا نوشته میشوند
|
||||||
|
# تا دیتابیس سایت فقطخواندنی بماند؛ در شروع سرویس خودکار ساخته میشود
|
||||||
|
chatbot_db_name: str = "aramesh_chatbot"
|
||||||
|
|
||||||
|
# LLM سازگار با OpenAI — اجباری؛ بدون آن پاسخگویی غیرفعال است
|
||||||
|
llm_base_url: str = ""
|
||||||
|
llm_api_key: str = ""
|
||||||
|
llm_model: str = "openai/gpt-oss-120b"
|
||||||
|
|
||||||
|
# مدلهای بازیابی
|
||||||
|
embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
||||||
|
# بعد بردار خروجی مدل امبدینگ (MiniLM-L12 = 384) — با تغییر مدل باید عوض شود
|
||||||
|
embedding_dim: int = 384
|
||||||
|
reranker_model: str = "BAAI/bge-reranker-v2-m3"
|
||||||
|
reranker_enabled: bool = True
|
||||||
|
reranker_device: str = "cpu"
|
||||||
|
|
||||||
|
# وزن جستوجوی معنایی در ترکیب نمرهها (۰ = فقط کلیدواژه، ۱ = فقط معنایی)
|
||||||
|
alpha_dense: float = 0.5
|
||||||
|
# تعداد سند نهایی که به LLM داده میشود
|
||||||
|
best_of_k: int = 3
|
||||||
|
# تعداد نتیجه هر پایگاه (معنایی و کلیدواژه) برای هر پرسوجو
|
||||||
|
retrieve_k: int = 12
|
||||||
|
# بازنویسی پرسوجو با تاریخچه گفتگو (فقط وقتی تاریخچه هست صدا زده میشود)
|
||||||
|
contextualize_enabled: bool = True
|
||||||
|
# گسترش پرسوجو به چند نوع — برای پیکره کوچک پیشفرض خاموش (تأخیر کمتر)
|
||||||
|
query_expansion: bool = False
|
||||||
|
|
||||||
|
# مسیر کش مدلها (در داکر روی والیوم /data سوار میشود)
|
||||||
|
hf_cache_dir: str = "/data/hf"
|
||||||
|
|
||||||
|
# محدودسازی نرخ درخواست هر IP (تعداد پیام در پنجره زمانی به ثانیه)
|
||||||
|
rate_limit_messages: int = 20
|
||||||
|
rate_limit_window: int = 300
|
||||||
|
|
||||||
|
# توکن بازسازی ایندکس — اگر خالی باشد مسیر reindex فقط از داخل شبکه کار میکند
|
||||||
|
reindex_token: str = ""
|
||||||
|
|
||||||
|
# ماندگاری کش پیشنهادها و زمینه سایت (ثانیه)
|
||||||
|
suggestions_ttl: int = 600
|
||||||
|
site_context_ttl: int = 120
|
||||||
|
|
||||||
|
# بازسازی خودکار ایندکس وقتی محتوای دیتابیس عوض شود (۰ = خاموش)
|
||||||
|
index_refresh_seconds: int = 600
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""خواندن محتوای سایت از PostgreSQL و تبدیل آن به سندهای قابل ایندکس.
|
||||||
|
|
||||||
|
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقالهها و
|
||||||
|
پروژهها هم میتواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
|
||||||
|
پشتیبانی میشوند. مقالههای پیشنویس ایندکس نمیشوند.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .persian import normalize
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# URL پایه صفحات — چتبات مسیر نسبی میدهد؛ فرانتاند روی همان دامنه است
|
||||||
|
_ARTICLE_URL = "/blog/{slug}"
|
||||||
|
_PROJECT_URL = "/projects/{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def _psycopg_dsn(database_url: str) -> str:
|
||||||
|
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل میکند."""
|
||||||
|
return database_url.replace("+psycopg", "").replace("+asyncpg", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_rows(dsn: str, sql: str) -> list[dict[str, Any]]:
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
with psycopg.connect(dsn, row_factory=dict_row, connect_timeout=10) as conn:
|
||||||
|
cur = conn.execute(sql)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def tiptap_blocks(node: Any) -> list[str]:
|
||||||
|
"""متن بلوکبهبلوک از سند TipTap (یا هر JSON تو در تو) بیرون میکشد."""
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return []
|
||||||
|
ntype = node.get("type")
|
||||||
|
if ntype in ("paragraph", "heading", "codeBlock"):
|
||||||
|
text = _inline_text(node).strip()
|
||||||
|
return [text] if text else []
|
||||||
|
blocks: list[str] = []
|
||||||
|
for child in node.get("content") or []:
|
||||||
|
blocks.extend(tiptap_blocks(child))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _inline_text(node: dict) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
for child in node.get("content") or []:
|
||||||
|
ctype = child.get("type")
|
||||||
|
if ctype == "text":
|
||||||
|
parts.append(child.get("text", ""))
|
||||||
|
elif ctype == "hardBreak":
|
||||||
|
parts.append(" ")
|
||||||
|
elif ctype == "image":
|
||||||
|
alt = (child.get("attrs") or {}).get("alt") or ""
|
||||||
|
if alt:
|
||||||
|
parts.append(f"[تصویر: {alt}]")
|
||||||
|
else:
|
||||||
|
parts.append(_inline_text(child))
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def body_to_text(body: Any) -> str:
|
||||||
|
"""بدنه مقاله/پروژه (آرایه پاراگراف یا سند TipTap) → متن یکپارچه."""
|
||||||
|
if not body:
|
||||||
|
return ""
|
||||||
|
if isinstance(body, list):
|
||||||
|
# فرمت قدیمی: آرایه رشتهای پاراگرافها
|
||||||
|
return "\n".join(str(p).strip() for p in body if str(p).strip())
|
||||||
|
blocks = tiptap_blocks(body)
|
||||||
|
return "\n".join(b for b in blocks if b)
|
||||||
|
|
||||||
|
|
||||||
|
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return {"text": text, "type": dtype, "title": title, "url": url}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: Any) -> str:
|
||||||
|
return normalize(str(value or "")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_site_documents(database_url: str) -> list[dict]:
|
||||||
|
"""همه محتوای عمومی سایت را به سند تبدیل میکند (بدون وابستگی به ORM بکاند)."""
|
||||||
|
dsn = _psycopg_dsn(database_url)
|
||||||
|
docs: list[dict] = []
|
||||||
|
|
||||||
|
# --- برند و اطلاعات تماس (رکورد یگانه) ---
|
||||||
|
brand_rows = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||||
|
if brand_rows:
|
||||||
|
b = brand_rows[0]
|
||||||
|
contact = "\n".join(
|
||||||
|
filter(None, [
|
||||||
|
f"نام مجموعه: {_clean(b['name'])}",
|
||||||
|
f"تلفن تماس: {_clean(b['phone'])}",
|
||||||
|
f"ایمیل: {_clean(b['email'])}",
|
||||||
|
f"آدرس: {_clean(b['address'])}",
|
||||||
|
])
|
||||||
|
)
|
||||||
|
d = _doc(contact, "contact", "اطلاعات تماس", "/contact")
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- دفاتر ---
|
||||||
|
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
|
||||||
|
d = _doc(
|
||||||
|
f"دفتر {_clean(row['city'])} — آدرس: {_clean(row['address'])} — ساعت کاری: {_clean(row['hours'])}",
|
||||||
|
"office", f"دفتر {_clean(row['city'])}", "/contact",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- خدمات ---
|
||||||
|
for row in _fetch_rows(dsn, "SELECT title, desc FROM services ORDER BY position"):
|
||||||
|
d = _doc(
|
||||||
|
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||||
|
"service", _clean(row["title"]), "/services",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- پرسشهای متداول ---
|
||||||
|
for row in _fetch_rows(dsn, "SELECT question, answer FROM faqs ORDER BY position"):
|
||||||
|
d = _doc(
|
||||||
|
f"پرسش: {_clean(row['question'])}\nپاسخ: {_clean(row['answer'])}",
|
||||||
|
"faq", _clean(row["question"]), "/faq",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- پروژهها ---
|
||||||
|
for row in _fetch_rows(
|
||||||
|
dsn,
|
||||||
|
"SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position",
|
||||||
|
):
|
||||||
|
meta = "\n".join(filter(None, [
|
||||||
|
f"مکان: {_clean(row['location'])}" if row["location"] else "",
|
||||||
|
f"سال: {_clean(row['year'])}" if row["year"] else "",
|
||||||
|
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیلشده'}",
|
||||||
|
f"متراژ: {_clean(row['area'])}" if row["area"] else "",
|
||||||
|
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
|
||||||
|
f"واحدها: {_clean(row['units'])}" if row["units"] else "",
|
||||||
|
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
|
||||||
|
]))
|
||||||
|
text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])]))
|
||||||
|
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- مقالهها (فقط منتشرشده) ---
|
||||||
|
for row in _fetch_rows(
|
||||||
|
dsn,
|
||||||
|
"SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position",
|
||||||
|
):
|
||||||
|
text = "\n".join(filter(None, [
|
||||||
|
f"دستهبندی: {_clean(row['category'])}" if row["category"] else "",
|
||||||
|
_clean(row["excerpt"]),
|
||||||
|
body_to_text(row["body"]),
|
||||||
|
]))
|
||||||
|
d = _doc(text, "article", _clean(row["title"]), _ARTICLE_URL.format(slug=row["slug"]))
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- مدلهای سرمایهگذاری ---
|
||||||
|
for row in _fetch_rows(dsn, "SELECT title, desc, points FROM investment_models ORDER BY position"):
|
||||||
|
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
|
||||||
|
d = _doc(
|
||||||
|
"\n".join(filter(None, [
|
||||||
|
f"«{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||||
|
f"نکات: {points}" if points else "",
|
||||||
|
])),
|
||||||
|
"investment", _clean(row["title"]), "/investment",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- مراحل همکاری ---
|
||||||
|
for row in _fetch_rows(dsn, "SELECT step, title, desc FROM process_steps ORDER BY position"):
|
||||||
|
d = _doc(
|
||||||
|
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||||
|
"process", _clean(row["title"]), "/investment",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
# --- چرا ما / تیم / نظرسنجی / تایملاین / داستان ما ---
|
||||||
|
for row in _fetch_rows(dsn, "SELECT title, desc FROM why_us ORDER BY position"):
|
||||||
|
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
for row in _fetch_rows(dsn, "SELECT name, role, bio FROM team ORDER BY position"):
|
||||||
|
d = _doc(
|
||||||
|
f"{_clean(row['name'])} — {_clean(row['role'])}. {_clean(row['bio'])}",
|
||||||
|
"team", _clean(row["name"]), "/about",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"):
|
||||||
|
d = _doc(
|
||||||
|
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
|
||||||
|
"testimonial", _clean(row["name"]), "/",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
for row in _fetch_rows(dsn, "SELECT year, title, desc FROM timeline ORDER BY position"):
|
||||||
|
d = _doc(
|
||||||
|
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||||
|
"timeline", _clean(row["title"]), "/about",
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
story_rows = _fetch_rows(dsn, "SELECT title, paragraphs, quote FROM story LIMIT 1")
|
||||||
|
if story_rows and story_rows[0]:
|
||||||
|
s = story_rows[0]
|
||||||
|
paragraphs = "\n".join(_clean(p) for p in (s["paragraphs"] or []) if _clean(p))
|
||||||
|
text = "\n".join(filter(None, [_clean(s["title"]), paragraphs, _clean(s["quote"])]))
|
||||||
|
d = _doc(text, "about", _clean(s["title"]) or "درباره ما", "/about")
|
||||||
|
if d:
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
|
log.info("Fetched %d documents from site database", len(docs))
|
||||||
|
return docs
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
|
||||||
|
"""متنهای بلند (مقاله/پروژه) را به قطعات همپوشان تقسیم میکند.
|
||||||
|
|
||||||
|
هر قطعه یک doc_id پایدار میگیرد («type:ترتیب در همان url:شماره قطعه») تا
|
||||||
|
همگامسازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
|
||||||
|
"""
|
||||||
|
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||||
|
|
||||||
|
from .vectorstore import chunk_content_hash
|
||||||
|
|
||||||
|
splitter = RecursiveCharacterTextSplitter(
|
||||||
|
chunk_size=chunk_size,
|
||||||
|
chunk_overlap=overlap,
|
||||||
|
separators=["\n", "۔", ".", "،", " ", ""],
|
||||||
|
)
|
||||||
|
|
||||||
|
# شماره ردیف هر سند در میان سندهای همtype و هم-url (برای FAQs که url یکسان دارند)
|
||||||
|
occurrence: dict[str, int] = {}
|
||||||
|
|
||||||
|
out: list[dict] = []
|
||||||
|
for doc in docs:
|
||||||
|
key = f"{doc['type']}|{doc['url']}"
|
||||||
|
occ = occurrence.get(key, 0)
|
||||||
|
occurrence[key] = occ + 1
|
||||||
|
|
||||||
|
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
|
||||||
|
for i, piece in enumerate(pieces, 1):
|
||||||
|
chunk = {
|
||||||
|
"doc_id": f"{doc['type']}:{occ}:{i}",
|
||||||
|
"text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}",
|
||||||
|
"type": doc["type"],
|
||||||
|
"title": doc["title"],
|
||||||
|
"url": doc["url"],
|
||||||
|
}
|
||||||
|
chunk["content_hash"] = chunk_content_hash(chunk)
|
||||||
|
out.append(chunk)
|
||||||
|
|
||||||
|
log.info("Chunked %d documents into %d pieces", len(docs), len(out))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def content_fingerprint(docs: list[dict]) -> str:
|
||||||
|
"""هش کل محتوا — فقط برای لاگ؛ تشخیص تغییر ردیفی در vectorstore انجام میشود."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
payload = json.dumps(docs, ensure_ascii=False, sort_keys=True)
|
||||||
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
# --- زمینه همیشهتازه سایت (بلوک کوتاه تماس/خدمات برای پرامپت) ---
|
||||||
|
_site_context_cache: tuple[float, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_site_context(database_url: str, ttl: int = 120) -> str:
|
||||||
|
"""نام برند، تماس و فهرست خدمات — با کش کوتاهمدت تا هر پیام یک کوئری سبک بزند."""
|
||||||
|
global _site_context_cache
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
now = _time.time()
|
||||||
|
if _site_context_cache and now - _site_context_cache[0] < ttl:
|
||||||
|
return _site_context_cache[1]
|
||||||
|
try:
|
||||||
|
dsn = _psycopg_dsn(database_url)
|
||||||
|
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||||
|
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
|
||||||
|
lines: list[str] = []
|
||||||
|
if brand:
|
||||||
|
b = brand[0]
|
||||||
|
lines += [
|
||||||
|
f"نام مجموعه: {_clean(b['name'])}",
|
||||||
|
f"تلفن تماس: {_clean(b['phone'])}",
|
||||||
|
f"ایمیل: {_clean(b['email'])}",
|
||||||
|
f"آدرس: {_clean(b['address'])}",
|
||||||
|
]
|
||||||
|
if services:
|
||||||
|
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
|
||||||
|
ctx = "\n".join(lines)
|
||||||
|
_site_context_cache = (now, ctx)
|
||||||
|
return ctx
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Site context fetch failed: %s", e)
|
||||||
|
return _site_context_cache[1] if _site_context_cache else ""
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""مدل امبدینگ — با لود تنبل تا اولین استفاده (برای تستهای سبک، torch لود نمیشود)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_embeddings():
|
||||||
|
from langchain_huggingface import HuggingFaceEmbeddings
|
||||||
|
|
||||||
|
os.makedirs(settings.hf_cache_dir, exist_ok=True)
|
||||||
|
return HuggingFaceEmbeddings(
|
||||||
|
model_name=settings.embedding_model,
|
||||||
|
model_kwargs={"device": "cpu"},
|
||||||
|
encode_kwargs={"normalize_embeddings": False},
|
||||||
|
cache_folder=settings.hf_cache_dir,
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""ایندکس ترکیبی روی pgvector + BM25 در حافظه.
|
||||||
|
|
||||||
|
بردارها در pgvector (دیتابیس اختصاصی چتبات) بهصورت افزایشی همگام میشوند —
|
||||||
|
فقط سطرهای جدید یا تغییر یافته امبد میشوند. BM25 چون سبک است در هر شروع
|
||||||
|
از روی همان سطرها ساخته میشود و نیازی به کش دیسکی ندارد.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from rank_bm25 import BM25Okapi
|
||||||
|
|
||||||
|
from . import content as content_mod
|
||||||
|
from .config import settings
|
||||||
|
from .persian import tokenize
|
||||||
|
from . import vectorstore
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SiteIndex:
|
||||||
|
"""حالت در حافظهٔ بازیابی — سندها (برای BM25) و وضعیت آماده بودن."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.docs: list[dict] = []
|
||||||
|
self.bm25: BM25Okapi | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
return self.bm25 is not None and bool(self.docs)
|
||||||
|
|
||||||
|
|
||||||
|
INDEX = SiteIndex()
|
||||||
|
|
||||||
|
|
||||||
|
def sync_index(force_rebuild: bool = False) -> dict:
|
||||||
|
"""همگامسازی کامل: محتوا → قطعه → pgvector (افزایشی) → ساخت BM25.
|
||||||
|
|
||||||
|
خروجی: آمار همگامسازی. خطای اتصال به دیتابیس بالا فرستاده میشود.
|
||||||
|
"""
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
vectorstore.ensure_schema()
|
||||||
|
|
||||||
|
docs = content_mod.fetch_site_documents(settings.database_url)
|
||||||
|
chunks = content_mod.chunk_documents(docs)
|
||||||
|
stats = vectorstore.sync_chunks(chunks, force=force_rebuild)
|
||||||
|
|
||||||
|
INDEX.docs = vectorstore.fetch_all_chunks()
|
||||||
|
corpus_tokens = [tokenize(d["text"]) for d in INDEX.docs]
|
||||||
|
INDEX.bm25 = BM25Okapi(corpus_tokens) if corpus_tokens else None
|
||||||
|
|
||||||
|
log.info("Index ready in %.1fs (%d vectors, %d docs, %s)",
|
||||||
|
time.time() - start, len(INDEX.docs), len(chunks), stats)
|
||||||
|
return stats
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""نرمالسازی متن فارسی و توکنسازی برای جستوجوی کلیدواژه (BM25).
|
||||||
|
|
||||||
|
بدون وابستگی سنگین — فقط استانداردسازی نویسهها، حذف اعراب و نیمفاصلههای
|
||||||
|
مشکلی و شکستن کلمههای مرکب روی نیمفاصله برای بهبود بازیابی.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# نویسههای عربی که در متن فارسی اشتباهاً استفاده میشوند
|
||||||
|
_CHAR_MAP = {
|
||||||
|
"\u064a": "\u06cc", # ي → ی
|
||||||
|
"\u0643": "\u06a9", # ك → ک
|
||||||
|
"\u0623": "\u0627", # أ → ا
|
||||||
|
"\u0625": "\u0627", # إ → ا
|
||||||
|
"\u0671": "\u0627", # ٱ → ا
|
||||||
|
"\u0629": "\u0647", # ة → ه
|
||||||
|
"\u0624": "\u0648", # ؤ → و
|
||||||
|
"\u0626": "\u06cc", # ئ → ی
|
||||||
|
"\u0640": "", # ـ (کشیده) حذف
|
||||||
|
}
|
||||||
|
|
||||||
|
# اعراب و علامتهای اضافی
|
||||||
|
_DIACRITICS = re.compile(r"[\u064b-\u0652\u0670\u0653-\u0655\u0640]")
|
||||||
|
|
||||||
|
# ارقام فارسی و عربی → ASCII
|
||||||
|
_DIGITS = str.maketrans({
|
||||||
|
"\u06f0": "0", "\u06f1": "1", "\u06f2": "2", "\u06f3": "3", "\u06f4": "4",
|
||||||
|
"\u06f5": "5", "\u06f6": "6", "\u06f7": "7", "\u06f8": "8", "\u06f9": "9",
|
||||||
|
"\u0660": "0", "\u0661": "1", "\u0662": "2", "\u0663": "3", "\u0664": "4",
|
||||||
|
"\u0665": "5", "\u0666": "6", "\u0667": "7", "\u0668": "8", "\u0669": "9",
|
||||||
|
})
|
||||||
|
|
||||||
|
# واژههای دستوری پرتکرار — در جستوجو بیمعنا هستند
|
||||||
|
STOPWORDS = frozenset("""
|
||||||
|
و در به از که با این آن است هست بود بودن شد شدن شود کرد کردن کرده کند کنی
|
||||||
|
برای را های ها هایی هم نیز اما یا اگر تا بر روی زیر بین پس قبل بعد همه هر چه
|
||||||
|
چیزی کس کسی کجا چرا چگونه چطور چند یکی دو سه ما شما آنها ایشان من تو او
|
||||||
|
کدام کی چی چهقدر چقدر
|
||||||
|
می نخواهد خواهد باشد باشند هستیم هستند بودم بودیم دارد دارند داشته داد دهد
|
||||||
|
بله خیر آیا فقط نیز خیلی بسیار کمی دیگر دیگری همان همین وقتی زمانی وقتیکه
|
||||||
|
اساساً لطفاً میشه میکنه هستن هستم
|
||||||
|
""".split())
|
||||||
|
|
||||||
|
# علائم نگارشی فارسی/لاتین — قبل از توکنسازی حذف میشوند (نیمفاصله جزو اینها نیست)
|
||||||
|
_PUNCT_RE = re.compile(r"[،؛؟!,;:\?\"'()\[\]{}«»….*#@&%+=/\\|~^$\u066d\u066c\u066b]+")
|
||||||
|
|
||||||
|
# توکن: حروف/ارقام فارسی و لاتین + نیمفاصله در میان کلمه
|
||||||
|
_TOKEN_RE = re.compile(r"[\w\u0600-\u06ff\u200c]+")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(text: str) -> str:
|
||||||
|
"""نویسههای غیراستاندارد عربی/فارسی را به شکل یکدست فارسی میبرد."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
out = []
|
||||||
|
for ch in text:
|
||||||
|
out.append(_CHAR_MAP.get(ch, ch))
|
||||||
|
text = "".join(out)
|
||||||
|
text = _DIACRITICS.sub("", text)
|
||||||
|
text = text.translate(_DIGITS)
|
||||||
|
# فاصلههای تکراری را یکی کن — نیمفاصله عمداً حفظ میشود (میشود)
|
||||||
|
text = re.sub(r"[ \t\r\n]+", " ", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def tokenize(text: str) -> list[str]:
|
||||||
|
"""توکنهای نرمالشده برای BM25 — کلمههای مرکب روی نیمفاصله هم جدا ایندکس میشوند."""
|
||||||
|
text = _PUNCT_RE.sub(" ", normalize(text).lower())
|
||||||
|
tokens = []
|
||||||
|
for raw in _TOKEN_RE.findall(text):
|
||||||
|
if raw in STOPWORDS or len(raw) < 2:
|
||||||
|
continue
|
||||||
|
tokens.append(raw)
|
||||||
|
# «میشود» → می + شود (فراخوانی بیشتر برای بازیابی)
|
||||||
|
if "\u200c" in raw:
|
||||||
|
for part in raw.split("\u200c"):
|
||||||
|
if len(part) >= 2 and part not in STOPWORDS:
|
||||||
|
tokens.append(part)
|
||||||
|
return tokens
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""گراف گفتگو (LangGraph) — پورت خط لوله v13 در سه گام:
|
||||||
|
|
||||||
|
۱) بازنویسی پرسش با تاریخچه (فقط وقتی تاریخچه هست — صرفهجویی در تأخیر)
|
||||||
|
۲) بازیابی ترکیبی با چند نوعواژه (اختیاری)
|
||||||
|
۳) تولید پاسخ فارسی با قواعد تکیه بر داده
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import json as _json
|
||||||
|
from typing import Annotated, List, Optional
|
||||||
|
|
||||||
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from langgraph.checkpoint.memory import MemorySaver
|
||||||
|
from langgraph.graph import END, StateGraph
|
||||||
|
from langgraph.graph.message import add_messages
|
||||||
|
|
||||||
|
from . import content as content_mod
|
||||||
|
from .config import settings
|
||||||
|
from .retrieval import retrieve_final
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FALLBACK_MESSAGE = (
|
||||||
|
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
|
||||||
|
"برای راهنمایی بیشتر میتوانید از صفحه «تماس با ما» با ما در ارتباط باشید."
|
||||||
|
)
|
||||||
|
LLM_UNAVAILABLE = (
|
||||||
|
"سرویس هوش مصنوعی هنوز پیکربندی نشده است. "
|
||||||
|
"متغیرهای LLM_BASE_URL و LLM_MODEL را در تنظیمات سرویس چتبات تنظیم کنید."
|
||||||
|
)
|
||||||
|
|
||||||
|
_llm = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm() -> ChatOpenAI | None:
|
||||||
|
"""LLM تولید — اگر پیکربندی نشده باشد None برمیگرداند."""
|
||||||
|
global _llm
|
||||||
|
if _llm is None and settings.llm_base_url:
|
||||||
|
_llm = ChatOpenAI(
|
||||||
|
model=settings.llm_model,
|
||||||
|
base_url=settings.llm_base_url,
|
||||||
|
api_key=settings.llm_api_key or "not-needed",
|
||||||
|
temperature=0,
|
||||||
|
streaming=True,
|
||||||
|
)
|
||||||
|
return _llm
|
||||||
|
|
||||||
|
|
||||||
|
class GraphState(TypedDict):
|
||||||
|
messages: Annotated[List, add_messages]
|
||||||
|
documents: List[str]
|
||||||
|
contextualized_query: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
# --- گام ۱: بازنویسی پرسش با تاریخچه ---
|
||||||
|
|
||||||
|
def contextualize_query(question: str, history: list) -> str:
|
||||||
|
"""ضمیرها و اشارههای پرسش را با تاریخچه گفتگو مشخص میکند («اون دومی» → موضوع واقعی)."""
|
||||||
|
model = get_llm()
|
||||||
|
if model is None or not history:
|
||||||
|
return question
|
||||||
|
try:
|
||||||
|
history_text = "\n".join(
|
||||||
|
f"{'کاربر' if isinstance(m, HumanMessage) else 'دستیار'}: {str(m.content)[:300]}"
|
||||||
|
for m in history[-6:]
|
||||||
|
)
|
||||||
|
prompt = (
|
||||||
|
"با توجه به تاریخچه گفتگو، پرسش کاربر را به یک پرسش مستقل و کامل فارسی بازنویسی کن.\n"
|
||||||
|
"- ضمیرها و اشارهها (او، آن، همین، دومی، این یکی) را به موضوع واقعیِ ذکرشده در تاریخچه تبدیل کن.\n"
|
||||||
|
"- اگر پرسش از قبل مستقل است، عیناً همان را برگردان.\n"
|
||||||
|
"- فقط متن پرسش بازنویسیشده را برگردان؛ هیچ توضیحی ننویس.\n\n"
|
||||||
|
f"تاریخچه گفتگو:\n{history_text}\n\n"
|
||||||
|
f"پرسش کاربر: {question}\n\n"
|
||||||
|
"پرسش مستقل:"
|
||||||
|
)
|
||||||
|
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
|
||||||
|
rewritten = (resp.content or "").strip() if isinstance(resp.content, str) else ""
|
||||||
|
if not rewritten or len(rewritten) > len(question) * 4:
|
||||||
|
return question
|
||||||
|
if rewritten != question:
|
||||||
|
log.info("Contextualized: '%s' → '%s'", question[:60], rewritten[:60])
|
||||||
|
return rewritten
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Contextualization failed: %s", e)
|
||||||
|
return question
|
||||||
|
|
||||||
|
|
||||||
|
def expand_query(query: str) -> list[str]:
|
||||||
|
"""گسترش پرسش به چند نوعواژه (اختیاری — پیشفرض خاموش برای پیکره کوچک)."""
|
||||||
|
model = get_llm()
|
||||||
|
if model is None:
|
||||||
|
return [query]
|
||||||
|
try:
|
||||||
|
prompt = (
|
||||||
|
"برای جستوجو در محتوای سایت یک گروه ساختمانی، ۳ نوعواژه جایگزین برای این پرسش بساز: "
|
||||||
|
"بازنویسی معنایی، استخراج کلیدواژه، گسترش با مفاهیم مرتبط.\n"
|
||||||
|
'فقط JSON برگردان: {"variants": ["۱", "۲", "۳"]}\n\n'
|
||||||
|
f"پرسش: {query}"
|
||||||
|
)
|
||||||
|
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
|
||||||
|
text = resp.content if isinstance(resp.content, str) else ""
|
||||||
|
match = re.search(r'\{.*"variants".*\}', text, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
variants = [v for v in _json.loads(match.group()).get("variants", []) if v and v != query][:3]
|
||||||
|
if variants:
|
||||||
|
log.info("Query variants: %s", [query] + variants)
|
||||||
|
return [query] + variants
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Query expansion failed: %s", e)
|
||||||
|
return [query]
|
||||||
|
|
||||||
|
|
||||||
|
# --- گام ۲: بازیابی ---
|
||||||
|
|
||||||
|
def retrieve_node(state: GraphState) -> dict:
|
||||||
|
messages = state.get("messages", [])
|
||||||
|
if not messages:
|
||||||
|
return {"documents": []}
|
||||||
|
question = messages[-1].content
|
||||||
|
history = messages[:-1]
|
||||||
|
|
||||||
|
query = contextualize_query(question, history) if settings.contextualize_enabled else question
|
||||||
|
variants = expand_query(query) if settings.query_expansion else [query]
|
||||||
|
|
||||||
|
docs = retrieve_final(query, variants)
|
||||||
|
doc_texts = [f"{d['text']}\n(منبع: {d['title']} — {d['url']})" for d in docs]
|
||||||
|
log.info("Retrieved %d documents for '%s'", len(doc_texts), question[:60])
|
||||||
|
return {
|
||||||
|
"documents": doc_texts,
|
||||||
|
"contextualized_query": query if query != question else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- گام ۳: تولید پاسخ ---
|
||||||
|
|
||||||
|
def build_system_prompt(documents: list[str], contextualized_query: str | None) -> str:
|
||||||
|
site_ctx = content_mod.fetch_site_context(settings.database_url, settings.site_context_ttl)
|
||||||
|
docs_block = "\n\n———\n\n".join(documents) if documents else "(سند مرتبطی یافت نشد)"
|
||||||
|
reminder = (
|
||||||
|
f"\n--- یادآوری ---\nپرسش کاربر پس از بازنویسی این است: «{contextualized_query}»\n\n"
|
||||||
|
if contextualized_query else ""
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"تو دستیار گفتگوی هوشمند سایت «گروه ساختمانی آرامش» هستی و فقط به فارسی پاسخ میدهی.\n"
|
||||||
|
"دانش تو اکیداً محدود به اطلاعات زیر است.\n\n"
|
||||||
|
f"--- اطلاعات پایه سایت (همیشه معتبر) ---\n{site_ctx}\n\n"
|
||||||
|
f"--- اسناد بازیابیشده مرتبط با پرسش ---\n{docs_block}\n"
|
||||||
|
f"{reminder}"
|
||||||
|
"--- قوانین پاسخ ---\n"
|
||||||
|
"۱. فقط بر اساس اطلاعات بالا پاسخ بده؛ بههیچوجه از دانش بیرونی استفاده نکن و چیزی نساز.\n"
|
||||||
|
"۲. پاسخ کوتاه، دقیق و مفید باشد (معمولاً ۲ تا ۵ جمله) و لحن حرفهای و محترمانه داشته باشد.\n"
|
||||||
|
f"۳. اگر اطلاعات کافی در اسناد نیست، فقط همین جمله را بنویس: «{FALLBACK_MESSAGE}»\n"
|
||||||
|
"۴. اگر سند مسیر صفحه مرتبط دارد، در پایان پاسخ آن را معرفی کن (مثلاً: جزئیات کامل در صفحه /blog/... ).\n"
|
||||||
|
"۵. درباره قیمت، زمان دقیق یا هر تعهدی فقط چیزی را بگو که در دادهها آمده است."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_node(state: GraphState) -> dict:
|
||||||
|
messages = state.get("messages", [])
|
||||||
|
question = messages[-1].content if messages else ""
|
||||||
|
|
||||||
|
model = get_llm()
|
||||||
|
if model is None:
|
||||||
|
return {"messages": [AIMessage(content=LLM_UNAVAILABLE)]}
|
||||||
|
|
||||||
|
documents = state.get("documents") or []
|
||||||
|
system = build_system_prompt(documents, state.get("contextualized_query"))
|
||||||
|
log.info("Generating answer with %d docs (system prompt %d chars)", len(documents), len(system))
|
||||||
|
|
||||||
|
history = messages[:-1]
|
||||||
|
reply = model.invoke(
|
||||||
|
[SystemMessage(content=system), *history, HumanMessage(content=question)],
|
||||||
|
config={"tags": ["generate"]},
|
||||||
|
)
|
||||||
|
return {"messages": [reply]}
|
||||||
|
|
||||||
|
|
||||||
|
# --- گراف ---
|
||||||
|
|
||||||
|
memory = MemorySaver()
|
||||||
|
workflow = StateGraph(GraphState)
|
||||||
|
workflow.add_node("retrieve", retrieve_node)
|
||||||
|
workflow.add_node("generate", generate_node)
|
||||||
|
workflow.set_entry_point("retrieve")
|
||||||
|
workflow.add_edge("retrieve", "generate")
|
||||||
|
workflow.add_edge("generate", END)
|
||||||
|
graph = workflow.compile(checkpointer=memory)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# سرویس چتبات — بازیابی ترکیبی روی محتوای سایت
|
||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.30
|
||||||
|
pydantic-settings>=2.3
|
||||||
|
|
||||||
|
# گراف گفتگو و LLM
|
||||||
|
langgraph>=0.2.60
|
||||||
|
langchain>=0.3
|
||||||
|
langchain-openai>=0.2
|
||||||
|
langchain-text-splitters>=0.3
|
||||||
|
|
||||||
|
# بازیابی — بردارها در pgvector میروند؛ BM25 و خوشهبندی در حافظه
|
||||||
|
sentence-transformers>=3.0
|
||||||
|
langchain-huggingface>=0.1
|
||||||
|
rank-bm25>=0.2.2
|
||||||
|
scikit-learn>=1.4
|
||||||
|
numpy>=1.26
|
||||||
|
|
||||||
|
# دیتابیس (فقط خواندن)
|
||||||
|
psycopg[binary]>=3.1
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""مدل ریرنکر BGE — با لود تنبل و امتیازدهی دستهای."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _CrossEncoder:
|
||||||
|
"""غلاف سبک روی AutoModelForSequenceClassification — API شبیه rank_bm25."""
|
||||||
|
def __init__(self) -> None:
|
||||||
|
import torch
|
||||||
|
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||||
|
|
||||||
|
os.makedirs(settings.hf_cache_dir, exist_ok=True)
|
||||||
|
self._torch = torch
|
||||||
|
self.tokenizer = AutoTokenizer.from_pretrained(settings.reranker_model, cache_dir=settings.hf_cache_dir)
|
||||||
|
self.model = AutoModelForSequenceClassification.from_pretrained(
|
||||||
|
settings.reranker_model, cache_dir=settings.hf_cache_dir
|
||||||
|
)
|
||||||
|
device = settings.reranker_device if settings.reranker_device == "cuda" and torch.cuda.is_available() else "cpu"
|
||||||
|
self.model.to(device)
|
||||||
|
self.model.eval()
|
||||||
|
self.device = device
|
||||||
|
log.info("Reranker loaded on %s", device)
|
||||||
|
|
||||||
|
def score(self, pairs: list[list[str]], batch_size: int = 16) -> list[float]:
|
||||||
|
torch = self._torch
|
||||||
|
out: list[float] = []
|
||||||
|
with torch.no_grad():
|
||||||
|
for i in range(0, len(pairs), batch_size):
|
||||||
|
inputs = self.tokenizer(
|
||||||
|
pairs[i:i + batch_size],
|
||||||
|
padding=True,
|
||||||
|
truncation=True,
|
||||||
|
max_length=512,
|
||||||
|
return_tensors="pt",
|
||||||
|
).to(self.device)
|
||||||
|
logits = self.model(**inputs).logits.squeeze(-1)
|
||||||
|
out.extend(logits.cpu().tolist())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_reranker() -> _CrossEncoder:
|
||||||
|
return _CrossEncoder()
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""بازیابی ترکیبی — پورت خط لوله v13 با سه تغییر برای این سایت:
|
||||||
|
|
||||||
|
۱) پایگاه sparse بهجای SPLADE (انگلیسیمحور و بیفایده روی فارسی) از BM25
|
||||||
|
با نرمالسازی فارسی استفاده میکند.
|
||||||
|
۲) جستوجوی معنایی بهجای FAISS با pgvector و فاصله کسینوسی در SQL انجام
|
||||||
|
میشود (همگامسازی افزایشی، فیلترپذیر، بدون فایل ایندکس جدا).
|
||||||
|
۳) مسیریابی alpha با LLM حذف شده و وزن ثابت تنظیمپذیر است — برای پیکرهٔ
|
||||||
|
کوچک سایت، هزینهٔ یک فراخوانی LLM اضافی بهازای هر پیام نمیارزد.
|
||||||
|
بقیهٔ منطق (نرمالسازی نمره، فیوژن وزندار، برش خوشهبندی، ریرنک) عین v13 است.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from . import vectorstore
|
||||||
|
from .config import settings
|
||||||
|
from .indexing import INDEX
|
||||||
|
from .persian import tokenize
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def dense_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||||
|
"""جستوجوی معنایی pgvector — خروجی: (سند، شباهت کسینوسی)."""
|
||||||
|
return vectorstore.dense_search(query, top_k)
|
||||||
|
|
||||||
|
|
||||||
|
def keyword_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||||
|
"""جستوجوی کلیدواژه BM25 روی متن نرمالشده فارسی."""
|
||||||
|
if INDEX.bm25 is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
tokens = tokenize(query)
|
||||||
|
if not tokens:
|
||||||
|
return []
|
||||||
|
scores = INDEX.bm25.get_scores(tokens)
|
||||||
|
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
|
||||||
|
return [(INDEX.docs[i], float(scores[i])) for i in ranked if scores[i] > 0]
|
||||||
|
except Exception as e:
|
||||||
|
log.error("BM25 retrieval failed: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _minmax(scores: list[float]) -> list[float]:
|
||||||
|
if not scores:
|
||||||
|
return []
|
||||||
|
lo, hi = min(scores), max(scores)
|
||||||
|
if hi == lo:
|
||||||
|
return [0.5] * len(scores)
|
||||||
|
return [(s - lo) / (hi - lo) for s in scores]
|
||||||
|
|
||||||
|
|
||||||
|
def hybrid_retrieve(query: str, alpha: float | None = None, top_k: int | None = None) -> list[tuple[dict, float]]:
|
||||||
|
"""ترکیب وزندار نمره معنایی و کلیدواژه؛ خروجی مرتبشده بر اساس نمره ترکیبی."""
|
||||||
|
alpha = settings.alpha_dense if alpha is None else alpha
|
||||||
|
top_k = top_k or settings.retrieve_k
|
||||||
|
|
||||||
|
dense = dense_retrieve(query, top_k)
|
||||||
|
keyword = keyword_retrieve(query, top_k)
|
||||||
|
log.info("Retrieval '%s': dense=%d, keyword=%d", query[:60], len(dense), len(keyword))
|
||||||
|
if not dense and not keyword:
|
||||||
|
return []
|
||||||
|
|
||||||
|
dense_norm = _minmax([s for _, s in dense])
|
||||||
|
keyword_norm = _minmax([s for _, s in keyword])
|
||||||
|
|
||||||
|
pool: dict = {} # doc_id → {doc, dense_max, kw_max}
|
||||||
|
for (doc, _), norm in zip(dense, dense_norm):
|
||||||
|
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
|
||||||
|
entry["dense_max"] = max(entry["dense_max"], norm)
|
||||||
|
for (doc, _), norm in zip(keyword, keyword_norm):
|
||||||
|
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
|
||||||
|
entry["kw_max"] = max(entry["kw_max"], norm)
|
||||||
|
|
||||||
|
combined = [
|
||||||
|
(entry["doc"], alpha * entry["dense_max"] + (1 - alpha) * entry["kw_max"])
|
||||||
|
for entry in pool.values()
|
||||||
|
]
|
||||||
|
combined.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
def cluster_cutoff(scored: list[tuple[dict, float]], silhouette_threshold: float = 0.55) -> list[tuple[dict, float]]:
|
||||||
|
"""برش خوشهای v13 — مرز طبیعی کیفیت بهجای top-k دلخواه.
|
||||||
|
|
||||||
|
روی نمرههای ۱بعدی K-Means با K=2 میزند؛ اگر جدایی خوشهها معنادار
|
||||||
|
باشد (silhouette ≥ 0.55) فقط خوشهٔ بالا را نگه میدارد.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
n = len(scored)
|
||||||
|
if n < 4:
|
||||||
|
return scored
|
||||||
|
try:
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
from sklearn.metrics import silhouette_score
|
||||||
|
|
||||||
|
scores = np.array([s for _, s in scored], dtype=float).reshape(-1, 1)
|
||||||
|
kmeans = KMeans(n_clusters=2, random_state=42, n_init=10)
|
||||||
|
labels = kmeans.fit_predict(scores)
|
||||||
|
sil = silhouette_score(scores, labels)
|
||||||
|
if sil < silhouette_threshold:
|
||||||
|
log.info("Clustering: silhouette=%.3f < %.2f — keeping all %d docs", sil, silhouette_threshold, n)
|
||||||
|
return scored
|
||||||
|
centers = kmeans.cluster_centers_.flatten()
|
||||||
|
good_label = int(np.argmax(centers))
|
||||||
|
cutoff = int(np.sum(labels == good_label))
|
||||||
|
cutoff = max(cutoff, 1)
|
||||||
|
log.info("Clustering: silhouette=%.3f — cutting at %d/%d docs", sil, cutoff, n)
|
||||||
|
return scored[:cutoff]
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Clustering failed (%s) — keeping all docs", e)
|
||||||
|
return scored
|
||||||
|
|
||||||
|
|
||||||
|
def rerank(query: str, scored: list[tuple[dict, float]], top_k: int) -> list[tuple[dict, float]]:
|
||||||
|
"""ریرنک کراسانکودر BGE — مرتبسازی نهایی بر اساس شباهت واقعی پرسش/سند."""
|
||||||
|
if not scored:
|
||||||
|
return []
|
||||||
|
if not settings.reranker_enabled:
|
||||||
|
return scored[:top_k]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .reranker import get_reranker
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
pairs = [[query, doc["text"]] for doc, _ in scored]
|
||||||
|
scores = get_reranker().score(pairs)
|
||||||
|
rescored = sorted(
|
||||||
|
((doc, float(s)) for (doc, _), s in zip(scored, scores)),
|
||||||
|
key=lambda x: x[1],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
log.info("Reranked %d docs in %.2fs — top scores: %s",
|
||||||
|
len(scored), time.time() - start,
|
||||||
|
[f"{s:.2f}" for _, s in rescored[:3]])
|
||||||
|
return rescored[:top_k]
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Reranking failed (%s) — falling back to fusion order", e)
|
||||||
|
return scored[:top_k]
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_final(query: str, query_variants: list[str] | None = None) -> list[dict]:
|
||||||
|
"""خط کامل بازیابی برای یک پرسش: هر نوع → فیوژن → برش خوشه → ریرنک → K نهایی."""
|
||||||
|
variants = query_variants or [query]
|
||||||
|
|
||||||
|
pool: dict = {}
|
||||||
|
for variant in variants:
|
||||||
|
for doc, score in hybrid_retrieve(variant):
|
||||||
|
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "score": 0.0})
|
||||||
|
entry["score"] = max(entry["score"], score)
|
||||||
|
|
||||||
|
combined = sorted(((e["doc"], e["score"]) for e in pool.values()),
|
||||||
|
key=lambda x: x[1], reverse=True)
|
||||||
|
if not combined:
|
||||||
|
return []
|
||||||
|
|
||||||
|
filtered = cluster_cutoff(combined)
|
||||||
|
# ریرنک روی نتیجههای برشخورده (سقف retrieve_k تا تأخیر کنترل شود)
|
||||||
|
rerank_candidates = filtered[: settings.retrieve_k]
|
||||||
|
final = rerank(query, rerank_candidates, settings.best_of_k)
|
||||||
|
|
||||||
|
log.info("Final retrieval: %d docs (from %d candidates, %d variants)",
|
||||||
|
len(final), len(combined), len(variants))
|
||||||
|
return [doc for doc, _ in final]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""تستهای خالص (بدون مدل/دیتابیس) برای ماژولهای چتبات — اجرا: python test_pure.py"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from chatbot.content import chunk_documents, tiptap_blocks, body_to_text
|
||||||
|
from chatbot.vectorstore import chunk_content_hash, chatbot_dsn
|
||||||
|
from chatbot.persian import normalize, tokenize
|
||||||
|
|
||||||
|
docs = [
|
||||||
|
{"text": "پاراگراف کوتاه خدمت الف", "type": "service", "title": "الف", "url": "/services"},
|
||||||
|
{"text": "سؤال اول؟", "type": "faq", "title": "س۱", "url": "/faq"},
|
||||||
|
{"text": "سؤال دوم؟", "type": "faq", "title": "س۲", "url": "/faq"},
|
||||||
|
{"text": "آ" * 2000, "type": "article", "title": "مقاله بلند", "url": "/blog/long"},
|
||||||
|
]
|
||||||
|
chunks = chunk_documents(docs)
|
||||||
|
ids = [c["doc_id"] for c in chunks]
|
||||||
|
assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
|
||||||
|
|
||||||
|
faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
|
||||||
|
assert faq_ids == ["faq:0:1", "faq:1:1"], faq_ids
|
||||||
|
|
||||||
|
art = [c for c in chunks if c["type"] == "article"]
|
||||||
|
assert len(art) >= 2 and art[1]["doc_id"] == "article:0:2", [x["doc_id"] for x in art]
|
||||||
|
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
|
||||||
|
|
||||||
|
c = chunks[0]
|
||||||
|
assert c["content_hash"] == chunk_content_hash(c)
|
||||||
|
c2 = dict(c, title="عنوان عوض شد")
|
||||||
|
# hash فیلد کپیشده است — باید با محاسبه دوباره مقایسه شود، نه با فیلد قدیمی
|
||||||
|
assert chunk_content_hash(c2) != c["content_hash"]
|
||||||
|
|
||||||
|
chunks2 = chunk_documents(docs)
|
||||||
|
assert [x["doc_id"] for x in chunks2] == ids
|
||||||
|
assert [x["content_hash"] for x in chunks2] == [x["content_hash"] for x in chunks]
|
||||||
|
|
||||||
|
settings_dsn = chatbot_dsn()
|
||||||
|
assert settings_dsn.endswith("/aramesh_chatbot"), settings_dsn
|
||||||
|
assert "+psycopg" not in settings_dsn
|
||||||
|
|
||||||
|
# نرمالسازی و توکنسازی فارسی
|
||||||
|
assert normalize("كامل يك ۱۲۳") == "کامل یک 123"
|
||||||
|
t = tokenize("کدام شرکت ساختمانی خوب در تهران؟")
|
||||||
|
assert "شرکت" in t and "تهران" in t and "کدام" not in t, t
|
||||||
|
assert "می\u200cسازیم" in tokenize("می\u200cسازیم") and "سازیم" in tokenize("می\u200cسازیم")
|
||||||
|
|
||||||
|
# استخراج متن TipTap و فرمت قدیمی
|
||||||
|
tiptap_doc = {
|
||||||
|
"type": "doc",
|
||||||
|
"content": [
|
||||||
|
{"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "معرفی پروژه"}]},
|
||||||
|
{"type": "paragraph", "content": [
|
||||||
|
{"type": "text", "text": "این برج در تهران "},
|
||||||
|
{"type": "text", "marks": [{"type": "bold"}], "text": "ساخته شد"},
|
||||||
|
{"type": "hardBreak"},
|
||||||
|
{"type": "image", "attrs": {"alt": "نمای بیرونی"}},
|
||||||
|
]},
|
||||||
|
{"type": "bulletList", "content": [
|
||||||
|
{"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "۲۴ طبقه"}]}]},
|
||||||
|
]},
|
||||||
|
{"type": "paragraph", "content": [{"type": "image", "attrs": {"alt": ""}}]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
blocks = tiptap_blocks(tiptap_doc)
|
||||||
|
assert blocks == ["معرفی پروژه", "این برج در تهران ساخته شد [تصویر: نمای بیرونی]", "۲۴ طبقه"], blocks
|
||||||
|
assert body_to_text(["الف", "ب"]) == "الف\nب"
|
||||||
|
assert body_to_text(None) == ""
|
||||||
|
|
||||||
|
print(f"ALL PURE TESTS OK — {len(chunks)} chunks from {len(docs)} docs")
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""ذخیرهساز برداری pgvector — جایگزین FAISS در مهاجرت به pgvector.
|
||||||
|
|
||||||
|
بردارها در یک دیتابیس جدا (aramesh_chatbot) روی همان نمونه Postgres سایت
|
||||||
|
نگهداری میشوند تا دیتابیس سایت فقطخواندنی بماند. همگامسازی افزایشی است:
|
||||||
|
فقط سطرهای جدید/تغییریافته امبد میشوند (کلید: doc_id پایدار + hash محتوا).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_driver(dsn: str) -> str:
|
||||||
|
return dsn.replace("+psycopg", "").replace("+asyncpg", "")
|
||||||
|
|
||||||
|
|
||||||
|
def chatbot_dsn() -> str:
|
||||||
|
"""DSN دیتابیس اختصاصی چتبات — از DATABASE_URL با تعویض نام دیتابیس ساخته میشود."""
|
||||||
|
parts = urlsplit(_strip_driver(settings.database_url))
|
||||||
|
return urlunsplit(parts._replace(path=f"/{settings.chatbot_db_name}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_dsn() -> str:
|
||||||
|
"""DSN دیتابیس postgres برای ساخت دیتابیس چتبات (فقط یکبار)."""
|
||||||
|
parts = urlsplit(_strip_driver(settings.database_url))
|
||||||
|
return urlunsplit(parts._replace(path="/postgres"))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema() -> None:
|
||||||
|
"""ساخت دیتابیس و جدول بردارها — idempotent و در هر شروع اجرا میشود."""
|
||||||
|
import psycopg
|
||||||
|
from psycopg import errors as pg_errors
|
||||||
|
|
||||||
|
try:
|
||||||
|
with psycopg.connect(_admin_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||||
|
try:
|
||||||
|
conn.execute(f'CREATE DATABASE "{settings.chatbot_db_name}"')
|
||||||
|
log.info("Created chatbot database '%s'", settings.chatbot_db_name)
|
||||||
|
except pg_errors.DuplicateDatabase:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Cannot reach Postgres to ensure chatbot database: {e}") from e
|
||||||
|
|
||||||
|
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||||
|
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||||
|
conn.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS chatbot_chunks (
|
||||||
|
doc_id TEXT PRIMARY KEY,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
content JSONB NOT NULL,
|
||||||
|
embedding vector({settings.embedding_dim}) NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS chatbot_chunks_embedding_hnsw
|
||||||
|
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
||||||
|
"""همگامسازی افزایشی قطعات با جدول pgvector.
|
||||||
|
|
||||||
|
- سطرهایی که دیگر وجود ندارند حذف میشوند
|
||||||
|
- سطرهای جدید یا تغییر یافته (hash یا مدل امبدینگ عوض شده) امبد و upsert میشوند
|
||||||
|
- force=True همه سطرها را از نو امبد میکند
|
||||||
|
خروجی: آمار عملیات برای لاگ.
|
||||||
|
"""
|
||||||
|
import psycopg
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from .embeddings import get_embeddings
|
||||||
|
|
||||||
|
model = settings.embedding_model
|
||||||
|
wanted = {c["doc_id"]: c for c in chunks}
|
||||||
|
|
||||||
|
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||||
|
existing: dict[str, str] = {}
|
||||||
|
if not force:
|
||||||
|
for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
|
||||||
|
if row["model"] == model:
|
||||||
|
existing[row["doc_id"]] = row["content_hash"]
|
||||||
|
else:
|
||||||
|
conn.execute("DELETE FROM chatbot_chunks")
|
||||||
|
|
||||||
|
to_delete = [doc_id for doc_id in existing if doc_id not in wanted]
|
||||||
|
if to_delete:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM chatbot_chunks WHERE doc_id = ANY(%s)",
|
||||||
|
[to_delete],
|
||||||
|
)
|
||||||
|
|
||||||
|
to_upsert = [
|
||||||
|
c for c in chunks
|
||||||
|
if existing.get(c["doc_id"]) != c["content_hash"]
|
||||||
|
]
|
||||||
|
inserted = updated = 0
|
||||||
|
|
||||||
|
if to_upsert:
|
||||||
|
vectors = get_embeddings().embed_documents([c["text"] for c in to_upsert])
|
||||||
|
for chunk, vec in zip(to_upsert, vectors):
|
||||||
|
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO chatbot_chunks (doc_id, content_hash, model, content, embedding)
|
||||||
|
VALUES (%s, %s, %s, %s, %s::vector)
|
||||||
|
ON CONFLICT (doc_id) DO UPDATE SET
|
||||||
|
content_hash = EXCLUDED.content_hash,
|
||||||
|
model = EXCLUDED.model,
|
||||||
|
content = EXCLUDED.content,
|
||||||
|
embedding = EXCLUDED.embedding
|
||||||
|
""",
|
||||||
|
(chunk["doc_id"], chunk["content_hash"], model, Jsonb(chunk), literal),
|
||||||
|
)
|
||||||
|
if chunk["doc_id"] in existing:
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
stats = {"inserted": inserted, "updated": updated, "deleted": len(to_delete),
|
||||||
|
"unchanged": len(wanted) - len(to_upsert)}
|
||||||
|
log.info("Vector sync: %s", stats)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all_chunks() -> list[dict]:
|
||||||
|
"""همه قطعات ذخیرهشده (برای ساخت BM25 در حافظه)."""
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"doc_id": r["doc_id"],
|
||||||
|
"content_hash": r["content_hash"],
|
||||||
|
**(r["content"] if isinstance(r["content"], dict) else json.loads(r["content"])),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def vector_count() -> int:
|
||||||
|
"""تعداد بردارهای ذخیرهشده — برای healthz (خطا → ۰)."""
|
||||||
|
try:
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
|
||||||
|
return int(conn.execute("SELECT count(*) FROM chatbot_chunks").fetchone()[0])
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||||
|
"""جستوجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
|
||||||
|
|
||||||
|
خطا (مثلاً دیتابیس هنوز آماده نیست) → فهرست خالی تا بازیابی فقط با BM25 ادامه یابد.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row as _dict_row
|
||||||
|
|
||||||
|
from .embeddings import get_embeddings
|
||||||
|
|
||||||
|
vec = get_embeddings().embed_query(query)
|
||||||
|
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
|
||||||
|
with psycopg.connect(chatbot_dsn(), row_factory=_dict_row, connect_timeout=10) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT doc_id, content, 1 - (embedding <=> %s::vector) AS sim
|
||||||
|
FROM chatbot_chunks
|
||||||
|
ORDER BY embedding <=> %s::vector
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(literal, literal, top_k),
|
||||||
|
).fetchall()
|
||||||
|
return [(_row_to_doc(r), float(r["sim"])) for r in rows]
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Dense search failed (%s) — falling back to keyword only", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_doc(row: Any) -> dict:
|
||||||
|
content = row["content"] if isinstance(row["content"], dict) else json.loads(row["content"])
|
||||||
|
return {
|
||||||
|
"doc_id": row["doc_id"],
|
||||||
|
"text": content.get("text", ""),
|
||||||
|
"type": content.get("type", ""),
|
||||||
|
"title": content.get("title", ""),
|
||||||
|
"url": content.get("url", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_content_hash(chunk: dict) -> str:
|
||||||
|
"""هش محتوای قطعه — تغییر متن یا متادیتا یعنی سطر باید بهروز شود."""
|
||||||
|
payload = json.dumps(
|
||||||
|
{k: chunk[k] for k in ("text", "type", "title", "url")},
|
||||||
|
ensure_ascii=False, sort_keys=True,
|
||||||
|
)
|
||||||
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# لایه مانیتورینگ — Prometheus، Grafana، Loki، Alertmanager و صادرکنندهها
|
||||||
|
#
|
||||||
|
# توسعه: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
|
||||||
|
# تولید: سه فایل با هم: base + monitoring + prod (بخش مانیتورینگ README)
|
||||||
|
#
|
||||||
|
# داشبورد: grafana.localhost (توسعه) — رمز از GRAFANA_ADMIN_PASSWORD در .env
|
||||||
|
# هشدارها: Prometheus → Alertmanager → تلگرام (ALERT_TELEGRAM_* در .env)
|
||||||
|
# منابع: مجموعاً حدود ۱ تا ۱.۵ گیگابایت RAM — با حذف هر سرویس، بقیه سرپا میمانند.
|
||||||
|
|
||||||
|
x-default-logging: &default-logging
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.53.0
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.path=/prometheus
|
||||||
|
- --storage.tsdb.retention.time=15d
|
||||||
|
- --storage.tsdb.retention.size=2GB
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/prometheus:/etc/prometheus:ro
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
networks: [monitoring, edge]
|
||||||
|
environment:
|
||||||
|
# پروب عمومی — در تولید SITE_URL در .env تنظیم میشود (مثل https://aramesh.example)
|
||||||
|
SITE_URL: ${SITE_URL:-http://frontend/}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 15s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 768M
|
||||||
|
|
||||||
|
alertmanager:
|
||||||
|
image: prom/alertmanager:v0.27.0
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/alertmanager/alertmanager.rendered.yml
|
||||||
|
- --storage.path=/alertmanager
|
||||||
|
# فایل رندرشدهٔ alertmanager.rendered.yml را scripts/stack.sh از قالب
|
||||||
|
# alertmanager.yml با مقادیر .env (ALERT_BOT_TOKEN/ALERT_CHAT_ID) میسازد —
|
||||||
|
# Alertmanager خودش متغیر محیطی را در کانفیگ جایگزین نمیکند
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/alertmanager:/etc/alertmanager:ro
|
||||||
|
- alertmanager_data:/alertmanager
|
||||||
|
networks: [monitoring]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128M
|
||||||
|
|
||||||
|
# منابع هر کانتینر — روی Linux سرور کامل کار میکند؛ روی Docker Desktop ویندوز
|
||||||
|
# ممکن است محدود باشد (حذفش بقیه استک را نمیشکند)
|
||||||
|
cadvisor:
|
||||||
|
image: gcr.io/cadvisor/cadvisor:v0.49.1
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
privileged: true
|
||||||
|
volumes:
|
||||||
|
- /:/rootfs:ro
|
||||||
|
- /var/run:/var/run:ro
|
||||||
|
- /sys:/sys:ro
|
||||||
|
- /var/lib/docker/:/var/lib/docker:ro
|
||||||
|
networks: [monitoring]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 192M
|
||||||
|
|
||||||
|
# منابع میزبان + سنجهٔ تازگی پشتیبان (backups/*.prom نوشتهٔ scripts/backup.sh)
|
||||||
|
node-exporter:
|
||||||
|
image: quay.io/prometheus/node-exporter:v1.8.2
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
command:
|
||||||
|
- --path.rootfs=/host
|
||||||
|
- --collector.textfile.directory=/textfile
|
||||||
|
volumes:
|
||||||
|
- /:/host:ro
|
||||||
|
- ./backups:/textfile:ro
|
||||||
|
networks: [monitoring]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 64M
|
||||||
|
|
||||||
|
postgres-exporter:
|
||||||
|
image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
environment:
|
||||||
|
DATA_SOURCE_NAME: postgresql://aramesh:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}@db:5432/arameshplaza?sslmode=disable
|
||||||
|
networks: [internal, monitoring]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 64M
|
||||||
|
|
||||||
|
blackbox-exporter:
|
||||||
|
image: prom/blackbox-exporter:v0.25.0
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
# edge برای رسیدن به سرویسهایی که پروب میشوند (frontend/minio/umami/chatbot/backend)
|
||||||
|
networks: [monitoring, edge]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 64M
|
||||||
|
|
||||||
|
# لاگ همهٔ کانتینرها — جستوجو در Grafana؛ خطاهای بکاند از همینجا دیده میشوند
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:3.1.1
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
command: -config.file=/etc/loki/loki-config.yml
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/loki:/etc/loki:ro
|
||||||
|
- loki_data:/loki
|
||||||
|
networks: [monitoring]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
|
promtail:
|
||||||
|
image: grafana/promtail:3.1.1
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
command: -config.file=/etc/promtail/promtail-config.yml
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/promtail:/etc/promtail:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- promtail_positions:/positions
|
||||||
|
networks: [monitoring]
|
||||||
|
depends_on: [loki]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128M
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:11.1.0
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD را در .env تنظیم کنید}
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
volumes:
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
networks: [monitoring, edge]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || wget -q --spider http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.grafana.rule=Host(`grafana.localhost`)
|
||||||
|
- traefik.http.routers.grafana.entrypoints=web
|
||||||
|
- traefik.http.services.grafana.loadbalancer.server.port=3000
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 384M
|
||||||
|
|
||||||
|
networks:
|
||||||
|
monitoring:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
prometheus_data:
|
||||||
|
alertmanager_data:
|
||||||
|
loki_data:
|
||||||
|
grafana_data:
|
||||||
|
promtail_positions:
|
||||||
+98
-16
@@ -1,15 +1,28 @@
|
|||||||
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورتهای استاندارد ۸۰/۴۴۳.
|
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورتهای استاندارد ۸۰/۴۳.
|
||||||
# پیشنیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ بهعلاوه رکورد DNS دامنه به این سرور.
|
# پیشنیاز: متغیرهای 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 پاسخ میدهند.
|
# همه درخواستهای 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:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
command: !override
|
command: !override
|
||||||
- --providers.docker=true
|
- --providers.docker=true
|
||||||
- --providers.docker.exposedbydefault=false
|
- --providers.docker.exposedbydefault=false
|
||||||
|
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
|
||||||
- --entrypoints.web.address=:80
|
- --entrypoints.web.address=:80
|
||||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||||
@@ -18,6 +31,9 @@ services:
|
|||||||
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
|
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
|
||||||
- --certificatesresolvers.le.acme.httpchallenge=true
|
- --certificatesresolvers.le.acme.httpchallenge=true
|
||||||
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
|
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
|
||||||
|
- --ping=true
|
||||||
|
- --accesslog=true
|
||||||
|
- --metrics.prometheus=true
|
||||||
ports: !override
|
ports: !override
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
@@ -25,26 +41,40 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- letsencrypt_data:/letsencrypt
|
- letsencrypt_data:/letsencrypt
|
||||||
|
|
||||||
backend:
|
# MinIO کنسول (رمز root) و API مستقیمش فقط از localhost سرور در دسترس باشند —
|
||||||
environment:
|
# از راه دور با تونل SSH: ssh -L 9001:127.0.0.1:9001 server
|
||||||
# سایت هممبدأ است — CORS لازم نیست
|
|
||||||
CORS_ORIGINS: '[]'
|
|
||||||
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
|
||||||
- traefik.http.routers.api.entrypoints=websecure
|
|
||||||
- traefik.http.routers.api.tls.certresolver=le
|
|
||||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
|
||||||
- traefik.http.routers.seo.entrypoints=websecure
|
|
||||||
- traefik.http.routers.seo.tls.certresolver=le
|
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:9001:9001"
|
||||||
|
- "127.0.0.1:19000:9000"
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
|
- traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
|
||||||
- traefik.http.routers.media.entrypoints=websecure
|
- traefik.http.routers.media.entrypoints=websecure
|
||||||
- traefik.http.routers.media.tls.certresolver=le
|
- 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:
|
frontend:
|
||||||
labels:
|
labels:
|
||||||
@@ -52,6 +82,58 @@ services:
|
|||||||
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
|
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
|
||||||
- traefik.http.routers.web.entrypoints=websecure
|
- traefik.http.routers.web.entrypoints=websecure
|
||||||
- traefik.http.routers.web.tls.certresolver=le
|
- 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:
|
volumes:
|
||||||
letsencrypt_data:
|
letsencrypt_data:
|
||||||
|
|||||||
+230
-20
@@ -1,71 +1,121 @@
|
|||||||
# مقادیر محرمانه از فایل .env ریشه خوانده میشوند — از .env.example کپی کنید:
|
# مقادیر محرمانه از فایل .env ریشه خوانده میشوند — از .env.example کپی کنید:
|
||||||
# cp .env.example .env
|
# cp .env.example .env (یا: bash scripts/gen-env.sh)
|
||||||
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README).
|
# برای انتشار با 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:
|
services:
|
||||||
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند) و /media (MinIO)
|
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند)، /media (MinIO)،
|
||||||
|
# /chat (چتبات)، / (آنالیتیکس روی analytics.localhost — پایین همین فایل)
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3
|
# v3.3+ قدیمیترین کلاینت داکر را دارد که با Engineهای جدید (API 1.5x) مشکل دارد —
|
||||||
|
# با v3.6 پین شده است
|
||||||
|
image: traefik:v3.6
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
command:
|
command:
|
||||||
- --providers.docker=true
|
- --providers.docker=true
|
||||||
- --providers.docker.exposedbydefault=false
|
- --providers.docker.exposedbydefault=false
|
||||||
# فقط کانتینرهای همین استک — با استکهای دیگر هممیزبان تداخل نام روتر پیش نمیآید
|
# فقط کانتینرهای همین استک — با استکهای دیگر هممیزبان تداخل نام روتر پیش نمیآید
|
||||||
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
|
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
|
||||||
- --entrypoints.web.address=:80
|
- --entrypoints.web.address=:80
|
||||||
|
# healthcheck داخلی + متریکها برای Prometheus + لاگ دسترسی
|
||||||
|
- --ping=true
|
||||||
|
- --accesslog=true
|
||||||
|
- --metrics.prometheus=true
|
||||||
ports:
|
ports:
|
||||||
- "${TRAEFIK_HTTP_PORT:-5173}:80"
|
- "${TRAEFIK_HTTP_PORT:-5173}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /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:
|
db:
|
||||||
image: postgres:17-alpine
|
# pgvector/pgvector:pg17 = همان PostgreSQL 17 + افزونهٔ vector برای چتبات
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: aramesh
|
POSTGRES_USER: aramesh
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||||
POSTGRES_DB: arameshplaza
|
POSTGRES_DB: arameshplaza
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- db_data:/var/lib/postgresql/data
|
||||||
|
shm_size: 256m
|
||||||
|
networks: [internal]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U aramesh -d arameshplaza"]
|
test: ["CMD-SHELL", "pg_isready -U aramesh -d arameshplaza"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
|
networks: [edge, internal]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
ports:
|
ports:
|
||||||
- "9001:9001" # کنسول MinIO — در محیط واقعی با فایروال ببندید
|
# در تولید، docker-compose.prod.yml اینها را فقط روی 127.0.0.1 میبندد —
|
||||||
- "19000:9000" # دسترسی مستقیم/لوکال به MinIO (اختیاری — تصاویر از مسیر /media سرو میشوند)
|
# کنسول رمز root دارد و نباید به اینترنت باز باشد
|
||||||
|
- "9001:9001"
|
||||||
|
- "19000:9000"
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
|
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||||
|
- traefik.docker.network=aramesh-backend_edge
|
||||||
- traefik.http.routers.media.rule=PathPrefix(`/media`)
|
- traefik.http.routers.media.rule=PathPrefix(`/media`)
|
||||||
- traefik.http.routers.media.entrypoints=web
|
- traefik.http.routers.media.entrypoints=web
|
||||||
- traefik.http.routers.media.priority=100
|
- 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.regex=^/media/(.*)
|
||||||
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
- 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
|
- traefik.http.services.media.loadbalancer.server.port=9000
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
# یکبار اجرا میشود: ساخت باکت، بازگذاشتن خواندن عمومی و پیشبارگذاری تصویرهای seed.
|
# یکبار اجرا میشود: ساخت باکت، بازگذاشتن خواندن عمومی و پیشبارگذاری تصویرهای seed
|
||||||
# تصویرهای داخل seed-media/ (که با پیشوند /media در دیتابیس ثبتاند) روی یک حجم تازه
|
# فقط وقتی باکت خالی است — تا هر deploy، کپی تکراری تصویرها را تکرار نکند.
|
||||||
# وارد باکت میشوند تا هر نسخه تازهکلون/بیلد، همان عکسها را داشته باشد؛
|
|
||||||
# روی حجم موجود، mc cp فایلهای تکراری را رد میکند و دادهتازه را نمیخورد.
|
|
||||||
minio-setup:
|
minio-setup:
|
||||||
image: minio/mc:latest
|
image: minio/mc:RELEASE.2025-04-08T15-39-49Z
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -76,19 +126,51 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./seed-media:/seed-media:ro
|
- ./seed-media:/seed-media:ro
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
networks: [internal]
|
||||||
|
logging: *default-logging
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||||
mc mb --ignore-existing local/$$MINIO_BUCKET &&
|
mc mb --ignore-existing local/$$MINIO_BUCKET &&
|
||||||
mc anonymous set download local/$$MINIO_BUCKET &&
|
mc anonymous set download local/$$MINIO_BUCKET &&
|
||||||
echo 'minio-setup: bucket ready' &&
|
COUNT=$$(mc ls --recursive local/$$MINIO_BUCKET | wc -l) &&
|
||||||
cd /seed-media && mc mirror --overwrite . local/$$MINIO_BUCKET/ &&
|
if [ \"$$COUNT\" -eq 0 ]; then
|
||||||
echo 'minio-setup: seed media mirrored'
|
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:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
||||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
|
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
|
||||||
@@ -106,11 +188,14 @@ services:
|
|||||||
FRONTEND_DIST: /srv/frontend
|
FRONTEND_DIST: /srv/frontend
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/dist:/srv/frontend:ro
|
- ./frontend/dist:/srv/frontend:ro
|
||||||
|
networks: [edge, internal]
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
db-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
@@ -119,9 +204,17 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
|
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||||
|
- traefik.docker.network=aramesh-backend_edge
|
||||||
- traefik.http.routers.api.rule=PathPrefix(`/api`)
|
- traefik.http.routers.api.rule=PathPrefix(`/api`)
|
||||||
- traefik.http.routers.api.entrypoints=web
|
- traefik.http.routers.api.entrypoints=web
|
||||||
- traefik.http.routers.api.priority=100
|
- 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
|
- traefik.http.services.api.loadbalancer.server.port=8000
|
||||||
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
|
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
|
||||||
# مسیرهای /en/... هم همه به بکاند میروند تا متای سئوی انگلیسی و html انگلیسی تزریق شود
|
# مسیرهای /en/... هم همه به بکاند میروند تا متای سئوی انگلیسی و html انگلیسی تزریق شود
|
||||||
@@ -129,29 +222,111 @@ services:
|
|||||||
- traefik.http.routers.seo.entrypoints=web
|
- traefik.http.routers.seo.entrypoints=web
|
||||||
- traefik.http.routers.seo.priority=50
|
- traefik.http.routers.seo.priority=50
|
||||||
- traefik.http.routers.seo.service=api
|
- traefik.http.routers.seo.service=api
|
||||||
|
# /metrics فقط برای Prometheus — روتری تعریف نمیشود، از بیرون در دسترس نیست
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 768M
|
||||||
|
|
||||||
# فقط سرو فایلهای استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
|
# فقط سرو فایلهای استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
- minio
|
condition: service_healthy
|
||||||
|
networks: [edge]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.web.rule=PathPrefix(`/`)
|
- traefik.http.routers.web.rule=PathPrefix(`/`)
|
||||||
- traefik.http.routers.web.entrypoints=web
|
- traefik.http.routers.web.entrypoints=web
|
||||||
- traefik.http.routers.web.priority=1
|
- traefik.http.routers.web.priority=1
|
||||||
- traefik.http.services.web.loadbalancer.server.port=80
|
- 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 این کار را میکند)
|
# ابزار پشتیبانگیری — فقط با پروفایل tools اجرا میشود (scripts/backup.sh این کار را میکند)
|
||||||
mc:
|
mc:
|
||||||
image: minio/mc:latest
|
image: minio/mc:RELEASE.2025-04-08T15-39-49Z
|
||||||
profiles: ["tools"]
|
profiles: ["tools"]
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
@@ -159,7 +334,42 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
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:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
chatbot_hf:
|
||||||
|
backups:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# فقط Caddyfile و dist/ به کانتکست بیلد میروند — node_modules و سورس عظیماند و لازم نیستند
|
||||||
|
*
|
||||||
|
!Caddyfile
|
||||||
|
!dist
|
||||||
+11
-2
@@ -1,12 +1,21 @@
|
|||||||
|
{
|
||||||
|
# پنل مدیریت Caddy لازم نیست — سطح حمله کمتر
|
||||||
|
admin off
|
||||||
|
}
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
root * /srv
|
root * /srv
|
||||||
encode gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمیگردند
|
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمیگردند
|
||||||
try_files {path} /index.html
|
try_files {path} /index.html
|
||||||
file_server
|
file_server
|
||||||
|
|
||||||
# کش بلندمدت برای فایلهای هشدار بیلد
|
# فایلهای هشدار بیلد: کش بلندمدت بیرحم
|
||||||
@assets path /assets/*
|
@assets path /assets/*
|
||||||
header @assets Cache-Control "public, immutable, max-age=31536000"
|
header @assets Cache-Control "public, immutable, max-age=31536000"
|
||||||
|
|
||||||
|
# بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
|
||||||
|
@notassets not path /assets/*
|
||||||
|
header @notassets Cache-Control "no-cache"
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -4,7 +4,17 @@
|
|||||||
|
|
||||||
FROM caddy:2-alpine
|
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 Caddyfile /etc/caddy/Caddyfile
|
||||||
COPY dist /srv
|
COPY --chown=caddy:caddy dist /srv
|
||||||
|
|
||||||
|
# بدون root — فقط فایلهای استاتیک سرو میشوند و TLS را Traefik تمام میکند
|
||||||
|
USER caddy
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
+29
-5
@@ -1,11 +1,36 @@
|
|||||||
|
|
||||||
|
|
||||||
# Aramesh Construction Group — Frontend
|
# Aramesh Construction Group — Frontend
|
||||||
|
|
||||||
A React + Vite + Tailwind CSS application. All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
|
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
|
## 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.
|
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
|
||||||
|
|
||||||
|
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
|
||||||
|
|
||||||
|
## Recent UI/UX Improvements
|
||||||
|
* **Tiptap Editor Fixes**:
|
||||||
|
* Resolved the initialization bug where the editor body remained empty during edit mode.
|
||||||
|
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
|
||||||
|
* **Dynamic TOC & Layout**:
|
||||||
|
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
|
||||||
|
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
|
||||||
|
|
||||||
|
## CMS, SEO & Editor Features
|
||||||
|
|
||||||
|
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
|
||||||
|
|
||||||
|
* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting.
|
||||||
|
* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema.
|
||||||
|
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
|
||||||
|
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
|
||||||
|
* **Custom Tiptap WYSIWYG Editor:**
|
||||||
|
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
|
||||||
|
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls.
|
||||||
|
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
|
||||||
|
* **Full Jalali (Shamsi) Calendar Integration:**
|
||||||
|
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
|
||||||
|
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
@@ -35,4 +60,3 @@ npm run build
|
|||||||
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
|
* `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/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
|
||||||
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
|
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,16 @@
|
|||||||
document.documentElement.setAttribute('dir', 'ltr');
|
document.documentElement.setAttribute('dir', 'ltr');
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+147
-5
@@ -19,7 +19,11 @@
|
|||||||
"lucide-react": "0.522.0",
|
"lucide-react": "0.522.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.88.0",
|
||||||
|
"react-multi-date-picker": "^4.5.2",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
|
"react-select": "^5.10.2",
|
||||||
|
"slugify": "^1.6.9",
|
||||||
"sweetalert2": "^11.26.25"
|
"sweetalert2": "^11.26.25"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -933,7 +937,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/utils": "^0.2.12"
|
"@floating-ui/utils": "^0.2.12"
|
||||||
}
|
}
|
||||||
@@ -943,7 +946,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.8.0",
|
"@floating-ui/core": "^1.8.0",
|
||||||
"@floating-ui/utils": "^0.2.12"
|
"@floating-ui/utils": "^0.2.12"
|
||||||
@@ -953,8 +955,7 @@
|
|||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@humanwhocodes/config-array": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
@@ -2020,6 +2021,15 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@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": {
|
"node_modules/@types/semver": {
|
||||||
"version": "7.8.0",
|
"version": "7.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
|
||||||
@@ -2763,6 +2773,16 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-helpers": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.8.7",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.407",
|
"version": "1.5.407",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
||||||
@@ -3733,6 +3753,12 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/memoize-one": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -3860,7 +3886,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -4238,6 +4263,17 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/prosemirror-changeset": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
|
||||||
@@ -4420,6 +4456,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
@@ -4433,12 +4475,52 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-element-popper": {
|
||||||
|
"version": "2.1.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-element-popper/-/react-element-popper-2.1.7.tgz",
|
||||||
|
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-hook-form": {
|
||||||
|
"version": "7.88.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz",
|
||||||
|
"integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-hook-form"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -4481,6 +4563,43 @@
|
|||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -4693,6 +4812,15 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/source-map": {
|
||||||
"version": "0.5.7",
|
"version": "0.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||||
@@ -5055,6 +5183,20 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
|||||||
@@ -21,7 +21,11 @@
|
|||||||
"lucide-react": "0.522.0",
|
"lucide-react": "0.522.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.88.0",
|
||||||
|
"react-multi-date-picker": "^4.5.2",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
|
"react-select": "^5.10.2",
|
||||||
|
"slugify": "^1.6.9",
|
||||||
"sweetalert2": "^11.26.25"
|
"sweetalert2": "^11.26.25"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Preloader } from './components/Preloader';
|
|||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Footer } from './components/Footer';
|
import { Footer } from './components/Footer';
|
||||||
import { FloatingContact } from './components/FloatingContact';
|
import { FloatingContact } from './components/FloatingContact';
|
||||||
|
import { ChatWidget } from './components/ChatWidget';
|
||||||
import { Home } from './pages/Home';
|
import { Home } from './pages/Home';
|
||||||
import { About } from './pages/About';
|
import { About } from './pages/About';
|
||||||
import { ServicesPage } from './pages/ServicesPage';
|
import { ServicesPage } from './pages/ServicesPage';
|
||||||
@@ -106,6 +107,7 @@ function AppChrome() {
|
|||||||
<AnimatedRoutes />
|
<AnimatedRoutes />
|
||||||
{!isAdminPage && <Footer />}
|
{!isAdminPage && <Footer />}
|
||||||
{!isAdminPage && <FloatingContact />}
|
{!isAdminPage && <FloatingContact />}
|
||||||
|
{!isAdminPage && <ChatWidget />}
|
||||||
{isAuthenticated && !isAdminPage && <AdminBar />}
|
{isAuthenticated && !isAdminPage && <AdminBar />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -115,8 +117,10 @@ function AppShell() {
|
|||||||
const { data, error, reload } = useSiteData();
|
const { data, error, reload } = useSiteData();
|
||||||
const { t, dir, lang } = useLang();
|
const { t, dir, lang } = useLang();
|
||||||
|
|
||||||
|
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
||||||
|
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
||||||
return (
|
return (
|
||||||
<div dir={dir} lang={lang} 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 />
|
<Preloader />
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||||
|
|||||||
@@ -188,6 +188,28 @@ export function AdminBar() {
|
|||||||
رمز
|
رمز
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
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 { 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 Swal from 'sweetalert2'
|
||||||
import { useSiteData } from '../api'
|
import { useSiteData } from '../api'
|
||||||
import { useAdmin } from './AdminContext'
|
import { useAdmin } from './AdminContext'
|
||||||
@@ -8,28 +9,118 @@ import { adminApi } from './adminClient'
|
|||||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||||
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
||||||
import { ImageField } from '../components/admin/ImageField'
|
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
|
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||||||
|
|
||||||
type Draft = {
|
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
|
||||||
id: number
|
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
|
||||||
slug: string
|
|
||||||
|
/**
|
||||||
|
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت میشوند:
|
||||||
|
* عنوان (مبنای Alt پویا)، دستهبندی و متادیتای تصویر شاخص.
|
||||||
|
* `category` در فرم یک شیء تکمقداری {value,label} یا null است
|
||||||
|
* که هنگام ذخیره به رشته (ستون category در بکاند) نگاشت میشود.
|
||||||
|
*/
|
||||||
|
type ArticleMetaForm = {
|
||||||
title: string
|
title: string
|
||||||
title_en: string
|
category: CategoryOption | null
|
||||||
date: string
|
image_alt: string
|
||||||
date_en: string
|
image_title: string
|
||||||
category: string
|
}
|
||||||
category_en: string
|
|
||||||
image: string
|
/** مقدار رشتهای ذخیرهشده → گزینه react-select (یا null وقتی خالی است) */
|
||||||
read: string
|
function toCategoryOption(value: string): CategoryOption | null {
|
||||||
read_en: string
|
const category = (value ?? '').trim()
|
||||||
excerpt: string
|
return category ? { label: category, value: category } : null
|
||||||
excerpt_en: string
|
}
|
||||||
body: ArticleBodyDoc
|
|
||||||
body_en: ArticleBodyDoc
|
/** گزینه react-select → مقدار رشتهای ذخیرهسازی در بکاند */
|
||||||
status: 'draft' | 'published'
|
function categoryValue(option: CategoryOption | null): string {
|
||||||
seo_keyphrase: 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> = {
|
const STATUS_TEXT: Record<string, string> = {
|
||||||
@@ -57,11 +148,16 @@ export function ArticleWritePage() {
|
|||||||
slug: `article-${Date.now()}`,
|
slug: `article-${Date.now()}`,
|
||||||
title: '',
|
title: '',
|
||||||
title_en: '',
|
title_en: '',
|
||||||
date: new Date().toLocaleDateString('fa-IR'),
|
date: new Date().toISOString().split('T')[0],
|
||||||
date_en: '',
|
date_en: '',
|
||||||
category: 'یادداشت',
|
category: ARTICLE_CATEGORIES[0],
|
||||||
category_en: '',
|
category_en: '',
|
||||||
|
tags: [],
|
||||||
image: '',
|
image: '',
|
||||||
|
image_alt: '',
|
||||||
|
image_title: '',
|
||||||
|
author: '',
|
||||||
|
meta_description: '',
|
||||||
read: '۵ دقیقه',
|
read: '۵ دقیقه',
|
||||||
read_en: '',
|
read_en: '',
|
||||||
excerpt: '',
|
excerpt: '',
|
||||||
@@ -71,7 +167,31 @@ export function ArticleWritePage() {
|
|||||||
status: 'draft',
|
status: 'draft',
|
||||||
seo_keyphrase: '',
|
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)
|
const [createdId, setCreatedId] = useState<number | null>(null)
|
||||||
|
/**
|
||||||
|
* شناسه محتوای بارگذاریشده — با رسیدن مقاله از سرور عوض میشود تا
|
||||||
|
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
|
||||||
|
*/
|
||||||
|
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const draftRef = useRef(draft)
|
const draftRef = useRef(draft)
|
||||||
draftRef.current = draft
|
draftRef.current = draft
|
||||||
@@ -105,13 +225,24 @@ export function ArticleWritePage() {
|
|||||||
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
|
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
|
||||||
setDraft({
|
setDraft({
|
||||||
...(article as unknown as Draft),
|
...(article as unknown as Draft),
|
||||||
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
|
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
|
||||||
|
// بدنه ذخیرهشده (سند جدید یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||||
|
body: toArticleBodyDoc(article.body),
|
||||||
body_en:
|
body_en:
|
||||||
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
|
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
|
||||||
? (rawEnBody as ArticleBodyDoc)
|
? (rawEnBody as ArticleBodyDoc)
|
||||||
: EMPTY_DOC,
|
: EMPTY_DOC,
|
||||||
})
|
})
|
||||||
setCreatedId(article.id)
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [article?.id])
|
}, [article?.id])
|
||||||
|
|
||||||
@@ -207,9 +338,15 @@ export function ArticleWritePage() {
|
|||||||
queueSave({ [name]: e.target.value }),
|
queueSave({ [name]: e.target.value }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const metaLength = (draft.meta_description ?? '').trim().length
|
||||||
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
||||||
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
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 (
|
return (
|
||||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||||||
{/* نوار بالا */}
|
{/* نوار بالا */}
|
||||||
@@ -302,49 +439,121 @@ export function ArticleWritePage() {
|
|||||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* عنوان — در ویرایش انگلیسی، متن فارسی فعلی جاینگهدار میماند */}
|
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
|
||||||
<input
|
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره میشود */}
|
||||||
type="text"
|
<Controller
|
||||||
{...textField(suf('title'))}
|
name="title"
|
||||||
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
|
control={control}
|
||||||
aria-label="عنوان مقاله"
|
render={({ field }) => (
|
||||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
<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-4 h-px w-28 bg-copper" />
|
||||||
|
|
||||||
{/* تصویر شاخص */}
|
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصلاند) */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<ImageField
|
<ImageField
|
||||||
label="تصویر شاخص مقاله"
|
label="تصویر شاخص مقاله"
|
||||||
value={draft.image}
|
value={draft.image}
|
||||||
|
alt={imageAltField.value}
|
||||||
|
altFallback={imageAltFallback}
|
||||||
|
title={imageTitleField.value}
|
||||||
onSave={async (url) => {
|
onSave={async (url) => {
|
||||||
queueSave({ image: url })
|
queueSave({ image: url })
|
||||||
return true
|
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>
|
||||||
|
|
||||||
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<ArticleEditor
|
<ArticleEditor
|
||||||
|
contentKey={loadedContentKey}
|
||||||
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
||||||
onChange={(json) => queueSave({ [suf('body')]: json })}
|
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>
|
</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
|
<SeoChecklist
|
||||||
input={{
|
input={{
|
||||||
title: draft.title,
|
title: draft.title,
|
||||||
slug: draft.slug,
|
slug: draft.slug,
|
||||||
excerpt: draft.excerpt,
|
excerpt: draft.meta_description || draft.excerpt,
|
||||||
body: draft.body,
|
body: draft.body,
|
||||||
keyphrase: draft.seo_keyphrase,
|
keyphrase: draft.seo_keyphrase,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
|
<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 className="mt-5 grid gap-5">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمیشود)</label>
|
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمیشود)</label>
|
||||||
@@ -356,38 +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"
|
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||||
<textarea
|
<div>
|
||||||
rows={2}
|
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||||
{...textField(suf('excerpt'))}
|
<textarea
|
||||||
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : undefined}
|
rows={2}
|
||||||
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"
|
{...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>
|
||||||
<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>
|
<div>
|
||||||
<label className="text-xs font-light text-ivory/50">دسته</label>
|
<label className="text-xs font-light text-ivory/50">دستهبندی مقاله (Category)</label>
|
||||||
<input
|
<div className="mt-2">
|
||||||
type="text"
|
{/* تکانتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
|
||||||
{...textField(suf('category'))}
|
<Controller
|
||||||
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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-light text-ivory/50">تاریخ</label>
|
<label className="text-xs font-light text-ivory/50">هشتگها / برچسبها</label>
|
||||||
<input
|
<div className="mt-2">
|
||||||
type="text"
|
<LuxeTagsSelect
|
||||||
{...textField(suf('date'))}
|
value={normalizeTags(draft.tags)}
|
||||||
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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label>
|
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
{...textField(suf('read'))}
|
disabled={editLang !== 'en'}
|
||||||
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"
|
{...(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>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { useSiteData } from '../api'
|
|||||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||||
import { adminApi } from './adminClient'
|
import { adminApi } from './adminClient'
|
||||||
import { editableFields } from './entities'
|
import { editableFields } from './entities'
|
||||||
|
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||||
import type { EntityConfig, FieldConfig } from './entities'
|
import type { EntityConfig, FieldConfig } from './entities'
|
||||||
import { ImageField } from '../components/admin/ImageField'
|
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'
|
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||||
|
|
||||||
type EntityDrawerProps = {
|
type EntityDrawerProps = {
|
||||||
@@ -117,22 +120,46 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
{fields.map((field) => (
|
{fields.map((field) => {
|
||||||
<FieldControl
|
const isImage = field.type === 'image'
|
||||||
key={field.name}
|
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
||||||
field={field}
|
const altKey = isImage
|
||||||
value={item[field.name]}
|
? field.name === 'src' && 'alt' in item
|
||||||
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
|
? 'alt'
|
||||||
onSave={(value) => saveField(field.name, value)}
|
: `${field.name}_alt`
|
||||||
registerFlush={
|
: ''
|
||||||
field.type === 'wysiwyg'
|
const imageTitleKey = isImage ? `${field.name}_title` : ''
|
||||||
? (fn) => {
|
// پیشنهاد Alt از عنوان رکورد ساخته میشود (هرگز از URL)
|
||||||
flushRef.current = fn
|
const imageAltFallback = [
|
||||||
}
|
String(item[config.titleKey] ?? '').trim(),
|
||||||
: undefined
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -157,19 +184,36 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
|||||||
function FieldControl({
|
function FieldControl({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
|
altValue,
|
||||||
|
altFallback,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
imageTitleValue,
|
||||||
onSave,
|
onSave,
|
||||||
|
onSaveAlt,
|
||||||
|
onSaveImageTitle,
|
||||||
registerFlush,
|
registerFlush,
|
||||||
}: {
|
}: {
|
||||||
field: FieldConfig
|
field: FieldConfig
|
||||||
value: unknown
|
value: unknown
|
||||||
|
/** مقدار Alt تصویر — فقط برای فیلدهای image */
|
||||||
|
altValue?: string
|
||||||
|
altFallback?: string
|
||||||
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی بهعنوان جاینگهدار نمایش داده میشود */
|
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی بهعنوان جاینگهدار نمایش داده میشود */
|
||||||
placeholder?: unknown
|
placeholder?: unknown
|
||||||
|
/** مقدار Title تصویر — فقط برای فیلدهای image */
|
||||||
|
imageTitleValue?: string
|
||||||
onSave: (value: unknown) => Promise<boolean>
|
onSave: (value: unknown) => Promise<boolean>
|
||||||
|
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||||
|
onSaveImageTitle?: (title: string) => Promise<boolean>
|
||||||
registerFlush?: (fn: () => void) => void
|
registerFlush?: (fn: () => void) => void
|
||||||
}) {
|
}) {
|
||||||
const [text, setText] = useState<string>(String(value ?? ''))
|
const [text, setText] = useState<string>(String(value ?? ''))
|
||||||
|
|
||||||
|
// همگامسازی مقدار محلی با دادهای که از بیرون میآید (بارگذاری/بهروزرسانی خوشبینانه)
|
||||||
|
useEffect(() => {
|
||||||
|
setText(String(value ?? ''))
|
||||||
|
}, [value])
|
||||||
|
|
||||||
const commitText = async () => {
|
const commitText = async () => {
|
||||||
const next = field.type === 'number' ? Number(text) || 0 : text
|
const next = field.type === 'number' ? Number(text) || 0 : text
|
||||||
if (String(value ?? '') === String(next)) return
|
if (String(value ?? '') === String(next)) return
|
||||||
@@ -177,7 +221,61 @@ function FieldControl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === 'image') {
|
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') {
|
if (field.type === 'wysiwyg') {
|
||||||
@@ -193,7 +291,11 @@ function FieldControl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === 'list') {
|
if (field.type === 'list') {
|
||||||
return <ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را میبیند' : undefined} />
|
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') {
|
if (field.type === 'textarea') {
|
||||||
@@ -230,6 +332,34 @@ function FieldControl({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** فیلد هشتگها — react-select قابل ساخت (Creatable) با ذخیره فوری */
|
||||||
|
function TagsField({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
field: FieldConfig
|
||||||
|
value: unknown
|
||||||
|
onSave: (value: unknown) => Promise<boolean>
|
||||||
|
}) {
|
||||||
|
const [tags, setTags] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||||
|
|
||||||
|
const update = (next: string[]) => {
|
||||||
|
setTags(next)
|
||||||
|
void onSave(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||||
|
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||||
|
<div className="mt-2">
|
||||||
|
<LuxeTagsSelect value={tags} onChange={update} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
||||||
function ListField({
|
function ListField({
|
||||||
field,
|
field,
|
||||||
@@ -309,10 +439,9 @@ function WysiwygField({
|
|||||||
registerFlush?: (fn: () => void) => void
|
registerFlush?: (fn: () => void) => void
|
||||||
hint?: string
|
hint?: string
|
||||||
}) {
|
}) {
|
||||||
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
|
const [doc, setDoc] = useState<Record<string, unknown>>(
|
||||||
value && typeof value === 'object' && !Array.isArray(value)
|
// بدنه ذخیرهشده (سند جدید، رشته JSON یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||||
? (value as Record<string, unknown>)
|
() => toArticleBodyDoc(value),
|
||||||
: { type: 'doc', content: [{ type: 'paragraph' }] },
|
|
||||||
)
|
)
|
||||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const pending = useRef<Record<string, unknown> | null>(null)
|
const pending = useRef<Record<string, unknown> | null>(null)
|
||||||
|
|||||||
@@ -83,15 +83,40 @@ export const adminApi = {
|
|||||||
reorder: <T = unknown>(resource: string, ids: number[]) =>
|
reorder: <T = unknown>(resource: string, ids: number[]) =>
|
||||||
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
|
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()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
|
if (name && name.trim()) fd.append('name', name.trim())
|
||||||
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
|
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) =>
|
changePassword: (currentPassword: string, newPassword: string) =>
|
||||||
authFetch<{ ok: boolean }>('/api/auth/change-password', {
|
authFetch<{ ok: boolean }>('/api/auth/change-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
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,40 +1,60 @@
|
|||||||
import type { SiteData } from '../api'
|
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 = {
|
export type FieldConfig = {
|
||||||
name: string
|
name: string
|
||||||
label: string
|
label: string
|
||||||
type: FieldType
|
type: FieldType
|
||||||
hint?: string
|
hint?: string
|
||||||
|
/** فقط برای type === 'select' — گزینههای ثابت react-select */
|
||||||
|
options?: string[]
|
||||||
|
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
|
||||||
|
creatable?: boolean
|
||||||
|
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
||||||
|
ui?: 'lines' | 'tags'
|
||||||
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمیشوند */
|
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمیشوند */
|
||||||
shared?: boolean
|
shared?: boolean
|
||||||
|
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
|
||||||
|
altFallbackSuffix?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EntityConfig = {
|
export type EntityConfig = {
|
||||||
/** مسیر منبع در پنل مدیریت، مثل services یا why-us */
|
|
||||||
resource: string
|
resource: string
|
||||||
/** کلید همان منبع در پاسخ GET /api/site */
|
|
||||||
dataKey: string
|
dataKey: string
|
||||||
title: string
|
title: string
|
||||||
/** مجموعه یا رکورد یگانه */
|
|
||||||
kind: 'collection' | 'singleton'
|
kind: 'collection' | 'singleton'
|
||||||
/** فیلدی که عنوان آیتم را نشان میدهد */
|
|
||||||
titleKey: string
|
titleKey: string
|
||||||
fields: FieldConfig[]
|
fields: FieldConfig[]
|
||||||
/** مقادیر پیشفرض برای «افزودن» */
|
|
||||||
defaults: Record<string, unknown>
|
defaults: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
const F = (name: string, label: string, type: FieldType = 'text', hint?: string, shared = false): FieldConfig => ({
|
const F = (
|
||||||
|
name: string,
|
||||||
|
label: string,
|
||||||
|
type: FieldType = 'text',
|
||||||
|
hint?: string,
|
||||||
|
sharedOrExtra?: boolean | Partial<FieldConfig>,
|
||||||
|
): FieldConfig => ({
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
type,
|
type,
|
||||||
hint,
|
hint,
|
||||||
shared,
|
...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null
|
||||||
|
? sharedOrExtra
|
||||||
|
: { shared: !!sharedOrExtra }),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** پیکربندی همه منابع قابل ویرایش — هم مجموعهها و هم رکوردهای یگانه */
|
/** دستهبندیهای استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده میشوند */
|
||||||
|
export const ARTICLE_CATEGORIES = [
|
||||||
|
'معماری و ساخت',
|
||||||
|
'سرمایهگذاری ملکی',
|
||||||
|
'طراحی داخلی',
|
||||||
|
'اخبار و اطلاعیهها',
|
||||||
|
'یادداشت مدیریت',
|
||||||
|
]
|
||||||
|
|
||||||
export const ENTITIES: Record<string, EntityConfig> = {
|
export const ENTITIES: Record<string, EntityConfig> = {
|
||||||
services: {
|
services: {
|
||||||
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
||||||
@@ -60,14 +80,32 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
fields: [
|
fields: [
|
||||||
F('title', 'عنوان'),
|
F('title', 'عنوان'),
|
||||||
F('body', 'متن مقاله', 'wysiwyg'),
|
F('body', 'متن مقاله', 'wysiwyg'),
|
||||||
F('excerpt', 'چکیده', 'textarea'),
|
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
|
||||||
F('image', 'تصویر شاخص', 'image', undefined, true),
|
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود', true),
|
||||||
F('category', 'دسته'),
|
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
|
||||||
F('date', 'تاریخ'),
|
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
|
||||||
F('read', 'زمان مطالعه'),
|
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),
|
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: {
|
testimonials: {
|
||||||
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
|
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
|
||||||
@@ -135,7 +173,6 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
|
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
|
||||||
defaults: { label: 'آیتم جدید', href: '/' },
|
defaults: { label: 'آیتم جدید', href: '/' },
|
||||||
},
|
},
|
||||||
|
|
||||||
seo_meta: {
|
seo_meta: {
|
||||||
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||||
titleKey: 'page_key',
|
titleKey: 'page_key',
|
||||||
@@ -146,8 +183,6 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
],
|
],
|
||||||
defaults: { page_key: '', title: '', description: '' },
|
defaults: { page_key: '', title: '', description: '' },
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- رکوردهای یگانه ----------
|
|
||||||
brand: {
|
brand: {
|
||||||
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||||
titleKey: 'name',
|
titleKey: 'name',
|
||||||
|
|||||||
@@ -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,7 +2,9 @@ import React from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||||
import type { Article } from '../data/site'
|
import type { Article } from '../data/site'
|
||||||
|
import { formatJalali } from '../utils/date'
|
||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
|
import { toPersianDigits } from '../utils/digits'
|
||||||
|
|
||||||
export function ArticleCard({ article }: { article: Article }) {
|
export function ArticleCard({ article }: { article: Article }) {
|
||||||
const { t, href } = useLang()
|
const { t, href } = useLang()
|
||||||
@@ -35,10 +37,10 @@ export function ArticleCard({ article }: { article: Article }) {
|
|||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
<span>{article.date}</span>
|
<span>{formatJalali(article.date)}</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||||
{article.read}
|
{toPersianDigits(article.read)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,12 +9,13 @@ import { useFieldSaver } from '../admin/AdminContext'
|
|||||||
import { InlineEdit } from './admin/InlineEdit'
|
import { InlineEdit } from './admin/InlineEdit'
|
||||||
import { SingletonPill } from '../admin/Editable'
|
import { SingletonPill } from '../admin/Editable'
|
||||||
import { API_URL } from '../config'
|
import { API_URL } from '../config'
|
||||||
|
import { track } from '../analytics'
|
||||||
|
|
||||||
export function ContactSection() {
|
export function ContactSection() {
|
||||||
const { brand, contact_section: content } = useSite()
|
const { brand, contact_section: content } = useSite()
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
const [form, setForm] = useState({ name: '', phone: '', note: '', website: '' })
|
||||||
const [sent, setSent] = useState(false)
|
const [sent, setSent] = useState(false)
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ export function ContactSection() {
|
|||||||
const res = await fetch(`${API_URL}/api/contact`, {
|
const res = await fetch(`${API_URL}/api/contact`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (!res.ok) {
|
||||||
@@ -90,6 +91,7 @@ export function ContactSection() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
track('contact-submit')
|
||||||
setSent(true)
|
setSent(true)
|
||||||
} catch {
|
} catch {
|
||||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||||
@@ -152,6 +154,7 @@ export function ContactSection() {
|
|||||||
<a
|
<a
|
||||||
href={brand.phone_href}
|
href={brand.phone_href}
|
||||||
dir="ltr"
|
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"
|
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)} />
|
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
|
||||||
@@ -200,7 +203,7 @@ export function ContactSection() {
|
|||||||
className="mt-8 font-light"
|
className="mt-8 font-light"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSent(false)
|
setSent(false)
|
||||||
setForm({ name: '', phone: '', note: '' })
|
setForm({ name: '', phone: '', note: '', website: '' })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||||
@@ -208,6 +211,17 @@ export function ContactSection() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
||||||
|
{/* 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>
|
<div>
|
||||||
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
|
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
|
||||||
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
|
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { PhoneIcon } from 'lucide-react';
|
import { PhoneIcon } from 'lucide-react';
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
|
import { track } from '../analytics';
|
||||||
|
|
||||||
export function FloatingContact() {
|
export function FloatingContact() {
|
||||||
const { brand } = useSite();
|
const { brand } = useSite();
|
||||||
@@ -11,6 +12,7 @@ export function FloatingContact() {
|
|||||||
<a
|
<a
|
||||||
href={brand.phone_href}
|
href={brand.phone_href}
|
||||||
aria-label={t('callBrand')}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{/* پالس هاله نوری در پسزمینه مانند رادار */}
|
{/* پالس هاله نوری در پسزمینه مانند رادار */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { LanguagesIcon } from 'lucide-react'
|
import { LanguagesIcon } from 'lucide-react'
|
||||||
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
||||||
|
import { track } from '../analytics'
|
||||||
|
|
||||||
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
||||||
export function LangToggle({ className = '' }: { className?: string }) {
|
export function LangToggle({ className = '' }: { className?: string }) {
|
||||||
@@ -10,7 +11,9 @@ export function LangToggle({ className = '' }: { className?: string }) {
|
|||||||
const target = counterpartPath(pathname, lang)
|
const target = counterpartPath(pathname, lang)
|
||||||
|
|
||||||
const switchLang = () => {
|
const switchLang = () => {
|
||||||
writeLangCookie(lang === 'fa' ? 'en' : 'fa')
|
const next = lang === 'fa' ? 'en' : 'fa'
|
||||||
|
track('lang-switch', { to: next })
|
||||||
|
writeLangCookie(next)
|
||||||
// پرش خودکار بازدید بعدی با انتخاب دستی همینجا گره نمیخورد
|
// پرش خودکار بازدید بعدی با انتخاب دستی همینجا گره نمیخورد
|
||||||
sessionStorage.setItem('langAutoRedirect', '1')
|
sessionStorage.setItem('langAutoRedirect', '1')
|
||||||
navigate(target)
|
navigate(target)
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { MouseEvent } from 'react'
|
||||||
|
import { ListIcon } from 'lucide-react'
|
||||||
|
import { slugifyFa, uniqueSlug } from '../utils/slug'
|
||||||
|
|
||||||
|
export type TocItem = { id: string; text: string; level: 2 | 3 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* متن یک تیتر از HTML — همه گرههای تودرتو پیمایش میشوند و <br> به فاصله تبدیل
|
||||||
|
* میشود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
|
||||||
|
*/
|
||||||
|
function headingText(element: Element): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
element.childNodes.forEach((node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
|
||||||
|
else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const child = node as Element
|
||||||
|
parts.push(child.tagName === 'BR' ? ' ' : headingText(child))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return parts.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* استخراج فهرست مطالب از HTML ویرایشگر — همه تیترهای H2 و H3 در هر عمقی از سند
|
||||||
|
* (حتی داخل نقلقول) شناسه لنگر میگیرند؛ متن فارسی در شناسه حفظ میشود و تیترهای
|
||||||
|
* تکراری پسوند عددی میگیرند. HTML برگشتی همان است که باید رندر شود.
|
||||||
|
*/
|
||||||
|
export function extractToc(
|
||||||
|
html: string,
|
||||||
|
transform?: (root: HTMLElement) => void,
|
||||||
|
): { items: TocItem[]; html: string } {
|
||||||
|
if (!html || typeof window === 'undefined') return { items: [], html }
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
const used = new Map<string, number>()
|
||||||
|
const items: TocItem[] = []
|
||||||
|
|
||||||
|
doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
|
||||||
|
const text = headingText(element).replace(/\s+/g, ' ').trim()
|
||||||
|
if (!text) return
|
||||||
|
// تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی میگیرد تا لنگرش بینام نماند
|
||||||
|
const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
|
||||||
|
element.setAttribute('id', id)
|
||||||
|
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// تبدیل اختیاری (بستهبندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا میشود
|
||||||
|
transform?.(doc.body)
|
||||||
|
|
||||||
|
return { items, html: doc.body.innerHTML }
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableOfContentsProps = {
|
||||||
|
items: TocItem[]
|
||||||
|
title?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فهرست مطالب کلیکپذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
|
||||||
|
* همه اندازهها و ترازبندیها منطقیاند (start/end) تا در چیدمان راستبهچپ و
|
||||||
|
* چپبهراست — بر اساس جهت مقاله — درست بنشیند.
|
||||||
|
*/
|
||||||
|
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
|
||||||
|
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveId(items[0]?.id ?? '')
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!items.length || typeof IntersectionObserver === 'undefined') return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((entry) => entry.isIntersecting)
|
||||||
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||||
|
const first = visible[0]?.target.id
|
||||||
|
if (first) setActiveId(first)
|
||||||
|
},
|
||||||
|
{ rootMargin: '-15% 0px -70% 0px', threshold: [0, 1] },
|
||||||
|
)
|
||||||
|
items.forEach((item) => {
|
||||||
|
const el = document.getElementById(item.id)
|
||||||
|
if (el) observer.observe(el)
|
||||||
|
})
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
const go = (event: MouseEvent<HTMLButtonElement>, id: string) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const el = document.getElementById(id)
|
||||||
|
if (!el) return
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
window.history.replaceState(null, '', `#${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="فهرست مطالب"
|
||||||
|
className={`rounded-2xl border border-white/10 bg-char/60 p-6 backdrop-blur-xl shadow-[0_20px_50px_rgba(0,0,0,0.35),inset_0_1px_1px_rgba(255,255,255,0.08)] ${className}`}
|
||||||
|
>
|
||||||
|
<p className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||||
|
<ListIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
|
||||||
|
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود میشود تا بریده نشود */}
|
||||||
|
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive = item.id === activeId
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => go(event, item.id)}
|
||||||
|
aria-current={isActive ? 'location' : undefined}
|
||||||
|
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
|
||||||
|
item.level === 3 ? 'ps-4 text-[13px]' : ''
|
||||||
|
} ${
|
||||||
|
isActive
|
||||||
|
? 'font-normal text-copper'
|
||||||
|
: 'text-ivory/60 hover:text-copper'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,45 +1,141 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { EditorContent, useEditor } from '@tiptap/react'
|
import { EditorContent, useEditor } from '@tiptap/react'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Image from '@tiptap/extension-image'
|
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
|
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||||
import {
|
import {
|
||||||
|
AlignLeftIcon,
|
||||||
|
AlignRightIcon,
|
||||||
BoldIcon,
|
BoldIcon,
|
||||||
ItalicIcon,
|
Heading1Icon,
|
||||||
Heading2Icon,
|
Heading2Icon,
|
||||||
Heading3Icon,
|
Heading3Icon,
|
||||||
ImagePlusIcon,
|
ImagePlusIcon,
|
||||||
|
ItalicIcon,
|
||||||
Link2Icon,
|
Link2Icon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
ListOrderedIcon,
|
ListOrderedIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
PilcrowIcon,
|
||||||
QuoteIcon,
|
QuoteIcon,
|
||||||
Redo2Icon,
|
Redo2Icon,
|
||||||
StrikethroughIcon,
|
StrikethroughIcon,
|
||||||
UnderlineIcon,
|
UnderlineIcon,
|
||||||
Undo2Icon,
|
Undo2Icon,
|
||||||
Loader2Icon,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Swal from 'sweetalert2'
|
|
||||||
import { useAdmin } from '../../admin/AdminContext'
|
import { useAdmin } from '../../admin/AdminContext'
|
||||||
import { adminApi } from '../../admin/adminClient'
|
import { adminApi } from '../../admin/adminClient'
|
||||||
|
import { DEFAULT_IMAGE_ALT } from '../../data/constants'
|
||||||
|
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
|
||||||
|
import { ArticleImage } from './ArticleImage'
|
||||||
|
import { BidiTypography, bidiPluginKey } from './BidiTypography'
|
||||||
|
import { EnglishText } from './EnglishText'
|
||||||
|
import { LinkModal } from './LinkModal'
|
||||||
|
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
|
||||||
|
import type { LinkModalValue } from './LinkModal'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مارکهای قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آنها از نوار ابزار برداشته شده و
|
||||||
|
* فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شدهاند.
|
||||||
|
*/
|
||||||
|
const LegacySmallSizeMark = Mark.create({
|
||||||
|
name: 'smallSize',
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'span[data-small]' }]
|
||||||
|
},
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
/** اکستنشنهای مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
|
/** اکستنشنهای مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
|
||||||
export const ARTICLE_EXTENSIONS = [
|
export const ARTICLE_EXTENSIONS = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
link: false,
|
link: false,
|
||||||
heading: { levels: [2, 3] },
|
heading: { levels: [1, 2, 3] },
|
||||||
}),
|
}),
|
||||||
Image.configure({ allowBase64: false }),
|
ArticleImage.configure({ allowBase64: false }),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||||
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
|
isAllowedUri: (uri: string) => isAllowedHref(uri),
|
||||||
}),
|
}),
|
||||||
|
LegacySmallSizeMark,
|
||||||
|
EnglishText,
|
||||||
|
BidiTypography,
|
||||||
]
|
]
|
||||||
|
|
||||||
type ArticleEditorProps = {
|
type ArticleEditorProps = {
|
||||||
doc: Record<string, unknown>
|
doc: Record<string, unknown>
|
||||||
onChange: (json: Record<string, unknown>) => void
|
onChange: (json: Record<string, unknown>) => void
|
||||||
|
/**
|
||||||
|
* شناسه محتوا — با هر تغییر آن، بدنه ویرایشگر از `doc` جاری دوباره بارگذاری میشود
|
||||||
|
* (مثلاً وقتی مقاله از سرور میرسد). بدون این prop محتوا فقط یکبار مقداردهی میشود.
|
||||||
|
*/
|
||||||
|
contentKey?: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمیگرداند */
|
||||||
|
type EditorInstance = NonNullable<ReturnType<typeof useEditor>>
|
||||||
|
|
||||||
|
type TextDirection = 'rtl' | 'ltr'
|
||||||
|
|
||||||
|
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپشده */
|
||||||
|
type LinkApplyMode = 'selection' | 'insert' | 'range'
|
||||||
|
|
||||||
|
type LinkModalState = {
|
||||||
|
open: boolean
|
||||||
|
editing: boolean
|
||||||
|
mode: LinkApplyMode
|
||||||
|
range: { from: number; to: number } | null
|
||||||
|
initial: LinkModalValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSED_LINK_MODAL: LinkModalState = {
|
||||||
|
open: false,
|
||||||
|
editing: false,
|
||||||
|
mode: 'selection',
|
||||||
|
range: null,
|
||||||
|
initial: { href: '', newTab: true, nofollow: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
||||||
|
const URL_LOOKBACK = 300
|
||||||
|
|
||||||
|
/** آیا src تصویر خارج از میزبانی سایت است (مثل چسباندهشده از Outline)؟ */
|
||||||
|
function isExternalImageSrc(src: string): boolean {
|
||||||
|
if (!src || src.startsWith('data:')) return false
|
||||||
|
if (src.startsWith('/')) return false // مسیر نسبی/داخلی
|
||||||
|
try {
|
||||||
|
return new URL(src).origin !== window.location.origin
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* میزبانی مجدد تصاویر خارجی یک HTML — هر <img> با نشانی خارجی از سرور دانلود و در
|
||||||
|
* Object Storage خود سایت ذخیره و src آن بازنویسی میشود (چسباندن از Outline و مانند آن).
|
||||||
|
* تصویری که درونریزیاش شکست بخورد با src اصلی میماند.
|
||||||
|
*/
|
||||||
|
async function rehostExternalImages(html: string): Promise<string> {
|
||||||
|
const parsed = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
const imgs = Array.from(parsed.querySelectorAll('img[src]'))
|
||||||
|
const externals = imgs.filter((img) => isExternalImageSrc(img.getAttribute('src') ?? ''))
|
||||||
|
if (!externals.length) return html
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
externals.map(async (img) => {
|
||||||
|
const original = img.getAttribute('src') ?? ''
|
||||||
|
try {
|
||||||
|
const { url } = await adminApi.importImage(original)
|
||||||
|
img.setAttribute('src', url)
|
||||||
|
} catch {
|
||||||
|
// نشانی خارجی میماند — شاید خودش کار کند
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return parsed.body.innerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolButton({
|
function ToolButton({
|
||||||
@@ -58,6 +154,7 @@ function ToolButton({
|
|||||||
type="button"
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-pressed={active}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
|
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
|
||||||
@@ -74,13 +171,17 @@ function ToolButton({
|
|||||||
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
|
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ویرایشگر WYSIWYG مقاله (TipTap) — راستبهچپ، با آپلود مستقیم تصویر به MinIO
|
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشندار، مودال لینک سئو-محور،
|
||||||
* (انتخاب فایل، چسباندن یا کشیدن تصویر داخل متن).
|
* کلید راستچین/چپچین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
|
||||||
*/
|
*/
|
||||||
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
|
||||||
const { runSave } = useAdmin()
|
const { runSave } = useAdmin()
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [direction, setDirection] = useState<TextDirection>('rtl')
|
||||||
|
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
||||||
|
// مرحله نامگذاری درج تصویر از نوار ابزار — پیشفرض از نام خود فایل
|
||||||
|
const [pendingImage, setPendingImage] = useState<{ file: File; name: string } | null>(null)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: ARTICLE_EXTENSIONS,
|
extensions: ARTICLE_EXTENSIONS,
|
||||||
@@ -89,16 +190,46 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
|||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
dir: 'rtl',
|
dir: 'rtl',
|
||||||
|
lang: 'fa',
|
||||||
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
||||||
'data-wysiwyg': 'true',
|
'data-wysiwyg': 'true',
|
||||||
},
|
},
|
||||||
handlePaste: (_event_ignored, event) => {
|
handlePaste: (_view, event, _slice) => {
|
||||||
const file = event.clipboardData?.files?.[0]
|
const file = event.clipboardData?.files?.[0]
|
||||||
if (file && file.type.startsWith('image/')) {
|
if (file && file.type.startsWith('image/')) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void uploadAndInsert(file)
|
void uploadAndInsert(file)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// محتوای HTML با تصویر خارجی (Outline و مانند آن) — تصاویر به میزبانی سایت منتقل میشوند
|
||||||
|
const html = event.clipboardData?.getData('text/html') ?? ''
|
||||||
|
if (html.includes('<img')) {
|
||||||
|
const parsed = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
const hasExternal = Array.from(parsed.querySelectorAll('img[src]')).some((img) =>
|
||||||
|
isExternalImageSrc(img.getAttribute('src') ?? ''),
|
||||||
|
)
|
||||||
|
if (hasExternal) {
|
||||||
|
event.preventDefault()
|
||||||
|
void (async () => {
|
||||||
|
const transformed = await rehostExternalImages(html)
|
||||||
|
editor?.chain().focus().insertContent(transformed).run()
|
||||||
|
})()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// چسباندن یک نشانی → مودال لینک بلافاصله باز میشود
|
||||||
|
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
|
||||||
|
if (isUrlLike(text)) {
|
||||||
|
event.preventDefault()
|
||||||
|
setLinkModal({
|
||||||
|
open: true,
|
||||||
|
editing: false,
|
||||||
|
mode: 'insert',
|
||||||
|
range: null,
|
||||||
|
initial: { href: normalizeHref(text) ?? text, newTab: true, nofollow: false },
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
handleDrop: (_view, event, _slice, moved) => {
|
handleDrop: (_view, event, _slice, moved) => {
|
||||||
@@ -109,159 +240,322 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
|||||||
void uploadAndInsert(file)
|
void uploadAndInsert(file)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
const html = event.dataTransfer?.getData('text/html') ?? ''
|
||||||
|
if (html.includes('<img')) {
|
||||||
|
const parsed = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
const hasExternal = Array.from(parsed.querySelectorAll('img[src]')).some((img) =>
|
||||||
|
isExternalImageSrc(img.getAttribute('src') ?? ''),
|
||||||
|
)
|
||||||
|
if (hasExternal) {
|
||||||
|
event.preventDefault()
|
||||||
|
void (async () => {
|
||||||
|
const transformed = await rehostExternalImages(html)
|
||||||
|
editor?.chain().focus().insertContent(transformed).run()
|
||||||
|
})()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
|
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
||||||
|
handleTextInput: (view, from, to, text) => {
|
||||||
|
if (text !== ' ' || from !== to) return false
|
||||||
|
const start = Math.max(0, from - URL_LOOKBACK)
|
||||||
|
const before = view.state.doc.textBetween(start, from, ' ', ' ').trimEnd()
|
||||||
|
const lastToken = before.slice(before.lastIndexOf(' ') + 1)
|
||||||
|
if (!isUrlLike(lastToken)) return false
|
||||||
|
setLinkModal({
|
||||||
|
open: true,
|
||||||
|
editing: false,
|
||||||
|
mode: 'range',
|
||||||
|
range: { from: from - lastToken.length, to: from },
|
||||||
|
initial: { href: normalizeHref(lastToken) ?? lastToken, newTab: true, nofollow: false },
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
// Enter داخل تیتر، بلوک تازه را پاراگراف میکند تا قالب تیتر سرایت نکند
|
||||||
|
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
|
||||||
},
|
},
|
||||||
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
||||||
})
|
})
|
||||||
|
|
||||||
async function uploadAndInsert(file: File) {
|
// جهت متن فقط روی ناحیه ویرایش اعمال میشود؛ نوار ابزار همیشه راستچین میماند
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
editor.view.dom.setAttribute('dir', direction)
|
||||||
|
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
|
||||||
|
// تزئینهای دوزبانه (اندازه ۲ پیکسل کوچکتر متن لاتین) با جهت تازه بازمحاسبه میشوند
|
||||||
|
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, direction))
|
||||||
|
}, [editor, direction])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ترکیب (نمونه ویرایشگر، شناسه محتوا) که آخرین بار بارگذاری شده است.
|
||||||
|
* تغییرهای معمول سند (تایپ کاربر) نباید بدنه را از نو بارگذاری کنند.
|
||||||
|
*/
|
||||||
|
const loadedContentRef = useRef<{ editor: EditorInstance; key: string | number } | null>(null)
|
||||||
|
|
||||||
|
// بارگذاری بدنه با تغییر شناسه محتوا (رسیدن مقاله از سرور) یا ساختهشدن نمونه ویرایشگر
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || contentKey === undefined) return
|
||||||
|
const loaded = loadedContentRef.current
|
||||||
|
if (loaded && loaded.editor === editor && loaded.key === contentKey) return
|
||||||
|
const incoming = doc && Object.keys(doc).length ? doc : null
|
||||||
|
if (!incoming) return
|
||||||
|
loadedContentRef.current = { editor, key: contentKey }
|
||||||
|
// emitUpdate=false تا بارگذاری، ذخیره خودکارِ بیمورد را فعال نکند
|
||||||
|
editor.commands.setContent(incoming, { emitUpdate: false })
|
||||||
|
}, [editor, contentKey, doc])
|
||||||
|
|
||||||
|
async function uploadAndInsert(file: File, name?: string) {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
await runSave(async () => {
|
await runSave(async () => {
|
||||||
const { url } = await adminApi.upload(file)
|
const { url } = await adminApi.upload(file, name)
|
||||||
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run()
|
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
||||||
|
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
|
||||||
|
.run()
|
||||||
})
|
})
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setLink = () => {
|
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
|
||||||
|
const openLinkModalForSelection = () => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
if (editor.isActive('link')) {
|
const attrs = editor.getAttributes('link') as {
|
||||||
editor.chain().focus().unsetLink().run()
|
href?: string | null
|
||||||
return
|
target?: string | null
|
||||||
|
rel?: string | null
|
||||||
}
|
}
|
||||||
void Swal.fire({
|
const editing = Boolean(attrs.href)
|
||||||
title: 'نشانی لینک',
|
setLinkModal({
|
||||||
input: 'text',
|
open: true,
|
||||||
inputValue: 'https://',
|
editing,
|
||||||
background: '#1e1a16',
|
mode: 'selection',
|
||||||
color: '#F5F3EF',
|
range: null,
|
||||||
showCancelButton: true,
|
initial: {
|
||||||
confirmButtonText: 'ثبت',
|
href: attrs.href ?? '',
|
||||||
cancelButtonText: 'انصراف',
|
newTab: editing ? attrs.target === '_blank' : true,
|
||||||
buttonsStyling: false,
|
nofollow: editing ? parseLinkRel(attrs.rel).nofollow : false,
|
||||||
customClass: {
|
|
||||||
popup: 'font-pinar border border-ivory/10',
|
|
||||||
title: 'font-pinar font-bold text-ivory',
|
|
||||||
input: 'font-pinar',
|
|
||||||
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5 mx-2',
|
|
||||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
|
||||||
},
|
},
|
||||||
}).then((result) => {
|
|
||||||
if (!result.isConfirmed || !result.value) return
|
|
||||||
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyLink = ({ href, newTab, nofollow }: LinkModalValue) => {
|
||||||
|
if (!editor) return
|
||||||
|
const linkAttrs = { href, target: newTab ? '_blank' : null, rel: buildLinkRel({ newTab, nofollow }) }
|
||||||
|
const chain = editor.chain().focus()
|
||||||
|
|
||||||
|
if (linkModal.mode === 'insert') {
|
||||||
|
// نشانی چسباندهشده: متن لینک تازه درج میشود
|
||||||
|
chain.insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: linkAttrs }] }).run()
|
||||||
|
} else if (linkModal.mode === 'range' && linkModal.range) {
|
||||||
|
// نشانی تایپشده: همان محدوده به لینک تبدیل میشود
|
||||||
|
chain.setTextSelection(linkModal.range).extendMarkRange('link').setLink(linkAttrs).run()
|
||||||
|
} else {
|
||||||
|
chain.extendMarkRange('link').setLink(linkAttrs).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
setLinkModal((state) => ({ ...state, open: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeLink = () => {
|
||||||
|
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
|
setLinkModal((state) => ({ ...state, open: false }))
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor) return null
|
if (!editor) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-ivory/15 bg-char">
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-ivory/10 px-2 py-1.5">
|
<div className="border border-ivory/15 bg-char" data-dir={direction}>
|
||||||
<ToolButton
|
{/* نوار ابزار: همیشه راستچین و در بالای ویرایشگر ثابت میماند */}
|
||||||
label="بولد"
|
<div
|
||||||
active={editor.isActive('bold')}
|
dir="rtl"
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
className="sticky top-14 z-20 flex flex-wrap items-center gap-1 border-b border-ivory/10 bg-char px-2 py-1.5"
|
||||||
>
|
>
|
||||||
<BoldIcon className="h-4 w-4" />
|
<ToolButton
|
||||||
</ToolButton>
|
label="پاراگراف"
|
||||||
<ToolButton
|
active={editor.isActive('paragraph')}
|
||||||
label="ایتالیک"
|
onClick={() => applyBlockFormat(editor.view, 'paragraph')}
|
||||||
active={editor.isActive('italic')}
|
>
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
<PilcrowIcon className="h-4 w-4" />
|
||||||
>
|
</ToolButton>
|
||||||
<ItalicIcon className="h-4 w-4" />
|
<ToolButton
|
||||||
</ToolButton>
|
label="تیتر ۱"
|
||||||
<ToolButton
|
active={editor.isActive('heading', { level: 1 })}
|
||||||
label="زیرخط"
|
onClick={() => applyBlockFormat(editor.view, 1)}
|
||||||
active={editor.isActive('underline')}
|
>
|
||||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
<Heading1Icon className="h-4 w-4" />
|
||||||
>
|
</ToolButton>
|
||||||
<UnderlineIcon className="h-4 w-4" />
|
<ToolButton
|
||||||
</ToolButton>
|
label="تیتر ۲"
|
||||||
<ToolButton
|
active={editor.isActive('heading', { level: 2 })}
|
||||||
label="خطخورده"
|
onClick={() => applyBlockFormat(editor.view, 2)}
|
||||||
active={editor.isActive('strike')}
|
>
|
||||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
<Heading2Icon className="h-4 w-4" />
|
||||||
>
|
</ToolButton>
|
||||||
<StrikethroughIcon className="h-4 w-4" />
|
<ToolButton
|
||||||
</ToolButton>
|
label="تیتر ۳"
|
||||||
|
active={editor.isActive('heading', { level: 3 })}
|
||||||
|
onClick={() => applyBlockFormat(editor.view, 3)}
|
||||||
|
>
|
||||||
|
<Heading3Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<ToolButton
|
{/* جهت متن — فقط محتوای ویرایشگر را عوض میکند */}
|
||||||
label="تیتر میانی"
|
<ToolButton
|
||||||
active={editor.isActive('heading', { level: 2 })}
|
label="راستچین (فارسی)"
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
active={direction === 'rtl'}
|
||||||
>
|
onClick={() => setDirection('rtl')}
|
||||||
<Heading2Icon className="h-4 w-4" />
|
>
|
||||||
</ToolButton>
|
<AlignRightIcon className="h-4 w-4" />
|
||||||
<ToolButton
|
</ToolButton>
|
||||||
label="تیتر کوچک"
|
<ToolButton
|
||||||
active={editor.isActive('heading', { level: 3 })}
|
label="چپچین (انگلیسی)"
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
active={direction === 'ltr'}
|
||||||
>
|
onClick={() => setDirection('ltr')}
|
||||||
<Heading3Icon className="h-4 w-4" />
|
>
|
||||||
</ToolButton>
|
<AlignLeftIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<ToolButton
|
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||||
label="لیست نقطهای"
|
<BoldIcon className="h-4 w-4" />
|
||||||
active={editor.isActive('bulletList')}
|
</ToolButton>
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||||
>
|
<ItalicIcon className="h-4 w-4" />
|
||||||
<ListIcon className="h-4 w-4" />
|
</ToolButton>
|
||||||
</ToolButton>
|
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
||||||
<ToolButton
|
<UnderlineIcon className="h-4 w-4" />
|
||||||
label="لیست شمارهای"
|
</ToolButton>
|
||||||
active={editor.isActive('orderedList')}
|
<ToolButton label="خطخورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
<StrikethroughIcon className="h-4 w-4" />
|
||||||
>
|
</ToolButton>
|
||||||
<ListOrderedIcon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
<ToolButton
|
|
||||||
label="نقلقول"
|
|
||||||
active={editor.isActive('blockquote')}
|
|
||||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
||||||
>
|
|
||||||
<QuoteIcon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<ToolButton label="لینک" active={editor.isActive('link')} onClick={setLink}>
|
<ToolButton label="لیست نقطهای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||||
<Link2Icon className="h-4 w-4" />
|
<ListIcon className="h-4 w-4" />
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
<ToolButton label="لیست شمارهای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||||
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
<ListOrderedIcon className="h-4 w-4" />
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
|
<ToolButton label="نقلقول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||||
|
<QuoteIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
<ToolButton label="افزودن لینک" active={editor.isActive('link')} onClick={openLinkModalForSelection}>
|
||||||
<Undo2Icon className="h-4 w-4" />
|
<Link2Icon className="h-4 w-4" />
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||||||
<Redo2Icon className="h-4 w-4" />
|
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
||||||
|
<Undo2Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
||||||
|
<Redo2Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
data-editor-file-input
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
setPendingImage({ file, name: file.name.replace(/\.[^.]+$/, '') })
|
||||||
|
}
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditorContent editor={editor} />
|
{/* مرحله نامگذاری تصویر — نام خوانای ذخیرهسازی با یک کلیک تایید میشود */}
|
||||||
|
{pendingImage && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="نام تصویر"
|
||||||
|
onClick={() => setPendingImage(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="w-full max-w-sm border border-ivory/15 bg-ink p-6 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-bold text-copper">درج تصویر</h2>
|
||||||
|
<label htmlFor="image-name-input" className="mt-4 block text-xs font-light text-ivory/60">
|
||||||
|
نام فایل تصویر (برای نشانی ذخیرهسازی — اختیاری)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="image-name-input"
|
||||||
|
type="text"
|
||||||
|
dir="auto"
|
||||||
|
autoFocus
|
||||||
|
value={pendingImage.name}
|
||||||
|
onChange={(e) => setPendingImage({ ...pendingImage, name: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const { file, name } = pendingImage
|
||||||
|
setPendingImage(null)
|
||||||
|
void uploadAndInsert(file, name)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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 className="mt-5 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingImage(null)}
|
||||||
|
className="border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const { file, name } = pendingImage
|
||||||
|
setPendingImage(null)
|
||||||
|
void uploadAndInsert(file, name)
|
||||||
|
}}
|
||||||
|
className="bg-copper px-6 py-2 text-xs font-bold text-char transition-transform hover:scale-105"
|
||||||
|
>
|
||||||
|
درج تصویر
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<input
|
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
||||||
ref={fileRef}
|
{linkModal.open && (
|
||||||
type="file"
|
<LinkModal
|
||||||
data-editor-file-input
|
initial={linkModal.initial}
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
editing={linkModal.editing}
|
||||||
className="hidden"
|
onApply={applyLink}
|
||||||
onChange={(e) => {
|
onRemove={removeLink}
|
||||||
const file = e.target.files?.[0]
|
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
|
||||||
if (file) void uploadAndInsert(file)
|
/>
|
||||||
e.target.value = ''
|
)}
|
||||||
}}
|
</>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import { mergeAttributes } from '@tiptap/core'
|
||||||
|
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
|
import { ImageNodeView } from './ImageNodeView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تصویر مقاله با کپشن.
|
||||||
|
* خروجی HTML در سایت: <figure><img><figcaption>کپشن</figcaption></figure>
|
||||||
|
* و در ویرایشگر با NodeView سفارشی (ورودی کپشن + دکمه حذف) نمایش داده میشود.
|
||||||
|
*/
|
||||||
|
export const ArticleImage = Image.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
// ویژگیهای استاندارد تصویر (src/alt/title/width/height) از افزونه پایه
|
||||||
|
...this.parent?.(),
|
||||||
|
caption: {
|
||||||
|
default: '',
|
||||||
|
parseHTML: (element) =>
|
||||||
|
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'figure',
|
||||||
|
priority: 60,
|
||||||
|
getAttrs: (element) => {
|
||||||
|
if (!(element instanceof HTMLElement)) return false
|
||||||
|
const img = element.querySelector('img')
|
||||||
|
if (!img) return false
|
||||||
|
return {
|
||||||
|
src: img.getAttribute('src'),
|
||||||
|
alt: img.getAttribute('alt'),
|
||||||
|
title: img.getAttribute('title'),
|
||||||
|
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// تصاویر بدون figure (محتوای قدیمی یا چسباندهشده)
|
||||||
|
{ tag: 'img[src]:not([src^="data:"])' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
const { caption, ...imageAttributes } = HTMLAttributes
|
||||||
|
const image: [string, Record<string, unknown>] = [
|
||||||
|
'img',
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, imageAttributes),
|
||||||
|
]
|
||||||
|
|
||||||
|
// کپشن خالی → عنصر figcaption اصلاً رندر نمیشود (فقط تصویر میماند)
|
||||||
|
return caption
|
||||||
|
? [
|
||||||
|
'figure',
|
||||||
|
{ class: 'article-figure' },
|
||||||
|
image,
|
||||||
|
['figcaption', { class: 'article-figure__caption' }, caption],
|
||||||
|
]
|
||||||
|
: ['figure', { class: 'article-figure' }, image]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(ImageNodeView)
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
import type { EditorState } from '@tiptap/pm/state'
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
|
import { LATIN_CLASS, RTL_CLASS, findScriptRuns } from '../../utils/bidiText'
|
||||||
|
|
||||||
|
/** نام مارکهای قدیمی که خودشان اندازه/جهت متن را تعیین میکنند */
|
||||||
|
const LEGACY_MARKS = new Set(['englishText', 'smallSize'])
|
||||||
|
|
||||||
|
type BidiState = { dir: 'rtl' | 'ltr' }
|
||||||
|
|
||||||
|
/** کلید پلاگین — جهت متن با متای همین کلید بهروز میشود */
|
||||||
|
export const bidiPluginKey = new PluginKey<BidiState>('bidiTypography')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تزئین دوزبانه متن ویرایشگر — اجرای لاتین (و در متن انگلیسی، اجرای فارسی) با یک
|
||||||
|
* span جهتدار نشانهگذاری میشود تا ویرایشگر هم دقیقاً همان اندازه «۲ پیکسل کوچکتر»
|
||||||
|
* خروجی سایت را نشان دهد. تزئینها سند را تغییر نمیدهند و ذخیره نمیشوند.
|
||||||
|
*/
|
||||||
|
export const BidiTypography = Extension.create({
|
||||||
|
name: 'bidiTypography',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin<BidiState>({
|
||||||
|
key: bidiPluginKey,
|
||||||
|
state: {
|
||||||
|
init: () => ({ dir: 'rtl' }),
|
||||||
|
apply: (tr, value) => {
|
||||||
|
const dir = tr.getMeta(bidiPluginKey) as BidiState['dir'] | undefined
|
||||||
|
return dir && dir !== value.dir ? { dir } : value
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations: (state) =>
|
||||||
|
buildDecorations(state, bidiPluginKey.getState(state)?.dir ?? 'rtl'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildDecorations(state: EditorState, dir: 'rtl' | 'ltr'): DecorationSet {
|
||||||
|
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
|
||||||
|
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
|
||||||
|
const decorations: Decoration[] = []
|
||||||
|
const { doc, schema } = state
|
||||||
|
|
||||||
|
doc.descendants((node, pos, parent) => {
|
||||||
|
if (node.type.name === 'codeBlock' || parent?.type.name === 'codeBlock') return false
|
||||||
|
if (!node.isText || !node.text) return
|
||||||
|
if (node.marks.some((mark) => mark.type === schema.marks.code || LEGACY_MARKS.has(mark.type.name))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const run of findScriptRuns(node.text, dir)) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.inline(pos + run.from, pos + run.to, { class: className, dir: runDir }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||||
|
|
||||||
|
/** نام مارک متن انگلیسی — ویرایشگر و رندر استاتیک مقاله از همین نام استفاده میکنند */
|
||||||
|
export const ENGLISH_TEXT_MARK = 'englishText'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مارک «متن انگلیسی» — متن انتخابشده را داخل
|
||||||
|
* <span dir="ltr" class="english-text"> میپیچد تا ۱۲ پیکسل و چپبهراست
|
||||||
|
* نمایش داده شود، بدون اینکه جهت متن فارسی اطرافش بههم بخورد.
|
||||||
|
*/
|
||||||
|
export const EnglishText = Mark.create({
|
||||||
|
name: ENGLISH_TEXT_MARK,
|
||||||
|
inclusive: false,
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'span.english-text' }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
mergeAttributes(HTMLAttributes, { dir: 'ltr', class: 'english-text' }),
|
||||||
|
0,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,22 +1,73 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useId, useRef, useState } from 'react'
|
||||||
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||||
|
import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
|
||||||
import { adminApi } from '../../admin/adminClient'
|
import { adminApi } from '../../admin/adminClient'
|
||||||
import { useAdmin } from '../../admin/AdminContext'
|
import { useAdmin } from '../../admin/AdminContext'
|
||||||
|
import { useSmartTextField } from './useSmartTextField'
|
||||||
|
|
||||||
type ImageFieldProps = {
|
type ImageFieldProps = {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
onSave: (url: string) => Promise<boolean>
|
onSave: (url: string) => Promise<boolean>
|
||||||
|
/** متن جایگزین (Alt) */
|
||||||
|
alt?: string
|
||||||
|
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||||
|
/** عنوان تصویر (Title) */
|
||||||
|
title?: string
|
||||||
|
onSaveTitle?: (title: string) => Promise<boolean>
|
||||||
|
/**
|
||||||
|
* پیشنهاد Alt وقتی کاربر چیزی ننوشته — مثل «{عنوان مقاله} - تصویر شاخص».
|
||||||
|
* هرگز از URL یا نام فایل ساخته نمیشود.
|
||||||
|
*/
|
||||||
|
altFallback?: string
|
||||||
|
/** پیشنهاد عنوان تصویر — پیشفرض «Photo 1» */
|
||||||
|
titleFallback?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const META_FIELD_CLASS =
|
||||||
|
'mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش استفاده میشود.
|
* انتخاب و آپلود تصویر به MinIO با پیشنمایش بزرگ.
|
||||||
* پس از آپلود موفق، URL نسبی /media/... ذخیره میشود.
|
* ترتیب فیلدها: ۱) عنوان تصویر (پیشفرض «Photo 1») ۲) متن جایگزین (پیشفرض «{عنوان} - تصویر شاخص»).
|
||||||
|
* هر دو «دستی یا پیشفرض» هستند و Alt هرگز از URL ساخته نمیشود.
|
||||||
|
* پیشفرض خودکار با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمیگردد.
|
||||||
*/
|
*/
|
||||||
export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
export function ImageField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
alt,
|
||||||
|
onSaveAlt,
|
||||||
|
title,
|
||||||
|
onSaveTitle,
|
||||||
|
altFallback = '',
|
||||||
|
titleFallback = '',
|
||||||
|
}: ImageFieldProps) {
|
||||||
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const titleId = useId()
|
||||||
|
const altId = useId()
|
||||||
|
const nameId = useId()
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
|
/** نام دلخواه فایل برای ذخیرهسازی خوانا — پیشفرض از نام فایل انتخابی ساخته میشود */
|
||||||
|
const [fileBaseName, setFileBaseName] = useState('')
|
||||||
|
|
||||||
|
const showTitle = title !== undefined || !!onSaveTitle
|
||||||
|
const showAlt = alt !== undefined || !!onSaveAlt
|
||||||
|
|
||||||
|
const titleField = useSmartTextField({
|
||||||
|
value: title ?? '',
|
||||||
|
fallback: titleFallback,
|
||||||
|
hardFallback: DEFAULT_IMAGE_TITLE,
|
||||||
|
onCommit: onSaveTitle,
|
||||||
|
})
|
||||||
|
const altField = useSmartTextField({
|
||||||
|
value: alt ?? '',
|
||||||
|
fallback: altFallback,
|
||||||
|
hardFallback: label,
|
||||||
|
onCommit: onSaveAlt,
|
||||||
|
})
|
||||||
|
|
||||||
if (!isAuthenticated || !isEditMode) return null
|
if (!isAuthenticated || !isEditMode) return null
|
||||||
|
|
||||||
@@ -25,10 +76,16 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
|||||||
setUploading(true)
|
setUploading(true)
|
||||||
try {
|
try {
|
||||||
await runSave(async () => {
|
await runSave(async () => {
|
||||||
const { url } = await adminApi.upload(file)
|
// نام ذخیرهسازی: نام تایپشده کاربر، وگرنه نام خود فایل بدون پسوند
|
||||||
|
const name = fileBaseName.trim() || file.name.replace(/\.[^.]+$/, '')
|
||||||
|
const { url } = await adminApi.upload(file, name)
|
||||||
const ok = await onSave(url)
|
const ok = await onSave(url)
|
||||||
if (!ok) throw new Error('ذخیره نشد')
|
if (!ok) throw new Error('ذخیره نشد')
|
||||||
|
// پیشفرضهای Alt/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
||||||
|
if (showTitle) await titleField.commit()
|
||||||
|
if (showAlt) await altField.commit()
|
||||||
})
|
})
|
||||||
|
setFileBaseName('')
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
@@ -37,43 +94,108 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||||
<div className="mt-2 flex items-center gap-3">
|
|
||||||
|
{/* پیشنمایش بزرگ تصویر */}
|
||||||
|
<div className="mt-2 overflow-hidden border border-ivory/15 bg-char">
|
||||||
{value ? (
|
{value ? (
|
||||||
<img
|
<img
|
||||||
src={value}
|
src={value}
|
||||||
alt={label}
|
alt={altField.display || label}
|
||||||
className="h-16 w-24 border border-ivory/15 object-cover"
|
title={titleField.display || undefined}
|
||||||
|
className="h-[27rem] w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
|
<div className="flex h-[27rem] w-full flex-col items-center justify-center gap-2 border border-dashed border-ivory/20 text-ivory/30">
|
||||||
—
|
<ImageIcon className="h-7 w-7" aria-hidden="true" />
|
||||||
|
<span className="text-[11px] font-light">تصویری انتخاب نشده است</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||||
onClick={() => inputRef.current?.click()}
|
<button
|
||||||
disabled={uploading}
|
type="button"
|
||||||
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
onClick={() => inputRef.current?.click()}
|
||||||
>
|
disabled={uploading}
|
||||||
{uploading ? (
|
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
||||||
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
>
|
||||||
) : (
|
{uploading ? (
|
||||||
<ImagePlusIcon className="h-3.5 w-3.5" />
|
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||||
)}
|
) : (
|
||||||
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
|
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||||
</button>
|
)}
|
||||||
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
|
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* نام ذخیرهسازی فایل — خالی یعنی از نام خود فایل استفاده شود */}
|
||||||
|
<div className="w-56">
|
||||||
|
<label htmlFor={nameId} className="text-[11px] font-light text-ivory/50">
|
||||||
|
نام فایل تصویر (اختیاری)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={nameId}
|
||||||
|
type="text"
|
||||||
|
dir="auto"
|
||||||
|
value={fileBaseName}
|
||||||
|
onChange={(event) => setFileBaseName(event.target.value)}
|
||||||
|
placeholder="مثلاً: elahieh-tower"
|
||||||
|
className={META_FIELD_CLASS.replace('mt-1.5 ', '')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(showTitle || showAlt) && (
|
||||||
|
<div className="mt-4 grid max-w-md gap-4 border-t border-ivory/10 pt-4 sm:grid-cols-2">
|
||||||
|
{showTitle && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor={titleId} className="text-[11px] font-light text-ivory/50">
|
||||||
|
عنوان تصویر (Title)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={titleId}
|
||||||
|
type="text"
|
||||||
|
dir="auto"
|
||||||
|
value={titleField.display}
|
||||||
|
onChange={(event) => titleField.onChange(event.target.value)}
|
||||||
|
onFocus={titleField.onFocus}
|
||||||
|
onBlur={titleField.onBlur}
|
||||||
|
placeholder={titleFallback.trim() || DEFAULT_IMAGE_TITLE}
|
||||||
|
className={META_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAlt && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor={altId} className="text-[11px] font-light text-ivory/50">
|
||||||
|
متن جایگزین (Alt)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={altId}
|
||||||
|
type="text"
|
||||||
|
dir="auto"
|
||||||
|
value={altField.display}
|
||||||
|
onChange={(event) => altField.onChange(event.target.value)}
|
||||||
|
onFocus={altField.onFocus}
|
||||||
|
onBlur={altField.onBlur}
|
||||||
|
placeholder={
|
||||||
|
altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحهخوانها'
|
||||||
|
}
|
||||||
|
className={META_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(event) => {
|
||||||
void pick(e.target.files?.[0])
|
void pick(event.target.files?.[0])
|
||||||
e.target.value = ''
|
event.target.value = ''
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { NodeViewWrapper } from '@tiptap/react'
|
||||||
|
import type { NodeViewProps } from '@tiptap/react'
|
||||||
|
import { CloudUploadIcon, PlusIcon, XIcon } from 'lucide-react'
|
||||||
|
import { adminApi } from '../../admin/adminClient'
|
||||||
|
import { useAdmin } from '../../admin/AdminContext'
|
||||||
|
|
||||||
|
type ImageAttrs = {
|
||||||
|
src: string
|
||||||
|
alt: string | null
|
||||||
|
title: string | null
|
||||||
|
caption: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const DELETE_BUTTON_CLASS =
|
||||||
|
'flex h-7 w-7 shrink-0 items-center justify-center border border-red-400/50 bg-ink/85 text-red-300 backdrop-blur-sm transition-colors duration-150 hover:bg-red-500/20'
|
||||||
|
|
||||||
|
const INLINE_FIELD_CLASS =
|
||||||
|
'w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||||
|
|
||||||
|
const ADD_CAPTION_CLASS =
|
||||||
|
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
|
||||||
|
|
||||||
|
/** تصاویر میزبان خارجی (مثل چسباندهشده از Outline) قابل میزبانی مجدد هستند */
|
||||||
|
function isExternalImage(src: string): boolean {
|
||||||
|
if (!src) return false
|
||||||
|
if (src.startsWith('/media/') || src.startsWith('/')) return false
|
||||||
|
try {
|
||||||
|
return new URL(src, window.location.origin).origin !== window.location.origin
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
|
||||||
|
* <figure><img><figcaption>کپشن</figcaption></figure>
|
||||||
|
*
|
||||||
|
* ویرایش کاملاً درونخطی است (بدون پاپاور/مودال):
|
||||||
|
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره میشود.
|
||||||
|
* - دکمه X کپشن را کامل حذف میکند؛ «افزودن کپشن» دوباره جعبه را باز میکند.
|
||||||
|
* - Alt و Title تصویر (نام تصویر برای سئو/دسترسپذیری) پس از درج هم قابل ویرایشاند.
|
||||||
|
* - تصویر میزبان خارجی دکمه «میزبانی مجدد» دارد: تصویر دانلود و در Object Storage
|
||||||
|
* خود سایت ذخیره میشود (ترمیم تصاویر چسباندهشده از Outline).
|
||||||
|
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
||||||
|
*/
|
||||||
|
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
||||||
|
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
||||||
|
const { runSave } = useAdmin()
|
||||||
|
const hasCaption = Boolean((caption ?? '').trim())
|
||||||
|
const [captionOpen, setCaptionOpen] = useState(true)
|
||||||
|
const [rehosting, setRehosting] = useState(false)
|
||||||
|
const captionInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const focusOnOpen = useRef(false)
|
||||||
|
const showCaption = captionOpen || hasCaption
|
||||||
|
const external = isExternalImage(src)
|
||||||
|
|
||||||
|
// فقط وقتی کاربر «افزودن کپشن» را میزند ورودی فوکوس میگیرد (نه هنگام بارگذاری مقاله)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusOnOpen.current) return
|
||||||
|
focusOnOpen.current = false
|
||||||
|
captionInputRef.current?.focus()
|
||||||
|
}, [captionOpen])
|
||||||
|
|
||||||
|
/** حذف کامل کپشن: عنصر figcaption و ورودیاش از DOM ویرایشگر بیرون میرود */
|
||||||
|
const removeCaption = () => {
|
||||||
|
updateAttributes({ caption: '' })
|
||||||
|
setCaptionOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCaption = () => {
|
||||||
|
focusOnOpen.current = true
|
||||||
|
setCaptionOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** میزبانی مجدد تصویر خارجی — دانلود از منبع و ذخیره در Object Storage سایت */
|
||||||
|
const rehost = () => {
|
||||||
|
if (rehosting) return
|
||||||
|
setRehosting(true)
|
||||||
|
void runSave(async () => {
|
||||||
|
const { url } = await adminApi.importImage(src)
|
||||||
|
updateAttributes({ src: url })
|
||||||
|
}).finally(() => setRehosting(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper
|
||||||
|
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
|
||||||
|
data-drag-handle
|
||||||
|
>
|
||||||
|
<figure className="article-figure relative">
|
||||||
|
<img src={src} alt={alt ?? ''} title={title ?? ''} draggable={false} />
|
||||||
|
|
||||||
|
{/* حذف کل تصویر */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={() => deleteNode()}
|
||||||
|
aria-label="حذف تصویر"
|
||||||
|
title="حذف تصویر"
|
||||||
|
className={`absolute left-2 top-2 z-10 ${DELETE_BUTTON_CLASS}`}
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* تصویر خارجی (مثل Outline) — منتقل به میزبانی سایت */}
|
||||||
|
{external && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={rehost}
|
||||||
|
disabled={rehosting}
|
||||||
|
aria-label="میزبانی مجدد تصویر"
|
||||||
|
title="تصویر از منبع خارجی دانلود و در سایت ذخیره میشود"
|
||||||
|
className="absolute right-2 top-2 z-10 flex items-center gap-1.5 border border-copper/60 bg-ink/85 px-2.5 py-1 text-[11px] font-light text-copper backdrop-blur-sm transition-colors duration-150 hover:bg-copper hover:text-char disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<CloudUploadIcon className={`h-3.5 w-3.5 ${rehosting ? 'animate-pulse' : ''}`} />
|
||||||
|
{rehosting ? 'در حال انتقال…' : 'میزبانی مجدد'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* نام تصویر — Alt و Title پس از درج هم قابل ویرایشاند */}
|
||||||
|
<div contentEditable={false} className="mt-1.5 grid gap-1.5 sm:grid-cols-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={alt ?? ''}
|
||||||
|
onChange={(event) => updateAttributes({ alt: event.target.value })}
|
||||||
|
placeholder="نام تصویر / متن جایگزین (Alt)…"
|
||||||
|
aria-label="متن جایگزین تصویر"
|
||||||
|
className={INLINE_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title ?? ''}
|
||||||
|
onChange={(event) => updateAttributes({ title: event.target.value })}
|
||||||
|
placeholder="عنوان تصویر (Title)…"
|
||||||
|
aria-label="عنوان تصویر"
|
||||||
|
className={INLINE_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCaption ? (
|
||||||
|
<figcaption className="article-figure__caption" contentEditable={false}>
|
||||||
|
<input
|
||||||
|
ref={captionInputRef}
|
||||||
|
type="text"
|
||||||
|
value={caption ?? ''}
|
||||||
|
onChange={(event) => updateAttributes({ caption: event.target.value })}
|
||||||
|
placeholder="توضیح تصویر (کپشن)…"
|
||||||
|
aria-label="کپشن تصویر"
|
||||||
|
className={INLINE_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
{/* حذف کامل کپشن — تصویر دستنخورده میماند */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={removeCaption}
|
||||||
|
aria-label="حذف کپشن"
|
||||||
|
title="حذف کپشن (تصویر حذف نمیشود)"
|
||||||
|
className={DELETE_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</figcaption>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={addCaption}
|
||||||
|
className={ADD_CAPTION_CLASS}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
افزودن کپشن
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import DatePicker from 'react-multi-date-picker'
|
||||||
|
import type { DateObject } from 'react-multi-date-picker'
|
||||||
|
import persian from 'react-date-object/calendars/persian'
|
||||||
|
import persian_fa from 'react-date-object/locales/persian_fa'
|
||||||
|
import { toISODate, toJalaliDateObject } from '../../utils/date'
|
||||||
|
|
||||||
|
type JalaliDatePickerProps = {
|
||||||
|
/** مقدار ذخیرهشده — ISO میلادی (YYYY-MM-DD) */
|
||||||
|
value: string
|
||||||
|
/** تغییر مقدار — همیشه ISO میلادی برمیگرداند تا ذخیرهسازی یکدست بماند */
|
||||||
|
onChange: (isoDate: string) => void
|
||||||
|
id?: string
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* انتخابگر تاریخ شمسی (react-multi-date-picker) با تقویم و locale فارسی.
|
||||||
|
* نمایش جلالی است اما مقدار ذخیرهشده ISO میلادی میماند.
|
||||||
|
*/
|
||||||
|
export function JalaliDatePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
id,
|
||||||
|
placeholder = 'انتخاب تاریخ',
|
||||||
|
disabled = false,
|
||||||
|
className = '',
|
||||||
|
}: JalaliDatePickerProps) {
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
id={id}
|
||||||
|
value={toJalaliDateObject(value)}
|
||||||
|
onChange={(selected) => {
|
||||||
|
const picked = Array.isArray(selected) ? selected[0] : selected
|
||||||
|
if (!picked) return
|
||||||
|
onChange(toISODate(picked as DateObject))
|
||||||
|
}}
|
||||||
|
calendar={persian}
|
||||||
|
locale={persian_fa}
|
||||||
|
format="YYYY/MM/DD"
|
||||||
|
calendarPosition="bottom-right"
|
||||||
|
className={`aramesh-datepicker ${className}`}
|
||||||
|
containerClassName="w-full"
|
||||||
|
inputClass="w-full cursor-pointer border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
mobileLabels={{ OK: 'تأیید', CANCEL: 'انصراف' }}
|
||||||
|
portal
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import { Link2Icon, Trash2Icon, XIcon } from 'lucide-react'
|
||||||
|
import { buildLinkRel, normalizeHref } from '../../utils/link'
|
||||||
|
|
||||||
|
export type LinkModalValue = {
|
||||||
|
href: string
|
||||||
|
newTab: boolean
|
||||||
|
nofollow: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkModalProps = {
|
||||||
|
/** مقادیر اولیه — از لینک فعال زیر نشانگر یا خالی برای لینک تازه */
|
||||||
|
initial: LinkModalValue
|
||||||
|
/** ویرایش لینک موجود (دکمه «حذف لینک» نمایش داده میشود) */
|
||||||
|
editing: boolean
|
||||||
|
onApply: (value: LinkModalValue) => void
|
||||||
|
onRemove: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_CLASS =
|
||||||
|
'mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||||
|
|
||||||
|
/** مدت انیمیشن ورود/خروج — با duration-300 کلاسها هماهنگ است */
|
||||||
|
const TRANSITION_MS = 300
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مودال ثبت و ویرایش لینک — انیمیشن نرم fade و scale با کلاسهای Tailwind.
|
||||||
|
* ورود با یک فریم تاخیر (تا کلاسهای بسته اعمال شوند) و خروج پیش از بستن مودال اجرا میشود.
|
||||||
|
* جایگزین پنجره پیشفرض مرورگر برای لینک شده است.
|
||||||
|
*/
|
||||||
|
export function LinkModal({ initial, editing, onApply, onRemove, onClose }: LinkModalProps) {
|
||||||
|
const [href, setHref] = useState(initial.href)
|
||||||
|
const [newTab, setNewTab] = useState(initial.newTab)
|
||||||
|
const [nofollow, setNofollow] = useState(initial.nofollow)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
|
||||||
|
// انیمیشن ورود: ابتدا حالت بسته رندر میشود، سپس به حالت باز میرود
|
||||||
|
useEffect(() => {
|
||||||
|
const frame = window.requestAnimationFrame(() => setVisible(true))
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** بستن نرم: اول انیمیشن خروج کامل میشود، بعد عملیات و unmount */
|
||||||
|
const closeWith = (action?: () => void) => {
|
||||||
|
if (closing) return
|
||||||
|
setClosing(true)
|
||||||
|
setVisible(false)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
action?.()
|
||||||
|
onClose()
|
||||||
|
}, TRANSITION_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const normalized = normalizeHref(href)
|
||||||
|
if (!normalized) {
|
||||||
|
setError('نشانی معتبر نیست — نمونه: https://example.com یا /contact')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeWith(() => onApply({ href: normalized, newTab, nofollow }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const relPreview = buildLinkRel({ newTab, nofollow })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[97] flex items-center justify-center p-6"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="ثبت لینک"
|
||||||
|
>
|
||||||
|
{/* پسزمینه تار — fade نرم */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-char/70 backdrop-blur-sm transition-opacity duration-300 ease-out ${
|
||||||
|
visible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={() => closeWith()}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* کارت مودال — fade + scale نرم */}
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
dir="rtl"
|
||||||
|
className={`relative w-full max-w-md border border-ivory/10 bg-ink p-7 shadow-2xl transition-all duration-300 ease-out ${
|
||||||
|
visible ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-95 opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||||
|
<Link2Icon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{editing ? 'ویرایش لینک' : 'افزودن لینک'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => closeWith()}
|
||||||
|
aria-label="بستن"
|
||||||
|
className="text-ivory/50 transition-colors hover:text-ivory"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* نشانی */}
|
||||||
|
<label htmlFor="link-href" className="mt-6 block text-xs font-light text-ivory/60">
|
||||||
|
نشانی پیوند (URL)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="link-href"
|
||||||
|
type="text"
|
||||||
|
dir="ltr"
|
||||||
|
autoFocus
|
||||||
|
value={href}
|
||||||
|
onChange={(event) => {
|
||||||
|
setHref(event.target.value)
|
||||||
|
if (error) setError('')
|
||||||
|
}}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-2 border-r-2 border-red-400/70 pr-3 text-[11px] font-light leading-6 text-red-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* باز شدن در تب جدید */}
|
||||||
|
<label className="mt-5 flex cursor-pointer items-center gap-3 text-sm font-light text-ivory/80">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newTab}
|
||||||
|
onChange={(event) => setNewTab(event.target.checked)}
|
||||||
|
className="h-4 w-4 accent-copper"
|
||||||
|
/>
|
||||||
|
باز شدن در تب جدید
|
||||||
|
<span className="text-[10px] font-light text-ivory/35" dir="ltr">target="_blank"</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* نوع لینک از نظر سئو */}
|
||||||
|
<fieldset className="mt-5">
|
||||||
|
<legend className="text-xs font-light text-ivory/60">انتقال اعتبار سئو (rel)</legend>
|
||||||
|
<div className="mt-3 grid gap-2.5">
|
||||||
|
{[
|
||||||
|
{ key: 'dofollow', label: 'Dofollow', hint: 'اعتبار سئو به مقصد منتقل میشود', value: false },
|
||||||
|
{ key: 'nofollow', label: 'Nofollow', hint: 'گوگل این لینک را دنبال نکند', value: true },
|
||||||
|
].map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.key}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 border px-4 py-3 transition-colors duration-150 ${
|
||||||
|
nofollow === option.value
|
||||||
|
? 'border-copper/60 bg-copper/10'
|
||||||
|
: 'border-ivory/12 hover:border-ivory/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="link-rel"
|
||||||
|
checked={nofollow === option.value}
|
||||||
|
onChange={() => setNofollow(option.value)}
|
||||||
|
className="mt-0.5 h-4 w-4 accent-copper"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span className="text-sm font-light text-ivory">{option.label}</span>
|
||||||
|
<span className="text-[11px] font-light text-ivory/45">{option.hint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{relPreview && (
|
||||||
|
<p className="mt-4 text-[10px] font-light text-ivory/35" dir="ltr">
|
||||||
|
rel="{relPreview}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* دکمهها */}
|
||||||
|
<div className="mt-7 flex items-center justify-between gap-3">
|
||||||
|
{editing ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => closeWith(onRemove)}
|
||||||
|
className="flex items-center gap-1.5 border border-red-400/40 px-4 py-2.5 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
حذف لینک
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => closeWith()}
|
||||||
|
className="border border-ivory/20 px-5 py-2.5 text-xs font-light text-ivory/70 transition-colors hover:border-ivory/40 hover:text-ivory"
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-copper px-6 py-2.5 text-xs font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
{editing ? 'ذخیره لینک' : 'افزودن لینک'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import Select from 'react-select'
|
||||||
|
import CreatableSelect from 'react-select/creatable'
|
||||||
|
import type { ClassNamesConfig, GroupBase, MultiValueRemoveProps } from 'react-select'
|
||||||
|
import { XIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export type LuxeOption = { label: string; value: string }
|
||||||
|
|
||||||
|
type LuxeClassNames<IsMulti extends boolean> = ClassNamesConfig<
|
||||||
|
LuxeOption,
|
||||||
|
IsMulti,
|
||||||
|
GroupBase<LuxeOption>
|
||||||
|
>
|
||||||
|
|
||||||
|
const CONTROL_CLASS_NAMES = (isFocused: boolean): string =>
|
||||||
|
`!min-h-[42px] !rounded-none !border !bg-char !transition-colors ${
|
||||||
|
isFocused
|
||||||
|
? '!border-copper !shadow-[0_0_0_1px_rgba(213,135,76,0.35)]'
|
||||||
|
: '!border-ivory/15 !shadow-none hover:!border-copper/50'
|
||||||
|
}`
|
||||||
|
|
||||||
|
const OPTION_CLASS_NAMES = (isSelected: boolean, isFocused: boolean): string =>
|
||||||
|
`!cursor-pointer !rounded-none !text-[13px] ${
|
||||||
|
isSelected
|
||||||
|
? '!bg-copper/25 !font-bold !text-copper'
|
||||||
|
: isFocused
|
||||||
|
? '!bg-copper/15 !text-ivory'
|
||||||
|
: '!bg-transparent !text-ivory/85'
|
||||||
|
}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کلاسهای Tailwind برای همه اسلاتهای react-select — تم تیره پروژه.
|
||||||
|
* react-select برای هر اسلات تابع میخواهد، پس همه با تابع برگردانده میشوند.
|
||||||
|
*/
|
||||||
|
function makeClassNames<IsMulti extends boolean>(
|
||||||
|
extras?: Partial<LuxeClassNames<IsMulti>>,
|
||||||
|
): LuxeClassNames<IsMulti> {
|
||||||
|
return {
|
||||||
|
control: (state) => CONTROL_CLASS_NAMES(state.isFocused),
|
||||||
|
option: (state) => OPTION_CLASS_NAMES(state.isSelected, state.isFocused),
|
||||||
|
valueContainer: () => '!gap-1 !px-3 !py-0.5',
|
||||||
|
placeholder: () => '!text-[13px] !text-ivory/35',
|
||||||
|
singleValue: () => '!text-[13px] !text-ivory',
|
||||||
|
input: () => '!text-[13px] !text-ivory',
|
||||||
|
indicatorsContainer: () => '!text-ivory/40',
|
||||||
|
dropdownIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||||
|
clearIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||||
|
indicatorSeparator: () => '!bg-ivory/15',
|
||||||
|
menu: () => '!mt-1 !rounded-none !border !border-ivory/10 !bg-char !shadow-lift',
|
||||||
|
menuList: () => '!p-1',
|
||||||
|
noOptionsMessage: () => '!text-xs !text-ivory/45',
|
||||||
|
loadingMessage: () => '!text-xs !text-ivory/45',
|
||||||
|
multiValue: () => '!m-0.5 !rounded-full !border !border-copper/40 !bg-copper/15 !py-0.5 !pe-0 !ps-2',
|
||||||
|
multiValueLabel: () => '!text-xs !text-copper',
|
||||||
|
...extras,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** دکمه ضربدر حذف برچسب انتخابشده — همیشه واضح و قابل کلیک */
|
||||||
|
function TagRemoveButton({ innerProps }: MultiValueRemoveProps<LuxeOption, true>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...innerProps}
|
||||||
|
role="button"
|
||||||
|
aria-label="حذف برچسب"
|
||||||
|
title="حذف برچسب"
|
||||||
|
className="mx-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-copper transition-colors duration-150 hover:bg-copper hover:text-char"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalTarget = (): HTMLElement | undefined =>
|
||||||
|
typeof document === 'undefined' ? undefined : document.body
|
||||||
|
|
||||||
|
const uniqueOptions = (values: string[]): LuxeOption[] =>
|
||||||
|
[...new Set(values.filter(Boolean))].map((item) => ({ label: item, value: item }))
|
||||||
|
|
||||||
|
type LuxeSelectProps = {
|
||||||
|
value: string
|
||||||
|
options: string[]
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
isClearable?: boolean
|
||||||
|
isDisabled?: boolean
|
||||||
|
inputId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** react-select تکی با تم تیره — گزینه فعلی حتی اگر در لیست نباشد حفظ میشود */
|
||||||
|
export function LuxeSelect({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'انتخاب کنید…',
|
||||||
|
isClearable = true,
|
||||||
|
isDisabled = false,
|
||||||
|
inputId,
|
||||||
|
}: LuxeSelectProps) {
|
||||||
|
const selectOptions = useMemo(
|
||||||
|
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||||
|
[options, value],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select<LuxeOption, false>
|
||||||
|
inputId={inputId}
|
||||||
|
classNamePrefix="luxe"
|
||||||
|
options={selectOptions}
|
||||||
|
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||||
|
onChange={(option) => onChange(option?.value ?? '')}
|
||||||
|
placeholder={placeholder}
|
||||||
|
isClearable={isClearable}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||||
|
classNames={makeClassNames<false>()}
|
||||||
|
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||||
|
menuPosition="fixed"
|
||||||
|
menuPortalTarget={portalTarget()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LuxeCreatableSelectProps = {
|
||||||
|
value: string
|
||||||
|
options: string[]
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
inputId?: string
|
||||||
|
/** متن گزینه «ساخت مقدار جدید» در فهرست بازشو */
|
||||||
|
createLabel?: (input: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** react-select تکی و قابل ساخت — کاربر با کلیک روی گزینه ساخت، مقدار تازه میسازد */
|
||||||
|
export function LuxeCreatableSelect({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'انتخاب یا نوشتن مورد جدید…',
|
||||||
|
inputId,
|
||||||
|
createLabel = (input) => `افزودن «${input}»`,
|
||||||
|
}: LuxeCreatableSelectProps) {
|
||||||
|
const selectOptions = useMemo(
|
||||||
|
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||||
|
[options, value],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreatableSelect<LuxeOption, false>
|
||||||
|
inputId={inputId}
|
||||||
|
classNamePrefix="luxe"
|
||||||
|
options={selectOptions}
|
||||||
|
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||||
|
onChange={(option) => onChange(option?.value?.trim() ?? '')}
|
||||||
|
placeholder={placeholder}
|
||||||
|
formatCreateLabel={(input) => createLabel(input)}
|
||||||
|
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||||
|
classNames={makeClassNames<false>()}
|
||||||
|
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||||
|
menuPosition="fixed"
|
||||||
|
menuPortalTarget={portalTarget()}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryOption = { label: string; value: string }
|
||||||
|
|
||||||
|
/** کلید نگهداری دستههای ساختهشده در مرورگر */
|
||||||
|
const CREATED_CATEGORIES_KEY = 'aramesh_article_categories'
|
||||||
|
|
||||||
|
/** دستههایی که ادمین ساخته است — برای مقالههای بعدی هم در فهرست میمانند */
|
||||||
|
function readCreatedCategories(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(CREATED_CATEGORIES_KEY)
|
||||||
|
const parsed: unknown = raw ? JSON.parse(raw) : []
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.filter((item): item is string => typeof item === 'string')
|
||||||
|
: []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeCreatedCategory(category: string): void {
|
||||||
|
try {
|
||||||
|
const current = readCreatedCategories()
|
||||||
|
if (current.includes(category)) return
|
||||||
|
window.localStorage.setItem(CREATED_CATEGORIES_KEY, JSON.stringify([...current, category]))
|
||||||
|
} catch {
|
||||||
|
/* حافظه مرورگر در دسترس نیست — بیصدا رد شو */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategorySelectProps = {
|
||||||
|
/** مقدار انتخابشده — یک شیء {value,label} یا null */
|
||||||
|
value: CategoryOption | null
|
||||||
|
/** تغییر انتخاب — null یعنی فقط انتخاب پاک شده است (دکمه X) */
|
||||||
|
onChange: (option: CategoryOption | null) => void
|
||||||
|
/** دستههای پایه */
|
||||||
|
options: string[]
|
||||||
|
/** متن گزینه «ساخت دسته جدید» در فهرست بازشو */
|
||||||
|
createLabel?: (input: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* انتخابگر تکمقداری دستهبندی (react-select Creatable).
|
||||||
|
* - isMulti=false و isClearable: دکمه X فقط انتخاب جاری را پاک میکند و گزینهها دستنخورده میمانند.
|
||||||
|
* - ساخت دسته جدید: به انتهای فهرست گزینهها اضافه و برای مقاله جاری انتخاب میشود و
|
||||||
|
* در همین مرورگر برای مقالههای بعدی نگه داشته میشود.
|
||||||
|
*/
|
||||||
|
export function CategorySelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
createLabel = (input) => `ایجاد دسته جدید: ${input}`,
|
||||||
|
}: CategorySelectProps) {
|
||||||
|
const [createdOptions, setCreatedOptions] = useState<string[]>(() => readCreatedCategories())
|
||||||
|
|
||||||
|
// گزینههای پایه + دستههای ساختهشده (همیشه در انتهای فهرست)
|
||||||
|
const selectOptions = useMemo(
|
||||||
|
() => uniqueOptions([...options, ...createdOptions.filter((item) => !options.includes(item))]),
|
||||||
|
[options, createdOptions],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** ساخت دسته جدید: افزودن به انتهای فهرست + انتخاب خودکار برای مقاله جاری */
|
||||||
|
const handleCreate = (input: string) => {
|
||||||
|
const category = input.trim()
|
||||||
|
if (!category) return
|
||||||
|
setCreatedOptions((previous) =>
|
||||||
|
previous.includes(category) ? previous : [...previous, category],
|
||||||
|
)
|
||||||
|
storeCreatedCategory(category)
|
||||||
|
onChange({ label: category, value: category })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreatableSelect<CategoryOption, false>
|
||||||
|
classNamePrefix="luxe"
|
||||||
|
isMulti={false}
|
||||||
|
isClearable
|
||||||
|
options={selectOptions}
|
||||||
|
value={value}
|
||||||
|
onChange={(option) => onChange(option ? { label: option.label, value: option.value } : null)}
|
||||||
|
onCreateOption={handleCreate}
|
||||||
|
formatCreateLabel={(input) => createLabel(input)}
|
||||||
|
placeholder="انتخاب یا نوشتن دسته جدید…"
|
||||||
|
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||||
|
classNames={makeClassNames<false>()}
|
||||||
|
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||||
|
menuPosition="fixed"
|
||||||
|
menuPortalTarget={portalTarget()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LuxeTagsSelectProps = {
|
||||||
|
value: string[]
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
placeholder?: string
|
||||||
|
}/** هشتگها با react-select قابل ساخت — هر برچسب دکمه ضربدر حذف مستقل دارد */
|
||||||
|
export function LuxeTagsSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'هشتگ بنویسید و Enter بزنید…',
|
||||||
|
}: LuxeTagsSelectProps) {
|
||||||
|
const options = useMemo(() => uniqueOptions(value), [value])
|
||||||
|
|
||||||
|
/** پاکسازی و حذف تکراریها هنگام افزودن یا حذف برچسب */
|
||||||
|
const applyTags = (next: readonly LuxeOption[]) => {
|
||||||
|
const tags = next.map((option) => option.value.trim()).filter(Boolean)
|
||||||
|
onChange([...new Set(tags)])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreatableSelect<LuxeOption, true>
|
||||||
|
classNamePrefix="luxe"
|
||||||
|
isMulti
|
||||||
|
options={options}
|
||||||
|
value={options}
|
||||||
|
onChange={applyTags}
|
||||||
|
placeholder={placeholder}
|
||||||
|
formatCreateLabel={(input) => `افزودن هشتگ «${input}»`}
|
||||||
|
noOptionsMessage={() => 'هشتگی یافت نشد'}
|
||||||
|
closeMenuOnSelect={false}
|
||||||
|
components={{ MultiValueRemove: TagRemoveButton }}
|
||||||
|
classNames={makeClassNames<true>({ multiValueRemove: () => '!bg-transparent' })}
|
||||||
|
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||||
|
menuPosition="fixed"
|
||||||
|
menuPortalTarget={portalTarget()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
||||||
import type { ArticleBodyDoc } from '../../data/site'
|
import type { ArticleBodyDoc } from '../../data/site'
|
||||||
|
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
|
||||||
|
|
||||||
export type SeoInput = {
|
export type SeoInput = {
|
||||||
title: string
|
title: string
|
||||||
@@ -13,78 +14,6 @@ export type SeoInput = {
|
|||||||
type Status = 'good' | 'ok' | 'bad'
|
type Status = 'good' | 'ok' | 'bad'
|
||||||
type Check = { label: string; status: Status; hint: string }
|
type Check = { label: string; status: Status; hint: string }
|
||||||
|
|
||||||
// ---------- استخراج متن/ساختار از سند TipTap ----------
|
|
||||||
|
|
||||||
function docToText(body: SeoInput['body']): string {
|
|
||||||
if (Array.isArray(body)) return body.join(' ')
|
|
||||||
const out: string[] = []
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (!node || typeof node !== 'object') return
|
|
||||||
const n = node as { type?: string; text?: string; content?: unknown[] }
|
|
||||||
if (n.type === 'text' && n.text) out.push(n.text)
|
|
||||||
for (const c of n.content ?? []) walk(c)
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
return out.join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function docHeadings(body: SeoInput['body']): string[] {
|
|
||||||
if (Array.isArray(body)) return []
|
|
||||||
const out: string[] = []
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (!node || typeof node !== 'object') return
|
|
||||||
const n = node as { type?: string; text?: string; attrs?: { level?: number }; content?: unknown[] }
|
|
||||||
if (n.type === 'heading' && n.attrs?.level === 2) {
|
|
||||||
const texts: string[] = []
|
|
||||||
const collect = (inner: unknown) => {
|
|
||||||
if (!inner || typeof inner !== 'object') return
|
|
||||||
const t = inner as { type?: string; text?: string; content?: unknown[] }
|
|
||||||
if (t.type === 'text' && t.text) texts.push(t.text)
|
|
||||||
for (const c of t.content ?? []) collect(c)
|
|
||||||
}
|
|
||||||
collect(n)
|
|
||||||
out.push(texts.join(''))
|
|
||||||
}
|
|
||||||
for (const c of n.content ?? []) walk(c)
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function docImages(body: SeoInput['body']): { src: string; alt: string }[] {
|
|
||||||
if (Array.isArray(body)) return []
|
|
||||||
const out: { src: string; alt: string }[] = []
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (!node || typeof node !== 'object') return
|
|
||||||
const n = node as { type?: string; attrs?: { src?: string; alt?: string }; content?: unknown[] }
|
|
||||||
if (n.type === 'image') out.push({ src: n.attrs?.src ?? '', alt: n.attrs?.alt ?? '' })
|
|
||||||
for (const c of n.content ?? []) walk(c)
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function docLinks(body: SeoInput['body']): string[] {
|
|
||||||
if (Array.isArray(body)) return []
|
|
||||||
const out: string[] = []
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (!node || typeof node !== 'object') return
|
|
||||||
const n = node as { type?: string; marks?: { type?: string; attrs?: { href?: string } }[]; content?: unknown[] }
|
|
||||||
for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) out.push(m.attrs.href)
|
|
||||||
for (const c of n.content ?? []) walk(c)
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstParagraph(body: SeoInput['body']): string {
|
|
||||||
if (Array.isArray(body)) return body[0] ?? ''
|
|
||||||
const p = (body as { content?: { type?: string; content?: unknown[] }[] }).content?.find(
|
|
||||||
(c) => c.type === 'paragraph',
|
|
||||||
)
|
|
||||||
return docToText({ type: 'doc', content: p ? [p] : [] } as never)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- محاسبه بررسیها ----------
|
// ---------- محاسبه بررسیها ----------
|
||||||
|
|
||||||
export function useSeoChecks(input: SeoInput): Check[] {
|
export function useSeoChecks(input: SeoInput): Check[] {
|
||||||
@@ -117,7 +46,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
|||||||
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
|
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
|
||||||
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
|
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
|
||||||
)
|
)
|
||||||
const h2s = docHeadings(input.body)
|
const h2s = docHeadings(input.body, 2)
|
||||||
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
|
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
|
||||||
else
|
else
|
||||||
checks.push(
|
checks.push(
|
||||||
@@ -140,7 +69,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
|||||||
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
|
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
|
||||||
|
|
||||||
// تیتر H2
|
// تیتر H2
|
||||||
const h2s = docHeadings(input.body)
|
const h2s = docHeadings(input.body, 2)
|
||||||
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
|
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
|
||||||
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخشبندی کنید.' })
|
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخشبندی کنید.' })
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { setBlockType, splitBlock } from '@tiptap/pm/commands'
|
||||||
|
import type { EditorView } from '@tiptap/pm/view'
|
||||||
|
import type { ResolvedPos } from '@tiptap/pm/model'
|
||||||
|
|
||||||
|
/** قالب بلوک جاری — پاراگراف یا یکی از سطوح تیتر */
|
||||||
|
export type BlockFormat = 'paragraph' | 1 | 2 | 3
|
||||||
|
|
||||||
|
type LineBounds = {
|
||||||
|
/** ابتدای بلوک جاری */
|
||||||
|
start: number
|
||||||
|
/** پایان بلوک جاری */
|
||||||
|
end: number
|
||||||
|
/** ابتدای خط جاری (پس از نزدیکترین شکست نرم قبل از نشانگر) */
|
||||||
|
lineStart: number
|
||||||
|
/** پایان خط جاری (نزدیکترین شکست نرم پس از نشانگر) */
|
||||||
|
lineEnd: number
|
||||||
|
/** موقعیت شکست نرم قبل از خط جاری — null یعنی خط، ابتدای بلوک است */
|
||||||
|
before: number | null
|
||||||
|
/** موقعیت شکست نرم بعد از خط جاری — null یعنی خط، انتهای بلوک است */
|
||||||
|
after: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مرزهای خط جاری داخل بلوک جاری — خطوطی که با شکست نرم (<br>) از هم جدا شدهاند */
|
||||||
|
function lineBounds($from: ResolvedPos): LineBounds {
|
||||||
|
const start = $from.start()
|
||||||
|
const end = $from.end()
|
||||||
|
let lineStart = start
|
||||||
|
let lineEnd = end
|
||||||
|
let before: number | null = null
|
||||||
|
let after: number | null = null
|
||||||
|
|
||||||
|
$from.parent.forEach((node, offset) => {
|
||||||
|
if (node.type.name !== 'hardBreak') return
|
||||||
|
const pos = start + offset
|
||||||
|
if (pos < $from.pos) {
|
||||||
|
lineStart = pos + 1
|
||||||
|
before = pos
|
||||||
|
} else if (after === null) {
|
||||||
|
lineEnd = pos
|
||||||
|
after = pos
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { start, end, lineStart, lineEnd, before, after }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جدا کردن خط جاری از خطوط چسبیده با شکست نرم (Shift+Enter یا متن چسباندهشده از Word).
|
||||||
|
* بدون این جداسازی، عوض کردن قالب یک خط، قالب همه خطوط همان بلوک را با هم عوض میکند.
|
||||||
|
* اگر گزینش از خط عبور کند (قالببندی چندخطیِ عمدی) دستنخورده میماند.
|
||||||
|
*/
|
||||||
|
function isolateCurrentLine(view: EditorView): boolean {
|
||||||
|
const { state } = view
|
||||||
|
const { selection } = state
|
||||||
|
const bounds = lineBounds(selection.$from)
|
||||||
|
|
||||||
|
if (bounds.before === null && bounds.after === null) return false
|
||||||
|
|
||||||
|
const selectionInsideLine =
|
||||||
|
selection.empty ||
|
||||||
|
(selection.from >= bounds.lineStart && selection.to <= bounds.lineEnd)
|
||||||
|
if (!selectionInsideLine) return false
|
||||||
|
|
||||||
|
const { tr } = state
|
||||||
|
if (bounds.after !== null) {
|
||||||
|
tr.delete(bounds.after, bounds.after + 1)
|
||||||
|
tr.split(bounds.after)
|
||||||
|
}
|
||||||
|
if (bounds.before !== null) {
|
||||||
|
tr.delete(bounds.before, bounds.before + 1)
|
||||||
|
tr.split(bounds.before)
|
||||||
|
}
|
||||||
|
if (!tr.docChanged) return false
|
||||||
|
|
||||||
|
view.dispatch(tr)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قالببندی بلوک جاری (تیتر ۱/۲/۳ یا پاراگراف) — فقط همان خطی که نشانگر در آن است
|
||||||
|
* و به خطوط بالا و پایین کاری ندارد. کلیک دوباره روی همان سطح تیتر، آن را به
|
||||||
|
* پاراگراف برمیگرداند (رفتار toggle).
|
||||||
|
*/
|
||||||
|
export function applyBlockFormat(view: EditorView, format: BlockFormat): boolean {
|
||||||
|
view.focus()
|
||||||
|
isolateCurrentLine(view)
|
||||||
|
|
||||||
|
const { state } = view
|
||||||
|
const paragraph = state.schema.nodes.paragraph
|
||||||
|
const heading = state.schema.nodes.heading
|
||||||
|
if (!paragraph || !heading) return false
|
||||||
|
|
||||||
|
if (format === 'paragraph') return setBlockType(paragraph)(state, view.dispatch, view)
|
||||||
|
|
||||||
|
const current = state.selection.$from.parent
|
||||||
|
const sameHeading = current.type === heading && current.attrs.level === format
|
||||||
|
return sameHeading
|
||||||
|
? setBlockType(paragraph)(state, view.dispatch, view)
|
||||||
|
: setBlockType(heading, { level: format })(state, view.dispatch, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter داخل تیتر: بلوک تازه پاراگراف میشود (نه تیتر تازه) تا قالب تیتر به خط
|
||||||
|
* بعدی سرایت نکند و مرز بلوکها تمیز بماند.
|
||||||
|
* این هندلر پیش از keymap پیشفرض اجرا میشود و فقط همین حالت را میگیرد.
|
||||||
|
*/
|
||||||
|
export function handleHeadingEnter(view: EditorView, event: KeyboardEvent): boolean {
|
||||||
|
if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const { state } = view
|
||||||
|
const { selection } = state
|
||||||
|
const paragraph = state.schema.nodes.paragraph
|
||||||
|
if (!paragraph || !selection.empty || selection.$from.parent.type.name !== 'heading') return false
|
||||||
|
|
||||||
|
// خط تازه با splitBlock ساخته و بعد به پاراگراف تبدیل میشود
|
||||||
|
if (!splitBlock(state, view.dispatch, view)) return false
|
||||||
|
setBlockType(paragraph)(view.state, view.dispatch, view)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
type SmartTextFieldOptions = {
|
||||||
|
/** مقدار ذخیرهشده در بیرون (منبع حقیقت) */
|
||||||
|
value: string
|
||||||
|
/** پیشنهاد خودکار — وقتی کاربر چیزی ننوشته، همین متن نمایش داده و ذخیره میشود */
|
||||||
|
fallback?: string
|
||||||
|
/** آخرین گزینه وقتی پیشنهادی هم وجود ندارد (مثل «Photo 1») */
|
||||||
|
hardFallback?: string
|
||||||
|
/** ذخیره مقدار نهایی — فقط وقتی نسبت به مقدار ذخیرهشده تغییر کرده باشد */
|
||||||
|
onCommit?: (next: string) => void | Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فیلد متنی با «پیشفرض هوشمند» برای Alt/Title تصویر:
|
||||||
|
* - متن کاربر تا لحظه ذخیره فقط در حالت محلی میماند؛ در حین تایپ نه trim میشود و نه از
|
||||||
|
* بیرون بازنویسی میشود، پس فاصله انتهای متن و جای نشانگر حفظ میشود.
|
||||||
|
* - با فوکوس، پیشفرض خودکار پاک میشود تا کاربر بلافاصله تایپ کند.
|
||||||
|
* - با بلور، اگر فیلد خالی بماند پیشفرض خودکار برمیگردد و مقدار نهایی ذخیره میشود.
|
||||||
|
*/
|
||||||
|
export function useSmartTextField({
|
||||||
|
value,
|
||||||
|
fallback = '',
|
||||||
|
hardFallback = '',
|
||||||
|
onCommit,
|
||||||
|
}: SmartTextFieldOptions) {
|
||||||
|
const [text, setText] = useState(value)
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
|
||||||
|
// تازهترین مقادیر در ref نگه داشته میشوند تا هندلرها به closure قدیمی وابسته نباشند
|
||||||
|
const textRef = useRef(text)
|
||||||
|
textRef.current = text
|
||||||
|
const focusedRef = useRef(focused)
|
||||||
|
focusedRef.current = focused
|
||||||
|
const fallbackRef = useRef(fallback)
|
||||||
|
fallbackRef.current = fallback
|
||||||
|
const hardFallbackRef = useRef(hardFallback)
|
||||||
|
hardFallbackRef.current = hardFallback
|
||||||
|
const valueRef = useRef(value)
|
||||||
|
valueRef.current = value
|
||||||
|
|
||||||
|
// همگامسازی با مقدار ذخیرهشده از بیرون (بارگذاری مقاله، بهروزرسانی خوشبینانه)
|
||||||
|
// اما هرگز در حین ویرایش کاربر — وگرنه نشانگر میپرد و فاصلهها میخورند
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusedRef.current) return
|
||||||
|
setText(value)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
/** مقدار نهایی فیلد: متن کاربر → پیشنهاد خودکار → پیشفرض ثابت */
|
||||||
|
const resolve = useCallback((): string => {
|
||||||
|
const current = textRef.current.trim()
|
||||||
|
if (current) return current
|
||||||
|
return fallbackRef.current.trim() || hardFallbackRef.current.trim()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const commit = useCallback(async (): Promise<void> => {
|
||||||
|
const next = resolve()
|
||||||
|
setText(next)
|
||||||
|
if (onCommit && next !== valueRef.current.trim()) await onCommit(next)
|
||||||
|
}, [onCommit, resolve])
|
||||||
|
|
||||||
|
const onFocus = useCallback(() => {
|
||||||
|
setFocused(true)
|
||||||
|
// پیشفرض خودکار متن واقعی نیست؛ با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند
|
||||||
|
const current = textRef.current.trim()
|
||||||
|
const auto = fallbackRef.current.trim() || hardFallbackRef.current.trim()
|
||||||
|
if (!current || current === auto) setText('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onBlur = useCallback(() => {
|
||||||
|
setFocused(false)
|
||||||
|
void commit()
|
||||||
|
}, [commit])
|
||||||
|
|
||||||
|
const onChange = useCallback((next: string) => {
|
||||||
|
setText(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** متن نمایشی — در نبود متن کاربر، پیشنهاد خودکار نشان داده میشود */
|
||||||
|
const display = focused ? text : text !== '' ? text : fallback
|
||||||
|
|
||||||
|
return { display, focused, onFocus, onBlur, onChange, commit, resolve }
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(isOpen ? null : i)}
|
onClick={() => setOpen(isOpen ? null : i)}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
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'}`}>
|
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||||
{faq.question}
|
{faq.question}
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import { useFieldSaver } from '../../admin/AdminContext'
|
|||||||
import { InlineEdit } from '../admin/InlineEdit'
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
import { SingletonPill } from '../../admin/Editable'
|
import { SingletonPill } from '../../admin/Editable'
|
||||||
import { API_URL } from '../../config'
|
import { API_URL } from '../../config'
|
||||||
|
import { track } from '../../analytics'
|
||||||
|
|
||||||
export function Newsletter() {
|
export function Newsletter() {
|
||||||
const { newsletter } = useSite()
|
const { newsletter } = useSite()
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
|
const [website, setWebsite] = useState('')
|
||||||
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ export function Newsletter() {
|
|||||||
const res = await fetch(`${API_URL}/api/newsletter`, {
|
const res = await fetch(`${API_URL}/api/newsletter`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email: emailValue }),
|
body: JSON.stringify({ email: emailValue, website }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -87,6 +89,7 @@ export function Newsletter() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
track('newsletter-signup')
|
||||||
setStatus('done')
|
setStatus('done')
|
||||||
setEmail('')
|
setEmail('')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -117,6 +120,17 @@ export function Newsletter() {
|
|||||||
|
|
||||||
<Reveal delay={0.08}>
|
<Reveal delay={0.08}>
|
||||||
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
|
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
|
||||||
|
{/* honeypot — خارج از دید؛ فقط رباتها آن را پر میکنند */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="website"
|
||||||
|
value={website}
|
||||||
|
onChange={(e) => setWebsite(e.target.value)}
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
|
||||||
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label htmlFor="newsletter-email" className="sr-only">
|
<label htmlFor="newsletter-email" className="sr-only">
|
||||||
{newsletter.placeholder}
|
{newsletter.placeholder}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function Services() {
|
|||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
|
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<Reveal className="max-w-2xl text-right">
|
<Reveal className="max-w-2xl text-start">
|
||||||
|
|
||||||
<h2 id="services-title" className="text-section font-bold text-ivory">
|
<h2 id="services-title" className="text-section font-bold text-ivory">
|
||||||
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* ثابتهای مشترک رابط کاربری — تنها منبع مقادیر پیشفرض (بدون متن پراکنده در کامپوننتها).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** عنوان پیشفرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */
|
||||||
|
export const DEFAULT_IMAGE_TITLE = 'Photo 1'
|
||||||
|
|
||||||
|
/** متن جایگزین پیشفرض تصویرهای داخل متن (وقتی نام فایل چیزی به دست نمیدهد) */
|
||||||
|
export const DEFAULT_IMAGE_ALT = 'تصویر مقاله'
|
||||||
|
|
||||||
|
/** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار میگیرد */
|
||||||
|
export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص'
|
||||||
|
|
||||||
|
/** سرعت مطالعه مبنای محاسبه زمان مطالعه — واژه در دقیقه */
|
||||||
|
export const WORDS_PER_MINUTE = 200
|
||||||
|
|
||||||
|
/** برچسب زمان مطالعه در خروجی */
|
||||||
|
export const READING_TIME_LABEL = 'دقیقه مطالعه'
|
||||||
@@ -46,7 +46,11 @@ export type ArticleStatus = 'draft' | 'published';
|
|||||||
export type Article = {
|
export type Article = {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string;
|
slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string;
|
||||||
image: string; read: string; read_en?: string; excerpt: string; excerpt_en?: string;
|
image: string;
|
||||||
|
read: string; read_en?: string; excerpt: string; excerpt_en?: string;
|
||||||
|
/** پرسشوپاسخهای پایان مقاله — سئوی FAQPage (faqs_en خالی → برگشت به فارسی) */
|
||||||
|
faqs?: { question: string; answer: string }[];
|
||||||
|
faqs_en?: { question: string; answer: string }[] | null;
|
||||||
/** سند TipTap — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
/** سند TipTap — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
||||||
body: ArticleBodyDoc | string[];
|
body: ArticleBodyDoc | string[];
|
||||||
/** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */
|
/** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */
|
||||||
@@ -55,4 +59,14 @@ export type Article = {
|
|||||||
status: ArticleStatus;
|
status: ArticleStatus;
|
||||||
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
||||||
seo_keyphrase: string;
|
seo_keyphrase: string;
|
||||||
|
/** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */
|
||||||
|
meta_description?: string;
|
||||||
|
/** متن جایگزین تصویر شاخص (Alt) */
|
||||||
|
image_alt?: string;
|
||||||
|
/** عنوان تصویر شاخص (Title) */
|
||||||
|
image_title?: string;
|
||||||
|
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده میشود */
|
||||||
|
author?: string;
|
||||||
|
/** هشتگها/برچسبهای مقاله */
|
||||||
|
tags?: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function localizeSite(data: SiteData, lang: Lang): SiteData {
|
|||||||
read: s(a.read, a.read_en),
|
read: s(a.read, a.read_en),
|
||||||
excerpt: s(a.excerpt, a.excerpt_en),
|
excerpt: s(a.excerpt, a.excerpt_en),
|
||||||
body: docHasText(a.body_en) ? a.body_en! : a.body,
|
body: docHasText(a.body_en) ? a.body_en! : a.body,
|
||||||
|
faqs: list(a.faqs ?? [], a.faqs_en),
|
||||||
})),
|
})),
|
||||||
testimonials: data.testimonials.map((tm) => ({
|
testimonials: data.testimonials.map((tm) => ({
|
||||||
...tm,
|
...tm,
|
||||||
|
|||||||
+199
-5
@@ -101,6 +101,7 @@ html[lang='en'] .article-body h3 {
|
|||||||
.article-body {
|
.article-body {
|
||||||
line-height: 2.4;
|
line-height: 2.4;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
font-size: 14px; /* متن اصلی ۱۴ تنظیم شد */
|
||||||
}
|
}
|
||||||
.article-body p {
|
.article-body p {
|
||||||
margin-top: 1.75rem;
|
margin-top: 1.75rem;
|
||||||
@@ -109,6 +110,11 @@ html[lang='en'] .article-body h3 {
|
|||||||
.article-body > :first-child {
|
.article-body > :first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
/* ---------- تایپوگرافی مقاله ----------
|
||||||
|
راستچین (فارسی): پایه ۱۴ / تیتر۱ ۱۸ / تیتر۲ ۱۶ / تیتر۳ ۱۴
|
||||||
|
چپچین (انگلیسی): پایه ۱۲ / تیتر۱ ۱۶ / تیتر۲ ۱۴ / تیتر۳ ۱۲
|
||||||
|
جهت با data-dir روی پوسته ویرایشگر یا مقاله تعیین میشود. */
|
||||||
|
.article-body h1,
|
||||||
.article-body h2,
|
.article-body h2,
|
||||||
.article-body h3 {
|
.article-body h3 {
|
||||||
color: #f5f3ef;
|
color: #f5f3ef;
|
||||||
@@ -116,15 +122,98 @@ html[lang='en'] .article-body h3 {
|
|||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
line-height: 1.9;
|
line-height: 1.9;
|
||||||
|
/* فاصله از هدر چسبان هنگام پرش لنگرهای فهرست مطالب */
|
||||||
|
scroll-margin-top: 7rem;
|
||||||
|
}
|
||||||
|
.article-body h1 {
|
||||||
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
.article-body h2 {
|
.article-body h2 {
|
||||||
font-size: 1.6rem;
|
font-size: 16px;
|
||||||
padding-inline-start: 0.85rem;
|
/* نوار مسی ندارد: نوار عمودی مسی نشانه اختصاصی نقلقول است و تیتر فقط با
|
||||||
border-inline-start: 3px solid #d5874c;
|
اندازه و وزن متمایز میشود تا با نقلقول اشتباه گرفته نشود. */
|
||||||
}
|
}
|
||||||
.article-body h3 {
|
.article-body h3 {
|
||||||
font-size: 1.25rem;
|
font-size: 14px;
|
||||||
color: #e8c9a8;
|
}
|
||||||
|
/* متن مقاله در هر دو زبان همتراز (justify) است: لبههای راست و چپ یکدست میمانند.
|
||||||
|
text-align: justify با direction درست، در فارسی و انگلیسی هر دو کار میکند. */
|
||||||
|
[data-dir='rtl'] .article-body {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: justify;
|
||||||
|
text-justify: inter-word;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body {
|
||||||
|
direction: ltr;
|
||||||
|
text-align: justify;
|
||||||
|
text-justify: inter-word;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* تیترها و فهرستها همتراز نمیشوند — فقط متن پاراگراف تراز میشود
|
||||||
|
(تیتر کوتاه با justify کشیده و بدقلق میشود و فهرست نقطهگذاریاش بههم میریزد). */
|
||||||
|
[data-dir='rtl'] .article-body h1,
|
||||||
|
[data-dir='rtl'] .article-body h2,
|
||||||
|
[data-dir='rtl'] .article-body h3,
|
||||||
|
[data-dir='rtl'] .article-body li,
|
||||||
|
[data-dir='rtl'] .article-body figcaption {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body h1,
|
||||||
|
[data-dir='ltr'] .article-body h2,
|
||||||
|
[data-dir='ltr'] .article-body h3,
|
||||||
|
[data-dir='ltr'] .article-body li,
|
||||||
|
[data-dir='ltr'] .article-body figcaption {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- تایپوگرافی دوزبانه (فارسی و لاتین در یک خط) ----------
|
||||||
|
متن لاتین همیشه ۲ پیکسل کوچکتر از متن فارسیِ همان بلوک است:
|
||||||
|
تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲.
|
||||||
|
calc(1em - 2px) نسبت به اندازه همان بلوک حساب میشود، پس قاعده در همه سطحها درست
|
||||||
|
میماند و خط پایه هم جابهجا نمیشود (line-height نسبی است).
|
||||||
|
در مقاله انگلیسی (data-dir=ltr) پایه همان اندازه کوچکتر است و واژههای فارسی
|
||||||
|
۲ پیکسل بزرگتر میشوند تا تفاضل همیشه ۲ پیکسل بماند. */
|
||||||
|
.article-body .latin,
|
||||||
|
.article-body .persian,
|
||||||
|
.article-body .english-text {
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body .latin,
|
||||||
|
.article-body .english-text {
|
||||||
|
font-size: calc(1em - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body .persian {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-dir='ltr'] .article-body .latin,
|
||||||
|
[data-dir='ltr'] .article-body .english-text {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-dir='ltr'] .article-body .persian {
|
||||||
|
font-size: calc(1em + 2px);
|
||||||
|
}
|
||||||
|
.article-body span[data-small="true"] {
|
||||||
|
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
|
||||||
|
}
|
||||||
|
/* مارک «متن انگلیسی» ویرایشگر — چپبهراست و ۱۲ پیکسل بدون بههمزدن جهت متن فارسی */
|
||||||
|
.english-text {
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
/* اندازه از قواعد دوزبانه بالا میآید: ۲ پیکسل کوچکتر از متن فارسی همان بلوک */
|
||||||
}
|
}
|
||||||
.article-body ul,
|
.article-body ul,
|
||||||
.article-body ol {
|
.article-body ol {
|
||||||
@@ -168,6 +257,36 @@ html[lang='en'] .article-body h3 {
|
|||||||
border: 1px solid rgba(245, 243, 239, 0.1);
|
border: 1px solid rgba(245, 243, 239, 0.1);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
/* ---------- تصویر کپشندار: <figure><img><figcaption> ---------- */
|
||||||
|
.article-body figure {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.article-body figure img {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.article-body figcaption {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
color: rgba(245, 243, 239, 0.5);
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.9;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/* ویرایشگر: کپشن ورودی و دکمه حذف زیر تصویر */
|
||||||
|
[data-wysiwyg] .article-figure-node {
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
[data-wysiwyg] .article-figure__caption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
/* تراز از جهت بلوک پیروی میکند — در مقاله انگلیسی چپ و در فارسی راست */
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
[data-wysiwyg] .article-figure-node.is-selected .article-figure {
|
||||||
|
outline: 1px dashed rgba(213, 135, 76, 0.8);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
.article-body strong {
|
.article-body strong {
|
||||||
color: #f5f3ef;
|
color: #f5f3ef;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -195,3 +314,78 @@ html[lang='en'] .article-body h3 {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- تقویم شمسی (react-multi-date-picker) — تم تیره با لهجه مسی ---------- */
|
||||||
|
.aramesh-datepicker.rmdp-wrapper {
|
||||||
|
width: max-content;
|
||||||
|
border: 1px solid rgba(245, 243, 239, 0.12);
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: #1e1a16;
|
||||||
|
color: #f5f3ef;
|
||||||
|
box-shadow: 0 24px 50px -18px rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-calendar {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-header-values {
|
||||||
|
color: #d5874c;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-arrow {
|
||||||
|
border-color: #d5874c;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-arrow-container {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-arrow-container:hover {
|
||||||
|
background-color: rgba(213, 135, 76, 0.2);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-week-day {
|
||||||
|
color: rgba(245, 243, 239, 0.45);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-day {
|
||||||
|
color: rgba(245, 243, 239, 0.85);
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-day span {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-day:hover:not(.rmdp-disabled):not(.rmdp-selected) span {
|
||||||
|
background-color: rgba(213, 135, 76, 0.16);
|
||||||
|
color: #d5874c;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-day.rmdp-selected span:not(.highlight) {
|
||||||
|
background-color: #d5874c;
|
||||||
|
color: #1e1a16;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-day.rmdp-today span {
|
||||||
|
background-color: rgba(213, 135, 76, 0.25);
|
||||||
|
color: #f5f3ef;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-day.rmdp-deactive {
|
||||||
|
color: rgba(245, 243, 239, 0.22);
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-day.rmdp-disabled {
|
||||||
|
color: rgba(245, 243, 239, 0.12);
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-month-picker,
|
||||||
|
.aramesh-datepicker .rmdp-year-picker {
|
||||||
|
background-color: #1e1a16;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-panel {
|
||||||
|
background-color: #1e1a16;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-panel-body li {
|
||||||
|
background-color: rgba(213, 135, 76, 0.15);
|
||||||
|
color: #f5f3ef;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.aramesh-datepicker .rmdp-action-button {
|
||||||
|
color: #d5874c;
|
||||||
|
}
|
||||||
@@ -1,33 +1,42 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react';
|
import { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
import { renderToHTMLString } from '@tiptap/static-renderer';
|
import { renderToHTMLString } from '@tiptap/static-renderer';
|
||||||
import { Reveal } from '../components/Reveal';
|
import { Reveal } from '../components/Reveal';
|
||||||
import { ButtonLink } from '../components/Button';
|
import { ButtonLink } from '../components/Button';
|
||||||
import { ArticleCard } from '../components/ArticleCard';
|
import { ArticleCard } from '../components/ArticleCard';
|
||||||
|
import { ArticleMeta } from '../components/ArticleMeta';
|
||||||
|
import { ArticleFaq } from '../components/ArticleFaq';
|
||||||
|
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
||||||
import { Newsletter } from '../components/home/Newsletter';
|
import { Newsletter } from '../components/home/Newsletter';
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
import { applySeo } from '../seo';
|
import { applySeo } from '../seo';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||||
|
import { docToText } from '../utils/tiptapDoc';
|
||||||
|
import { detectDirection } from '../utils/direction';
|
||||||
|
import { wrapScriptRuns } from '../utils/bidiText';
|
||||||
import type { Article } from '../data/site';
|
import type { Article } from '../data/site';
|
||||||
|
|
||||||
/** بدنه مقاله را به HTML تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
||||||
function renderBody(article: Article): { html: string | null; legacy: string[] } {
|
function renderBody(
|
||||||
|
article: Article,
|
||||||
|
dir: 'rtl' | 'ltr',
|
||||||
|
): { html: string | null; toc: TocItem[]; legacy: string[] } {
|
||||||
const body = article.body as unknown;
|
const body = article.body as unknown;
|
||||||
if (Array.isArray(body)) return { html: null, legacy: body };
|
if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
|
||||||
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
|
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
|
||||||
try {
|
try {
|
||||||
return {
|
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never });
|
||||||
html: renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }),
|
// تیترها شناسه لنگر میگیرند و متن لاتین/فارسی برای قاعده «۲ پیکسل کوچکتر» بستهبندی میشود
|
||||||
legacy: [],
|
const { items, html: htmlWithIds } = extractToc(html, (root) => wrapScriptRuns(root, dir));
|
||||||
};
|
return { html: htmlWithIds, toc: items, legacy: [] };
|
||||||
} catch {
|
} catch {
|
||||||
return { html: null, legacy: [] };
|
return { html: null, toc: [], legacy: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { html: null, legacy: [] };
|
return { html: null, toc: [], legacy: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArticleDetail() {
|
export function ArticleDetail() {
|
||||||
@@ -39,24 +48,44 @@ export function ArticleDetail() {
|
|||||||
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!article) return;
|
if (!article) return;
|
||||||
|
let jsonLd: Record<string, unknown> | Record<string, unknown>[] = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: article.title,
|
||||||
|
description: article.meta_description || article.excerpt,
|
||||||
|
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
||||||
|
inLanguage: lang,
|
||||||
|
author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
|
||||||
|
publisher: { '@type': 'Organization', name: brand.name },
|
||||||
|
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
|
||||||
|
};
|
||||||
|
// پرسشوپاسخ مقاله → اسکیمای FAQPage در همان بلوک (آرایهای از دو اسکیما)
|
||||||
|
const articleFaqs = article.faqs ?? [];
|
||||||
|
if (articleFaqs.length) {
|
||||||
|
jsonLd = [
|
||||||
|
jsonLd,
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: articleFaqs
|
||||||
|
.filter((f) => f.question.trim())
|
||||||
|
.map((f) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: f.question,
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: f.answer },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${article.title} | ${brand.name}`,
|
title: `${article.title} | ${brand.name}`,
|
||||||
description: article.excerpt,
|
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
||||||
|
description: article.meta_description || article.excerpt,
|
||||||
path: href(`/blog/${article.slug}`),
|
path: href(`/blog/${article.slug}`),
|
||||||
image: article.image,
|
image: article.image,
|
||||||
type: 'article',
|
type: 'article',
|
||||||
lang,
|
lang,
|
||||||
jsonLd: {
|
jsonLd,
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'Article',
|
|
||||||
headline: article.title,
|
|
||||||
description: article.excerpt,
|
|
||||||
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
|
||||||
inLanguage: lang,
|
|
||||||
author: { '@type': 'Organization', name: brand },
|
|
||||||
publisher: { '@type': 'Organization', name: brand },
|
|
||||||
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}, [article?.id, lang, href]);
|
}, [article?.id, lang, href]);
|
||||||
|
|
||||||
@@ -73,13 +102,29 @@ export function ArticleDetail() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { html, legacy } = renderBody(article);
|
const dir = detectDirection(article.title, article.excerpt, docToText(article.body));
|
||||||
|
const { html, toc, legacy } = renderBody(article, dir);
|
||||||
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
|
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جهت غالب متن مقاله — بدنه (با data-dir، طبق قاعده CSS مقاله) و سمت فهرست مطالب
|
||||||
|
* از همین پیروی میکنند: فارسی راستبهچپ (فهرست سمت راست)، انگلیسی چپبهراست (فهرست سمت چپ).
|
||||||
|
*/
|
||||||
|
const hasToc = toc.length > 0;
|
||||||
|
// در چیدمان راستبهچپ ستون شماره ۱ سمت راست است؛ پس ستونها صریح شماره میخورند
|
||||||
|
const gridColumns = hasToc ? 'lg:grid-cols-[17rem_minmax(0,1fr)]' : '';
|
||||||
|
const bodyColumns = 'lg:col-start-2';
|
||||||
|
const tocColumns = 'lg:col-start-1';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
|
<section dir={dir} className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
|
||||||
<img src={article.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
<img
|
||||||
|
src={article.image}
|
||||||
|
alt={article.image_alt || article.title}
|
||||||
|
title={article.image_title || undefined}
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
|
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
@@ -91,47 +136,69 @@ export function ArticleDetail() {
|
|||||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||||
{t('home')}
|
{t('home')}
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
||||||
<Link to={href('/blog')} className="transition-colors hover:text-copper">
|
<Link to={href('/blog')} className="transition-colors hover:text-copper">
|
||||||
{t('blog')}
|
{t('blog')}
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
||||||
<span className="text-copper">{article.category}</span>
|
<span className="text-copper">{article.category}</span>
|
||||||
</nav>
|
</nav>
|
||||||
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
|
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
|
||||||
<div className="mt-6 flex items-center gap-5 text-xs font-light text-ivory/60">
|
|
||||||
<span>{article.date}</span>
|
{/* نویسنده، تاریخ جلالی و زمان مطالعه */}
|
||||||
<span className="flex items-center gap-1.5">
|
<ArticleMeta article={article} className="mt-7" />
|
||||||
<ClockIcon className="h-3.5 w-3.5" />
|
|
||||||
{article.read}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<article className="bg-ink py-20 lg:py-28">
|
<article className="bg-ink py-20 lg:py-28">
|
||||||
<div className="mx-auto max-w-3xl px-6">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<Reveal>
|
<div
|
||||||
<p className="border-copper ltr:border-l-2 ltr:pl-6 rtl:border-r-2 rtl:pr-6 text-lg font-light leading-10 text-ivory/80">
|
dir={dir}
|
||||||
{article.excerpt}
|
data-dir={dir}
|
||||||
</p>
|
className={`mx-auto grid max-w-5xl gap-12 lg:gap-14 ${gridColumns}`}
|
||||||
</Reveal>
|
>
|
||||||
{html ? (
|
<div className={`min-w-0 lg:row-start-1 ${hasToc ? bodyColumns : ''}`}>
|
||||||
<Reveal delay={0.05}>
|
<Reveal>
|
||||||
<div
|
<p className="border-s-2 border-copper ps-6 text-lg font-light leading-10 text-ivory/80">
|
||||||
className="article-body mt-4 text-base font-light text-ivory/70"
|
{article.excerpt}
|
||||||
dangerouslySetInnerHTML={{ __html: html }} />
|
</p>
|
||||||
</Reveal>
|
|
||||||
) : (
|
|
||||||
legacy.map((paragraph, i) =>
|
|
||||||
<Reveal key={i} delay={0.03 * i}>
|
|
||||||
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)
|
{html ? (
|
||||||
)}
|
<Reveal delay={0.05}>
|
||||||
|
<div
|
||||||
|
className="article-body mt-4 text-base font-light text-ivory/70"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
</Reveal>
|
||||||
|
) : (
|
||||||
|
legacy.map((paragraph, i) =>
|
||||||
|
<Reveal key={i} delay={0.03 * i}>
|
||||||
|
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* فهرست مطالب — در دسکتاپ کنار متن میچسبد و همراه مقاله پایین میآید */}
|
||||||
|
{hasToc && (
|
||||||
|
<aside
|
||||||
|
dir={dir}
|
||||||
|
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
|
||||||
|
>
|
||||||
|
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{/* پرسشوپاسخهای پایان مقاله — همان ظاهر آکاردئونی FAQ سایت */}
|
||||||
|
<div className="bg-ink pb-4">
|
||||||
|
<div className="mx-auto max-w-5xl px-6 lg:px-10">
|
||||||
|
<h2 className="text-section font-bold text-ivory">{t('fallbackFaqTitle')}</h2>
|
||||||
|
<ArticleFaq faqs={article.faqs ?? []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
|
<section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
|
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useSite } from '../api'
|
|||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
import { EditableItem } from '../admin/Editable'
|
import { EditableItem } from '../admin/Editable'
|
||||||
import { useAdmin } from '../admin/AdminContext'
|
import { useAdmin } from '../admin/AdminContext'
|
||||||
|
import { formatJalali } from '../utils/date'
|
||||||
|
import { toPersianDigits } from '../utils/digits'
|
||||||
import { PlusIcon } from 'lucide-react'
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
function DraftBadge({ className = '' }: { className?: string }) {
|
function DraftBadge({ className = '' }: { className?: string }) {
|
||||||
@@ -94,10 +96,10 @@ export function Blog() {
|
|||||||
<p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p>
|
<p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p>
|
||||||
<div className="mt-10 flex items-center justify-between border-t border-ivory/10 pt-8 text-xs font-light text-ivory/50">
|
<div className="mt-10 flex items-center justify-between border-t border-ivory/10 pt-8 text-xs font-light text-ivory/50">
|
||||||
<span className="flex items-center gap-4">
|
<span className="flex items-center gap-4">
|
||||||
<span>{featured.date}</span>
|
<span>{formatJalali(featured.date)}</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<ClockIcon className="h-3.5 w-3.5" />
|
<ClockIcon className="h-3.5 w-3.5" />
|
||||||
{featured.read}
|
{toPersianDigits(featured.read)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2 text-copper">
|
<span className="flex items-center gap-2 text-copper">
|
||||||
|
|||||||
+4
-3
@@ -72,10 +72,10 @@ function upsertAlternates(path: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setJsonLd(data: Record<string, unknown> | null): void {
|
function setJsonLd(data: Record<string, unknown> | Record<string, unknown>[] | null): void {
|
||||||
const id = 'seo-jsonld'
|
const id = 'seo-jsonld'
|
||||||
let el = document.getElementById(id)
|
let el = document.getElementById(id)
|
||||||
if (!data) {
|
if (!data || (Array.isArray(data) && data.length === 0)) {
|
||||||
el?.remove()
|
el?.remove()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,8 @@ export type SeoInput = {
|
|||||||
type?: 'website' | 'article'
|
type?: 'website' | 'article'
|
||||||
/** زبان جاری — برای og:locale و لینکهای hreflang */
|
/** زبان جاری — برای og:locale و لینکهای hreflang */
|
||||||
lang?: Lang
|
lang?: Lang
|
||||||
jsonLd?: Record<string, unknown> | null
|
/** اسکیمای JSON-LD — یک شیء یا آرایهای از چند اسکیما (مثل Article + FAQPage) */
|
||||||
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** تنظیم همه تگهای سئوی صفحه در یک فراخوانی */
|
/** تنظیم همه تگهای سئوی صفحه در یک فراخوانی */
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* دوزبانهسازی خودکار متن مقاله — قاعده: متن لاتین همیشه «۲ پیکسل کوچکتر» از متن
|
||||||
|
* فارسیِ همان بلوک است (تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲) و در
|
||||||
|
* مقاله انگلیسی برعکس، واژههای فارسی ۲ پیکسل بزرگتر میشوند.
|
||||||
|
*
|
||||||
|
* اجرای غیرغالب متن با یک span جهتدار جدا میشود تا نه جهت متن بههم بریزد
|
||||||
|
* (unicode-bidi: isolate) و نه خط پایه جابهجا شود؛ اندازه در CSS با
|
||||||
|
* calc(1em - 2px) نسبت به همان بلوک حساب میشود.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** اجرای متن لاتین — حروف/ارقام لاتین بههمراه فاصله و نشانههای داخلی */
|
||||||
|
const LATIN_PATTERN =
|
||||||
|
"[A-Za-z0-9\\u00c0-\\u024f]+(?:[ \\t.,:;'\"()&/+%\\u2013\\u2014-]+[A-Za-z0-9\\u00c0-\\u024f]+)*"
|
||||||
|
|
||||||
|
/** اجرای متن فارسی/عربی — با نیمفاصله و ارقام فارسی */
|
||||||
|
const RTL_PATTERN = '[\\u0590-\\u08ff\\u200c]+(?:[ \\t\\u200c]+[\\u0590-\\u08ff\\u200c]+)*'
|
||||||
|
|
||||||
|
/** کلاس span متن لاتین و متن فارسی — همینها در CSS اندازه ۲ پیکسل تفاضلی میگیرند */
|
||||||
|
export const LATIN_CLASS = 'latin'
|
||||||
|
export const RTL_CLASS = 'persian'
|
||||||
|
|
||||||
|
/** عناصری که متنشان دستنخورده میماند (کد، مارکهای قدیمی و اجرای بستهبندیشده) */
|
||||||
|
const SKIP_SELECTOR = 'code, pre, kbd, samp, script, style, .english-text, [data-small], .latin, .persian'
|
||||||
|
|
||||||
|
/** همه اجراهای غیرغالب یک متن — با بازه نسبت به ابتدای همان متن */
|
||||||
|
export function findScriptRuns(text: string, dir: 'rtl' | 'ltr'): { from: number; to: number }[] {
|
||||||
|
const scan = new RegExp(dir === 'rtl' ? LATIN_PATTERN : RTL_PATTERN, 'g')
|
||||||
|
const runs: { from: number; to: number }[] = []
|
||||||
|
|
||||||
|
for (const match of text.matchAll(scan)) {
|
||||||
|
const from = match.index ?? 0
|
||||||
|
runs.push({ from, to: from + match[0].length })
|
||||||
|
}
|
||||||
|
|
||||||
|
return runs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بستهبندی اجرای غیرغالب همه گرههای متنی داخل یک ریشه HTML.
|
||||||
|
* در مقاله فارسی اجرای لاتین و در مقاله انگلیسی اجرای فارسی جدا میشود.
|
||||||
|
*/
|
||||||
|
export function wrapScriptRuns(root: HTMLElement, dir: 'rtl' | 'ltr'): void {
|
||||||
|
const document = root.ownerDocument
|
||||||
|
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
|
||||||
|
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
|
||||||
|
|
||||||
|
const texts: Text[] = []
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||||
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||||
|
const text = node as Text
|
||||||
|
if (!text.parentElement || text.parentElement.closest(SKIP_SELECTOR)) continue
|
||||||
|
if (!text.data.trim()) continue
|
||||||
|
if (findScriptRuns(text.data, dir).length) texts.push(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const text of texts) {
|
||||||
|
const value = text.data
|
||||||
|
const fragment = document.createDocumentFragment()
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
for (const run of findScriptRuns(value, dir)) {
|
||||||
|
if (run.from > cursor) fragment.append(value.slice(cursor, run.from))
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.className = className
|
||||||
|
span.setAttribute('dir', runDir)
|
||||||
|
span.textContent = value.slice(run.from, run.to)
|
||||||
|
fragment.append(span)
|
||||||
|
cursor = run.to
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < value.length) fragment.append(value.slice(cursor))
|
||||||
|
text.replaceWith(fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import DateObject from 'react-date-object'
|
||||||
|
import gregorian from 'react-date-object/calendars/gregorian'
|
||||||
|
import persian from 'react-date-object/calendars/persian'
|
||||||
|
import persian_fa from 'react-date-object/locales/persian_fa'
|
||||||
|
|
||||||
|
/** قالب رسمی نمایش تاریخ جلالی — تقویم شمسی و اعداد فارسی */
|
||||||
|
export const JALALI_DATE_FORMATTER = new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
calendar: 'persian',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** ساعت به وقت جلالی (فارسی) */
|
||||||
|
export const JALALI_TIME_FORMATTER = new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T\s].*)?$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نمایش هر مقدار تاریخ به شکل جلالی (مثل «۲۲ شهریور ۱۴۰۵»).
|
||||||
|
* رشتههایی که تاریخ نیستند (مثل تاریخ شمسی دستنویس) دستنخورده برمیگردند.
|
||||||
|
*/
|
||||||
|
export function formatJalali(value?: string | number | Date | null, fallback = ''): string {
|
||||||
|
if (value === null || value === undefined || value === '') return fallback
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return Number.isNaN(value.getTime()) ? fallback : JALALI_DATE_FORMATTER.format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
const fromNumber = new Date(value)
|
||||||
|
return Number.isNaN(fromNumber.getTime()) ? fallback : JALALI_DATE_FORMATTER.format(fromNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = String(value).trim()
|
||||||
|
if (!raw) return fallback
|
||||||
|
|
||||||
|
if (ISO_DATE.test(raw)) {
|
||||||
|
const parsed = new Date(raw)
|
||||||
|
if (!Number.isNaN(parsed.getTime())) return JALALI_DATE_FORMATTER.format(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
/** رشته ISO میلادی ذخیرهشده (YYYY-MM-DD) → DateObject شمسی برای انتخابگر تاریخ */
|
||||||
|
export function toJalaliDateObject(value?: string | null): DateObject | null {
|
||||||
|
const raw = (value ?? '').trim()
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
if (ISO_DATE.test(raw)) {
|
||||||
|
const parsed = new DateObject({ date: raw })
|
||||||
|
if (parsed.isValid) return parsed.convert(persian, persian_fa)
|
||||||
|
}
|
||||||
|
|
||||||
|
// تاریخهای شمسی دستنویس مثل ۱۴۰۳/۰۵/۱۲
|
||||||
|
const manual = new DateObject({ date: raw, calendar: persian, locale: persian_fa })
|
||||||
|
return manual.isValid ? manual : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** انتخاب انتخابگر تاریخ → رشته ISO میلادی برای ذخیره در دیتابیس */
|
||||||
|
export function toISODate(value?: DateObject | Date | null): string {
|
||||||
|
if (!value) return ''
|
||||||
|
const asObject = value instanceof Date ? new DateObject({ date: value }) : value
|
||||||
|
const gregorianDate = asObject.convert(gregorian)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${gregorianDate.year}-${pad(gregorianDate.month.number)}-${pad(gregorianDate.day)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
|
||||||
|
|
||||||
|
/** تبدیل ارقام لاتین به ارقام فارسی — برای نمایش یکدست فارسی */
|
||||||
|
export function toPersianDigits(value: string | number): string {
|
||||||
|
return String(value).replace(/[0-9]/g, (digit) => PERSIAN_DIGITS[Number(digit)])
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* تشخیص جهت غالب متن — پایه چیدمان مقاله و فهرست مطالب.
|
||||||
|
* سایت فارسی است؛ اما مقالهای که متنش لاتین است باید چپبهراست خوانده شود.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** خطهای راستبهچپ: عبری، عربی و فارسی */
|
||||||
|
const RTL_CHARS = /[\u0590-\u05ff\u0600-\u06ff\u0700-\u074f\ufb50-\ufdff\ufe70-\ufeff]/
|
||||||
|
/** ارقام فارسی/عربی — در شمارش جهت بیطرفاند */
|
||||||
|
const RTL_DIGITS = /[\u0660-\u0669\u06f0-\u06f9]/
|
||||||
|
/** حروف لاتین (با علامتها) */
|
||||||
|
const LTR_CHARS = /[A-Za-z\u00c0-\u024f]/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جهت غالب نمونههای متن: فارسی/عربی → rtl و لاتین → ltr.
|
||||||
|
* در حالت مساوی (مثلاً متن کوتاه یا فقط رقم) پیشفرض سایت، راستبهچپ، برمیگردد.
|
||||||
|
*/
|
||||||
|
export function detectDirection(...samples: (string | null | undefined)[]): 'rtl' | 'ltr' {
|
||||||
|
let rtl = 0
|
||||||
|
let ltr = 0
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
for (const char of sample ?? '') {
|
||||||
|
if (RTL_DIGITS.test(char)) continue
|
||||||
|
if (RTL_CHARS.test(char)) rtl += 1
|
||||||
|
else if (LTR_CHARS.test(char)) ltr += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtl >= ltr ? 'rtl' : 'ltr'
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/** پروتکلها و الگوهای مجاز برای نشانی پیوند — ویرایشگر و مودال لینک هر دو از اینها استفاده میکنند */
|
||||||
|
const ALLOWED_URI = /^(https?:\/\/|\/|#|mailto:|tel:)/i
|
||||||
|
const BARE_DOMAIN = /^[\w-]+(\.[\w-]+)+([/?#].*)?$/i
|
||||||
|
/** نشانی کامل بدون فاصله — برای تشخیص متن چسباندهشده یا تایپشده */
|
||||||
|
const URL_LIKE = /^(https?:\/\/\S+|www\.\S+|[a-z0-9-]+(\.[a-z0-9-]+)+(\/\S*)?)$/i
|
||||||
|
|
||||||
|
/** آیا متن یک نشانی کامل است؟ (برای بازکردن خودکار مودال لینک) */
|
||||||
|
export function isUrlLike(value: string): boolean {
|
||||||
|
return URL_LIKE.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** آیا نشانی برای درج در لینک مجاز است؟ */
|
||||||
|
export function isAllowedHref(uri: string): boolean {
|
||||||
|
return ALLOWED_URI.test(uri.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نرمالسازی نشانی ورودی کاربر:
|
||||||
|
* «example.com» → «https://example.com» و ورودی خالی/نامعتبر → null
|
||||||
|
*/
|
||||||
|
export function normalizeHref(raw: string): string | null {
|
||||||
|
const value = raw.trim()
|
||||||
|
if (!value) return null
|
||||||
|
if (isAllowedHref(value)) return value
|
||||||
|
if (BARE_DOMAIN.test(value)) return `https://${value}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LinkOptions = {
|
||||||
|
/** باز شدن در تب جدید */
|
||||||
|
newTab: boolean
|
||||||
|
/** عدم انتقال اعتبار سئو (nofollow) */
|
||||||
|
nofollow: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ساخت مقدار rel بر اساس گزینههای انتخابی کاربر */
|
||||||
|
export function buildLinkRel({ newTab, nofollow }: LinkOptions): string | null {
|
||||||
|
const tokens: string[] = []
|
||||||
|
if (newTab) tokens.push('noopener', 'noreferrer')
|
||||||
|
if (nofollow) tokens.push('nofollow')
|
||||||
|
return tokens.length ? tokens.join(' ') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** خواندن گزینه nofollow از مقدار rel لینک موجود */
|
||||||
|
export function parseLinkRel(rel?: string | null): LinkOptions {
|
||||||
|
const tokens = (rel ?? '').split(/\s+/).filter(Boolean)
|
||||||
|
return { newTab: tokens.includes('noopener') || tokens.includes('noreferrer'), nofollow: tokens.includes('nofollow') }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { READING_TIME_LABEL, WORDS_PER_MINUTE } from '../data/constants'
|
||||||
|
import { toPersianDigits } from './digits'
|
||||||
|
import { docToText } from './tiptapDoc'
|
||||||
|
|
||||||
|
export { WORDS_PER_MINUTE, READING_TIME_LABEL }
|
||||||
|
|
||||||
|
/** شمارش واژههای متن سند (بدون شمارش نویسههای قالببندی) */
|
||||||
|
export function countWords(body: unknown): number {
|
||||||
|
return docToText(body)
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** زمان مطالعه بر حسب دقیقه — سقف واژهها تقسیم بر سرعت مطالعه، حداقل یک دقیقه */
|
||||||
|
export function readingTimeMinutes(body: unknown): number {
|
||||||
|
return Math.max(1, Math.ceil(countWords(body) / WORDS_PER_MINUTE))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** زمان مطالعه آماده نمایش با ارقام فارسی — مثل «۵ دقیقه مطالعه» */
|
||||||
|
export function formatReadingTime(body: unknown): string {
|
||||||
|
return `${toPersianDigits(readingTimeMinutes(body))} ${READING_TIME_LABEL}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن برای فهرست مطالب.
|
||||||
|
* حروف و اعداد فارسی/عربی و لاتین در شناسه میمانند (در URL بهصورت percent-encoded
|
||||||
|
* نوشته میشوند و در همه مرورگرها کار میکنند)؛ فقط نشانهها و فاصلهها به خط تیره
|
||||||
|
* تبدیل میشوند. نیمفاصله و نویسههای نامرئی جهت هم حذف میشوند تا تیترهای تکراری
|
||||||
|
* شناسه یکتا بگیرند.
|
||||||
|
*/
|
||||||
|
export function slugifyFa(input: string): string {
|
||||||
|
return (input ?? '')
|
||||||
|
.toString()
|
||||||
|
.normalize('NFC')
|
||||||
|
// نیمفاصله، اتصالدهنده و نویسههای نامرئی جهت → حذف
|
||||||
|
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
|
||||||
|
// یکسانسازی حروف عربی با معادل فارسی
|
||||||
|
.replace(/[يى]/g, 'ی')
|
||||||
|
.replace(/ك/g, 'ک')
|
||||||
|
.replace(/[ۀة]/g, 'ه')
|
||||||
|
.toLowerCase()
|
||||||
|
// فقط حروف/اعداد یونیکد (فارسی، عربی، لاتین و...) و فاصله/خط تیره بماند
|
||||||
|
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-{2,}/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی میگیرند (مثل about، about-2) */
|
||||||
|
export function uniqueSlug(base: string, used: Map<string, number>): string {
|
||||||
|
const key = base || 'section'
|
||||||
|
const count = used.get(key) ?? 0
|
||||||
|
used.set(key, count + 1)
|
||||||
|
return count === 0 ? key : `${key}-${count + 1}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* کمکتابعهای مشترک سند TipTap — استخراج متن و ساختار از JSON مقاله.
|
||||||
|
* هم چکلیست سئو و هم محاسبه زمان مطالعه از همینجا استفاده میکنند (DRY).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ArticleBodyDoc } from '../data/site'
|
||||||
|
|
||||||
|
type DocNode = {
|
||||||
|
type?: string
|
||||||
|
text?: string
|
||||||
|
attrs?: Record<string, unknown>
|
||||||
|
marks?: { type?: string; attrs?: Record<string, unknown> }[]
|
||||||
|
content?: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(node: unknown, visit: (node: DocNode) => void): void {
|
||||||
|
if (!node || typeof node !== 'object') return
|
||||||
|
const current = node as DocNode
|
||||||
|
visit(current)
|
||||||
|
for (const child of current.content ?? []) walk(child, visit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** متن یک سند TipTap (یا آرایه پاراگرافهای قدیمی) */
|
||||||
|
export function docToText(body: unknown): string {
|
||||||
|
if (Array.isArray(body)) return body.join(' ')
|
||||||
|
const parts: string[] = []
|
||||||
|
walk(body, (node) => {
|
||||||
|
if (node.type === 'text' && node.text) parts.push(node.text)
|
||||||
|
})
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** متن تیترهای یک سطح مشخص (پیشفرض H2) */
|
||||||
|
export function docHeadings(body: unknown, level = 2): string[] {
|
||||||
|
if (Array.isArray(body)) return []
|
||||||
|
const headings: string[] = []
|
||||||
|
walk(body, (node) => {
|
||||||
|
if (node.type !== 'heading' || node.attrs?.level !== level) return
|
||||||
|
const texts: string[] = []
|
||||||
|
walk(node, (inner) => {
|
||||||
|
if (inner.type === 'text' && inner.text) texts.push(inner.text)
|
||||||
|
})
|
||||||
|
headings.push(texts.join(''))
|
||||||
|
})
|
||||||
|
return headings
|
||||||
|
}
|
||||||
|
|
||||||
|
/** تصاویر سند با متن جایگزین */
|
||||||
|
export function docImages(body: unknown): { src: string; alt: string }[] {
|
||||||
|
if (Array.isArray(body)) return []
|
||||||
|
const images: { src: string; alt: string }[] = []
|
||||||
|
walk(body, (node) => {
|
||||||
|
if (node.type !== 'image') return
|
||||||
|
images.push({ src: String(node.attrs?.src ?? ''), alt: String(node.attrs?.alt ?? '') })
|
||||||
|
})
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نشانی همه پیوندهای سند */
|
||||||
|
export function docLinks(body: unknown): string[] {
|
||||||
|
if (Array.isArray(body)) return []
|
||||||
|
const links: string[] = []
|
||||||
|
walk(body, (node) => {
|
||||||
|
for (const mark of node.marks ?? []) {
|
||||||
|
if (mark.type === 'link' && mark.attrs?.href) links.push(String(mark.attrs.href))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
/** متن نخستین پاراگراف — برای بررسی کلمه کلیدی در ابتدای متن */
|
||||||
|
export function firstParagraph(body: unknown): string {
|
||||||
|
if (Array.isArray(body)) return body[0] ?? ''
|
||||||
|
const content = (body as { content?: { type?: string }[] } | null)?.content
|
||||||
|
const paragraph = content?.find((child) => child?.type === 'paragraph')
|
||||||
|
return paragraph ? docToText({ type: 'doc', content: [paragraph] }) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as ArticleBodyDoc
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تبدیل بدنه ذخیرهشده مقاله به سند TipTap برای بارگذاری در ویرایشگر.
|
||||||
|
* ردیفهای قدیمی متن را بهصورت آرایه پاراگراف نگه داشتهاند و ممکن است رشته JSON هم
|
||||||
|
* برگردد؛ هر دو شکل به سند تبدیل میشوند تا هنگام ویرایش، متن قبلی از دست نرود.
|
||||||
|
*/
|
||||||
|
export function toArticleBodyDoc(body: unknown): ArticleBodyDoc {
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
const paragraphs = body
|
||||||
|
.map((paragraph) => String(paragraph ?? '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((text) => ({ type: 'paragraph', content: [{ type: 'text', text }] }))
|
||||||
|
return paragraphs.length ? { type: 'doc', content: paragraphs } : { ...EMPTY_DOC }
|
||||||
|
}
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
try {
|
||||||
|
return toArticleBodyDoc(JSON.parse(body))
|
||||||
|
} catch {
|
||||||
|
return { ...EMPTY_DOC }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body && typeof body === 'object') return body as ArticleBodyDoc
|
||||||
|
return { ...EMPTY_DOC }
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import react from '@vitejs/plugin-react'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
// در حالت توسعه، /api و /media به Traefik پروکسی میشوند (پورت ۵۱۷۳ روی هاست)
|
port: 5174, // سرور توسعه فرانتاند روی این پورت اجرا میشود تا تداخل نکند
|
||||||
|
strictPort: true, // جلوگیری از تغییر خودکار پورت
|
||||||
proxy: {
|
proxy: {
|
||||||
|
// در حالت توسعه، درخواستهای API و Media به Traefik (پورت ۵۱۷۳) فرستاده میشوند
|
||||||
'/api': 'http://localhost:5173',
|
'/api': 'http://localhost:5173',
|
||||||
'/media': 'http://localhost:5173',
|
'/media': 'http://localhost:5173',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user