12 Commits
Author SHA1 Message Date
Behrad 575e4868be feat(mobile): complete responsive design and layout optimizations across landing page 2026-10-04 23:48:19 +03:30
MMD 846682ac4a صفحه تماس: حذف بخش دفاتر مطابق تصمیم تیم UI
CI / tests (push) Canceled after 0s
CI / images (push) Canceled after 0s
بازگرداندن من اشتباه بود — تیم UI عمداً این بخش را برداشته/جابه‌جا کرده است.
جای‌نگهدار خودشان (section خالی با aria-label) حفظ شد و فقط importهای
بی‌استفاده حذف شدند تا تایپ‌چک تمیز بماند.
2026-09-26 23:30:18 +03:30
MMD f7105a62cc بازگرداندن بخش دفاتر در صفحه تماس
شاخه UI این بخش را به یک section خالی تبدیل کرده بود (کار ناتمام)؛
ادغام خودکار آن را بی‌صدا حذف می‌کرد. محتوای دفاتر (شهر/نشانی/ساعات)
با همان چیدمان و ویرایش‌پذیری مدیریتی برگردانده شد.
2026-09-26 23:09:13 +03:30
MMD 0628344dcf Merge remote-tracking branch 'origin/feature/ui-updates-v1' into new-one 2026-09-26 23:04:15 +03:30
MMD d25db9367c پیش‌نمایش خصوصی: سایت آنلاین اما غیرعمومی تا زمان انتشار
- گیت ورود Traefik (Basic Auth) روی همه مسیرها: سایت، API، تصاویر و چت — بدون رمز فقط ۴۰۱
- لایه دوم noindex: هدر X-Robots-Tag روی همه پاسخ‌ها + robots.txt مسدودکننده + نقشه سایت خالی
- اسکریپت scripts/preview-mode.sh on|off|status — ساخت خودکار نام کاربری/رمز و هماهنگ‌کردن .env
- دو دامنه (DOMAIN + DOMAIN_ALIAS) با گواهی TLS و ریدایرکت ۳۰۱ دامنه دوم به اصلی در زمان انتشار
- اسکیمای FAQPage در پوسته سئوی سمت سرور (فارسی و انگلیسی) برای خزنده‌های بدون جاوااسکریپت
- مستندسازی جریان پیش‌نمایش و چک‌لیست انتشار در README و .env.example
2026-09-26 22:47:04 +03:30
MMD 1d29e50852 وبلاگ: نام‌گذاری و متادیتای تصاویر، پرسش‌وپاسخ مقاله، میزبانی تصاویر چسبانده‌شده
- Alt/Title تصویر شاخص اکنون ذخیره می‌شود (ستون‌های جدید image_alt/image_title — قبلاً بی‌صدا دور ریخته می‌شد)
- Alt و Title تصاویر داخل متن پس از درج هم قابل ویرایش‌اند (کنار کپشن)
- نام فایل ذخیره‌سازی خوانا: نام دلخواه هنگام درج یا نام خود فایل (به‌جای UUID خالی)
- پنل پرسش‌وپاسخ پایان مقاله (ستون‌های faqs/faqs_en) با ظاهر آکاردئونی سایت و اسکیمای FAQPage
- چسباندن از Outline و هر منبع خارجی: تصاویر خودکار دانلود و در Object Storage سایت میزبانی می‌شوند (endpoint جدید import-image با محافظ SSRF) + دکمه میزبانی مجدد روی تصاویر خارجی موجود
2026-09-26 22:46:56 +03:30
fatemeh sane 853ad67db5 feat(ui): update UI v1 2026-09-25 22:49:30 +03:30
MMD 851e3fc2dc fix(compose): umami healthcheck via 127.0.0.1 — localhost resolves to ::1 and umami listens on IPv4 only 2026-09-19 01:24:15 +03:30
MMD 74770cd99d fix(stack): bring-up fixes from first local deployment
- traefik pin v3.3 -> v3.6: v3.3's old docker client fails against
  Engine 29 socket proxy (empty daemon errors, no routers)
- backend/minio/chatbot/umami: traefik.docker.network hint — Traefik
  picked the internal-network IP for multi-network services and replies
  were hairpin-dropped (all backend routes hung)
- db-init: psql needs an explicit -d arameshplaza (no default db 'aramesh'),
  set -e so failures actually fail; one-shot folded-YAML needs semicolons
- chatbot image: drop g++ (apt unreachable in build network; all wheels
  prebuilt) and mkdir /data before chown (VOLUME does not create it)
- frontend image: create caddy user (alpine image has none) with writable
  /config and /data
- monitoring: blackbox probes replace raw metric scrapes of JSON/HTML
  health endpoints (they reported 'down' while healthy); blackbox joins
  edge; SITE_URL passed via env (Prometheus has no ${VAR:-default}
  expansion); ContainerMemoryNearLimit PromQL fixed (comparison inside
  label braces); HostDiskAlmostFull filters WSL/Docker-Desktop
  pseudo-mounts
- alertmanager: config rendered by stack.sh (no env expansion in AM) and
  delivered to Bale (tapi.bale.ai); stack.sh monitoring command
- machine-local override: minio console/API moved to 29001/29000
2026-09-19 01:22:02 +03:30
MMD ce0735d775 feat(stack): full overhaul — hardening, ops tooling, monitoring, analytics, chatbot
Security: deep /api/health (DB+MinIO probes, 503 on failure) + /api/live;
rate-limited public forms with honeypots; real client IP behind Traefik
(--proxy-headers); TipTap URL sanitization on write (nh3); non-root
containers + slim .dockerignore; prod MinIO console bound to 127.0.0.1;
HSTS/nosniff on TLS routers; JWT 8h; no default credentials in code.

Reliability: Alembic migrations (create_all baseline); graceful shutdown
(exec uvicorn); hardened backups (checksums, sanity check, freshness gauge,
optional rclone off-site) + restore.sh + hourly db-backup sidecar (ops
profile); stack.sh ops tooling (deploy/logs/health/backup/restore/monitoring);
.gitattributes LF for shell scripts.

Orchestration: json-file log rotation, pinned images, per-service memory
limits, edge/internal networks, Traefik ping/accesslog/metrics + flood
limiter, conditional minio-setup, pgvector db image, one-shot db-init
(umami + aramesh_chatbot databases).

Chatbot: unified from site-chatbot branch (RAG + pgvector, SSE, ChatWidget).

Monitoring: Prometheus/Alertmanager/Loki/Promtail/Grafana + cAdvisor,
node/postgres/blackbox exporters, provisioned dashboards and 10 alert
rules; alerts delivered to Bale (tapi.bale.ai — Telegram-compatible).

Analytics: Umami on shared Postgres, build-time tracker injection, custom
events (contact/newsletter/phone/chat/lang) via frontend/src/analytics.ts.

Hygiene: CSV export for contact messages and subscribers; upload image
optimization (Pillow); CI script + GitHub-compatible workflow; audit.sh;
gen-env.sh for strong credentials.
2026-09-19 00:24:57 +03:30
MMD 805f4594d4 Merge remote-tracking branch 'origin/dashboard/blog-changes'
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/pages/ArticleDetail.tsx
2026-09-17 12:31:04 +03:30
fatemeh sane 7a0defc3d4 fix(admin): resolve Tiptap editor bugs, dynamic RTL/LTR layout, sticky TOC, and equalize Bidi typography 2026-09-17 10:10:50 +03:30
176 changed files with 17051 additions and 1068 deletions
+48
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
# اسکریپت‌های شل باید در لینوکس هم LF باشند — CRLF در bash می‌شکند
*.sh text eol=lf
*.yml text
*.yaml text
*.json text
Dockerfile text
*.py text
+47
View File
@@ -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
+7
View File
@@ -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
+195 -67
View File
@@ -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)
│ ├── migrations/ # مهاجرت‌های نسخه‌دار Alembic
│ ├── tests/ # تست‌های API (pytest) │ ├── tests/ # تست‌های API (pytest)
│ ├── requirements.txt │ ├── alembic.ini
│ ├── Dockerfile │ └── Dockerfile # بدون root — خاموشی تمیز با exec
│ └── .env.example ├── chatbot/ # دستیار هوشمند (RAG + pgvector)
├── scripts/backup.sh # پشتیبان‌گیری دیتابیس و تصاویر ├── monitoring/ # پیکربندی Prometheus/Alertmanager/Loki/Promtail/Grafana
├── docker-compose.yml # اجرای کامل با یک دستور ├── scripts/
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt │ ├── 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 تزریق‌شده سئو هم افشا نمی‌شوند
+13
View File
@@ -0,0 +1,13 @@
# کانتکست بیلد باریک — .venv و کش‌های تست محلی جایی در تصویر ندارند
.venv/
venv/
tests/
.pytest_cache/
__pycache__/
**/__pycache__/
*.pyc
README.md
.env
.env.*
Dockerfile
.dockerignore
+12 -3
View File
@@ -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='*'"]
+40
View File
@@ -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
+197 -15
View File
@@ -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:
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: try:
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type) data, content_type, width, height = _optimize_image(data, content_type)
except Exception: except Exception:
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود") # تصویر خراب/ناشناخته — همان داده اصلی ذخیره می‌شود
pass
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
url = _store_image(data, content_type, display_name)
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)):
+40 -5
View File
@@ -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()
+42 -2
View File
@@ -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)
+6 -1
View File
@@ -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()
+11
View File
@@ -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
View File
@@ -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():
+11
View File
@@ -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":
+7
View File
@@ -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")
# کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود # کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود
+65 -14
View File
@@ -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
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 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="تعداد درخواست‌ها زیاد است — کمی بعد دوباره امتحان کنید",
)
+58
View File
@@ -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
View File
@@ -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)
+45 -2
View File
@@ -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}",
+49
View File
@@ -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()
+25
View File
@@ -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
+8
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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", "")
+12
View File
@@ -0,0 +1,12 @@
# کانتکست بیلد باریک
__pycache__/
**/__pycache__/
*.pyc
.venv/
venv/
.env
.env.*
README.md
Dockerfile
.dockerignore
test_pure.py
+28
View File
@@ -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
+29
View File
@@ -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=*"]
+121
View File
@@ -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) است
+1
View File
@@ -0,0 +1 @@
"""سرویس چت‌بات سایت آرامش — بازیابی ترکیبی روی محتوای PostgreSQL."""
+284
View File
@@ -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)
+57
View File
@@ -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()
+315
View File
@@ -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 ""
+20
View File
@@ -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,
)
+55
View File
@@ -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
+79
View File
@@ -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
+191
View File
@@ -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)
+20
View File
@@ -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
+50
View File
@@ -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()
+165
View File
@@ -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]
+70
View File
@@ -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")
+211
View File
@@ -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]
+189
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+4
View File
@@ -0,0 +1,4 @@
# فقط Caddyfile و dist/ به کانتکست بیلد می‌روند — node_modules و سورس عظیم‌اند و لازم نیستند
*
!Caddyfile
!dist
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -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
View File
@@ -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
+26 -3
View File
@@ -3,9 +3,25 @@
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`). 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, and the UI is fully localized for Persian (Farsi) users. The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
## Recent UI/UX Improvements
* **Projects Map Restoration**: Successfully re-integrated and mounted the interactive project overview map directly at the top of the Projects page to display all project markers.
* **Project Details Update**: Revamped and updated the presentation of project details for better clarity and structure.
* **Stats Section Relocation**: Moved the statistics (metrics) section higher up in the page layout for better visibility and impact.
* **FAQ Section Redesign**: Refactored the FAQ layout into a centered vertical stack with full RTL/LTR responsiveness, featuring an integrated centered header block (title, description, and accent line) and direction-aware (`text-start`) accordion content.
* **Newsletter Section Polish**: Updated the newsletter container to a clean solid `bg-copper` background while introducing high-contrast button styling (`bg-ink` default transitioning to white on hover) preserving site-wide kinetic animation logic.
* **Article Cards Spacing**: Refined vertical layout spacing and responsive gaps between title, excerpt, and footer blocks without impacting typography scales.
* **Tiptap Editor Fixes**:
* Resolved the initialization bug where the editor body remained empty during edit mode.
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
* **Dynamic TOC & Layout**:
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
## CMS, SEO & Editor Features ## 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: The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
@@ -15,8 +31,8 @@ The project includes a robust, custom-built Digital Twin (Admin Panel) with adva
* **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. * **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. * **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:** * **Custom Tiptap WYSIWYG Editor:**
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) with specific font scaling (RTL Base: 14px, LTR Base: 12px) mapped to a fixed toolbar that maintains layout stability. * **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 (separate 'X' buttons to delete either the image entirely or just the caption text). * **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`). * **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:** * **Full Jalali (Shamsi) Calendar Integration:**
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience. * Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
@@ -51,3 +67,10 @@ npm run build
* `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.
```
حالا اگر بخواهید این تغییرات جدید را هم در پیام کامیت خود بیاورید، می‌توانید از این کامیت یک‌خطی استفاده کنید:
```bash
git commit -m "docs: update README with project details revamp, stats section relocation, FAQ layout, and map restoration"
+10
View File
@@ -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>
+254
View File
@@ -17,9 +17,12 @@
"@tiptap/static-renderer": "^3.31.3", "@tiptap/static-renderer": "^3.31.3",
"framer-motion": "^11.5.4", "framer-motion": "^11.5.4",
"lucide-react": "0.522.0", "lucide-react": "0.522.0",
"maplibre-gl": "^6.10.0",
"pmtiles": "^4.5.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-hook-form": "^7.88.0",
"react-icons": "^5.7.0",
"react-multi-date-picker": "^4.5.2", "react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
"react-select": "^5.10.2", "react-select": "^5.10.2",
@@ -1041,6 +1044,92 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "26.4.4",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/mlt": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -1973,6 +2062,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2450,6 +2545,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2783,6 +2887,12 @@
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/electron-to-chromium": { "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",
@@ -3157,6 +3267,12 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -3310,6 +3426,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -3637,6 +3759,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json5": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -3650,6 +3778,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3753,6 +3887,39 @@
"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/maplibre-gl": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.10.0.tgz",
"integrity": "sha512-pZIJ1/rsB1jDjT3eBTfMaeSY45lRKdCuZtqXqTnkUrn0UJO9IwtbywT5ym3/cz57MGEELwAwQSYjB/Fj9TlktQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.2.0",
"@mapbox/unitbezier": "^1.0.0",
"@mapbox/vector-tile": "^3.0.0",
"@maplibre/geojson-vt": "^6.1.1",
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
"@maplibre/mlt": "^1.3.0",
"@maplibre/vt-pbf": "^4.3.2",
"@types/geojson": "^7946.0.16",
"bidi-js": "^1.1.0",
"earcut": "^3.2.3",
"gl-matrix": "^3.4.4",
"kdbush": "^4.1.0",
"murmurhash-js": "^1.0.0",
"pbf": "^5.1.2",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
@@ -3796,6 +3963,15 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion-dom": { "node_modules/motion-dom": {
"version": "11.18.1", "version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
@@ -3817,6 +3993,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/mz": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -4042,6 +4224,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4081,6 +4275,15 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/pmtiles": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.5.0.tgz",
"integrity": "sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w==",
"license": "BSD-3-Clause",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.26", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@@ -4253,6 +4456,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -4413,6 +4622,12 @@
"prosemirror-transform": "^1.1.0" "prosemirror-transform": "^1.1.0"
} }
}, },
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4444,6 +4659,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -4501,6 +4722,15 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-icons": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": { "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",
@@ -4623,6 +4853,15 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -4653,6 +4892,15 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -5046,6 +5294,12 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+4
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "npx vite", "dev": "npx vite",
"build": "npx vite build", "build": "npx vite build",
"build:map-worker": "esbuild src/map/pmtiles.worker.ts --bundle --format=esm --outfile=public/map/pmtiles-worker.mjs",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx", "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"preview": "npx vite preview" "preview": "npx vite preview"
}, },
@@ -19,9 +20,12 @@
"@tiptap/static-renderer": "^3.31.3", "@tiptap/static-renderer": "^3.31.3",
"framer-motion": "^11.5.4", "framer-motion": "^11.5.4",
"lucide-react": "0.522.0", "lucide-react": "0.522.0",
"maplibre-gl": "^6.10.0",
"pmtiles": "^4.5.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-hook-form": "^7.88.0",
"react-icons": "^5.7.0",
"react-multi-date-picker": "^4.5.2", "react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
"react-select": "^5.10.2", "react-select": "^5.10.2",
+836
View File
@@ -0,0 +1,836 @@
// node_modules/fflate/esm/browser.js
var u8 = Uint8Array;
var u16 = Uint16Array;
var i32 = Int32Array;
var fleb = new u8([
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
2,
2,
2,
2,
3,
3,
3,
3,
4,
4,
4,
4,
5,
5,
5,
5,
0,
/* unused */
0,
0,
/* impossible */
0
]);
var fdeb = new u8([
0,
0,
0,
0,
1,
1,
2,
2,
3,
3,
4,
4,
5,
5,
6,
6,
7,
7,
8,
8,
9,
9,
10,
10,
11,
11,
12,
12,
13,
13,
/* unused */
0,
0
]);
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
var freb = function(eb, start) {
var b2 = new u16(31);
for (var i = 0; i < 31; ++i) {
b2[i] = start += 1 << eb[i - 1];
}
var r = new i32(b2[30]);
for (var i = 1; i < 30; ++i) {
for (var j2 = b2[i]; j2 < b2[i + 1]; ++j2) {
r[j2] = j2 - b2[i] << 5 | i;
}
}
return { b: b2, r };
};
var _a = freb(fleb, 2);
var fl = _a.b;
var revfl = _a.r;
fl[28] = 258, revfl[258] = 28;
var _b = freb(fdeb, 0);
var fd = _b.b;
var revfd = _b.r;
var rev = new u16(32768);
for (i = 0; i < 32768; ++i) {
x2 = (i & 43690) >> 1 | (i & 21845) << 1;
x2 = (x2 & 52428) >> 2 | (x2 & 13107) << 2;
x2 = (x2 & 61680) >> 4 | (x2 & 3855) << 4;
rev[i] = ((x2 & 65280) >> 8 | (x2 & 255) << 8) >> 1;
}
var x2;
var i;
var hMap = function(cd, mb, r) {
var s = cd.length;
var i = 0;
var l = new u16(mb);
for (; i < s; ++i) {
if (cd[i])
++l[cd[i] - 1];
}
var le = new u16(mb);
for (i = 1; i < mb; ++i) {
le[i] = le[i - 1] + l[i - 1] << 1;
}
var co;
if (r) {
co = new u16(1 << mb);
var rvb = 15 - mb;
for (i = 0; i < s; ++i) {
if (cd[i]) {
var sv = i << 4 | cd[i];
var r_1 = mb - cd[i];
var v2 = le[cd[i] - 1]++ << r_1;
for (var m = v2 | (1 << r_1) - 1; v2 <= m; ++v2) {
co[rev[v2] >> rvb] = sv;
}
}
}
} else {
co = new u16(s);
for (i = 0; i < s; ++i) {
if (cd[i]) {
co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
}
}
}
return co;
};
var flt = new u8(288);
for (i = 0; i < 144; ++i)
flt[i] = 8;
var i;
for (i = 144; i < 256; ++i)
flt[i] = 9;
var i;
for (i = 256; i < 280; ++i)
flt[i] = 7;
var i;
for (i = 280; i < 288; ++i)
flt[i] = 8;
var i;
var fdt = new u8(32);
for (i = 0; i < 32; ++i)
fdt[i] = 5;
var i;
var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
var max = function(a) {
var m = a[0];
for (var i = 1; i < a.length; ++i) {
if (a[i] > m)
m = a[i];
}
return m;
};
var bits = function(d2, p, m) {
var o = p / 8 | 0;
return (d2[o] | d2[o + 1] << 8) >> (p & 7) & m;
};
var bits16 = function(d2, p) {
var o = p / 8 | 0;
return (d2[o] | d2[o + 1] << 8 | d2[o + 2] << 16) >> (p & 7);
};
var shft = function(p) {
return (p + 7) / 8 | 0;
};
var slc = function(v2, s, e) {
if (s == null || s < 0)
s = 0;
if (e == null || e > v2.length)
e = v2.length;
return new u8(v2.subarray(s, e));
};
var ec = [
"unexpected EOF",
"invalid block type",
"invalid length/literal",
"invalid distance",
"stream finished",
"no stream handler",
,
// determined by compression function
"no callback",
"invalid UTF-8 data",
"extra field too long",
"date not in range 1980-2099",
"filename too long",
"stream finishing",
"invalid zip data"
// determined by unknown compression method
];
var err = function(ind, msg, nt) {
var e = new Error(msg || ec[ind]);
e.code = ind;
if (Error.captureStackTrace)
Error.captureStackTrace(e, err);
if (!nt)
throw e;
return e;
};
var inflt = function(dat, st, buf, dict) {
var sl = dat.length, dl = dict ? dict.length : 0;
if (!sl || st.f && !st.l)
return buf || new u8(0);
var noBuf = !buf;
var resize = noBuf || st.i != 2;
var noSt = st.i;
if (noBuf)
buf = new u8(sl * 3);
var cbuf = function(l2) {
var bl = buf.length;
if (l2 > bl) {
var nbuf = new u8(Math.max(bl * 2, l2));
nbuf.set(buf);
buf = nbuf;
}
};
var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
var tbts = sl * 8;
do {
if (!lm) {
final = bits(dat, pos, 1);
var type = bits(dat, pos + 1, 3);
pos += 3;
if (!type) {
var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
if (t > sl) {
if (noSt)
err(0);
break;
}
if (resize)
cbuf(bt + l);
buf.set(dat.subarray(s, t), bt);
st.b = bt += l, st.p = pos = t * 8, st.f = final;
continue;
} else if (type == 1)
lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
else if (type == 2) {
var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
var tl = hLit + bits(dat, pos + 5, 31) + 1;
pos += 14;
var ldt = new u8(tl);
var clt = new u8(19);
for (var i = 0; i < hcLen; ++i) {
clt[clim[i]] = bits(dat, pos + i * 3, 7);
}
pos += hcLen * 3;
var clb = max(clt), clbmsk = (1 << clb) - 1;
var clm = hMap(clt, clb, 1);
for (var i = 0; i < tl; ) {
var r = clm[bits(dat, pos, clbmsk)];
pos += r & 15;
var s = r >> 4;
if (s < 16) {
ldt[i++] = s;
} else {
var c = 0, n = 0;
if (s == 16)
n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
else if (s == 17)
n = 3 + bits(dat, pos, 7), pos += 3;
else if (s == 18)
n = 11 + bits(dat, pos, 127), pos += 7;
while (n--)
ldt[i++] = c;
}
}
var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
lbt = max(lt);
dbt = max(dt);
lm = hMap(lt, lbt, 1);
dm = hMap(dt, dbt, 1);
} else
err(1);
if (pos > tbts) {
if (noSt)
err(0);
break;
}
}
if (resize)
cbuf(bt + 131072);
var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
var lpos = pos;
for (; ; lpos = pos) {
var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
pos += c & 15;
if (pos > tbts) {
if (noSt)
err(0);
break;
}
if (!c)
err(2);
if (sym < 256)
buf[bt++] = sym;
else if (sym == 256) {
lpos = pos, lm = null;
break;
} else {
var add = sym - 254;
if (sym > 264) {
var i = sym - 257, b2 = fleb[i];
add = bits(dat, pos, (1 << b2) - 1) + fl[i];
pos += b2;
}
var d2 = dm[bits16(dat, pos) & dms], dsym = d2 >> 4;
if (!d2)
err(3);
pos += d2 & 15;
var dt = fd[dsym];
if (dsym > 3) {
var b2 = fdeb[dsym];
dt += bits16(dat, pos) & (1 << b2) - 1, pos += b2;
}
if (pos > tbts) {
if (noSt)
err(0);
break;
}
if (resize)
cbuf(bt + 131072);
var end = bt + add;
if (bt < dt) {
var shift = dl - dt, dend = Math.min(dt, end);
if (shift + bt < 0)
err(3);
for (; bt < dend; ++bt)
buf[bt] = dict[shift + bt];
}
for (; bt < end; ++bt)
buf[bt] = buf[bt - dt];
}
}
st.l = lm, st.p = lpos, st.b = bt, st.f = final;
if (lm)
final = 1, st.m = lbt, st.d = dm, st.n = dbt;
} while (!final);
return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
};
var et = /* @__PURE__ */ new u8(0);
var gzs = function(d2) {
if (d2[0] != 31 || d2[1] != 139 || d2[2] != 8)
err(6, "invalid gzip data");
var flg = d2[3];
var st = 10;
if (flg & 4)
st += (d2[10] | d2[11] << 8) + 2;
for (var zs = (flg >> 3 & 1) + (flg >> 4 & 1); zs > 0; zs -= !d2[st++])
;
return st + (flg & 2);
};
var gzl = function(d2) {
var l = d2.length;
return (d2[l - 4] | d2[l - 3] << 8 | d2[l - 2] << 16 | d2[l - 1] << 24) >>> 0;
};
var zls = function(d2, dict) {
if ((d2[0] & 15) != 8 || d2[0] >> 4 > 7 || (d2[0] << 8 | d2[1]) % 31)
err(6, "invalid zlib data");
if ((d2[1] >> 5 & 1) == +!dict)
err(6, "invalid zlib data: " + (d2[1] & 32 ? "need" : "unexpected") + " dictionary");
return (d2[1] >> 3 & 4) + 2;
};
function inflateSync(data, opts) {
return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
}
function gunzipSync(data, opts) {
var st = gzs(data);
if (st + 8 > data.length)
err(6, "invalid gzip data");
return inflt(data.subarray(st, -8), { i: 2 }, opts && opts.out || new u8(gzl(data)), opts && opts.dictionary);
}
function unzlibSync(data, opts) {
return inflt(data.subarray(zls(data, opts && opts.dictionary), -4), { i: 2 }, opts && opts.out, opts && opts.dictionary);
}
function decompressSync(data, opts) {
return data[0] == 31 && data[1] == 139 && data[2] == 8 ? gunzipSync(data, opts) : (data[0] & 15) != 8 || data[0] >> 4 > 7 || (data[0] << 8 | data[1]) % 31 ? inflateSync(data, opts) : unzlibSync(data, opts);
}
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
var tds = 0;
try {
td.decode(et, { stream: true });
tds = 1;
} catch (e) {
}
// node_modules/pmtiles/dist/esm/index.js
var j = Object.defineProperty;
var S = Math.pow;
var d = (o, t) => j(o, "name", { value: t, configurable: true });
var h = (o, t, e) => new Promise((r, n) => {
var s = (c) => {
try {
a(e.next(c));
} catch (l) {
n(l);
}
}, i = (c) => {
try {
a(e.throw(c));
} catch (l) {
n(l);
}
}, a = (c) => c.done ? r(c.value) : Promise.resolve(c.value).then(s, i);
a((e = e.apply(o, t)).next());
});
var re = d((o, t) => {
let e = false, r = "", n = L.GridLayer.extend({ createTile: d((s, i) => {
let a = document.createElement("img"), c = new AbortController(), l = c.signal;
return a.cancel = () => {
c.abort();
}, e || (o.getHeader().then((u) => {
u.tileType === 1 || u.tileType === 6 ? console.error("Error: archive contains vector tiles, but leafletRasterLayer is for displaying raster tiles. See https://github.com/protomaps/PMTiles/tree/main/js for details.") : u.tileType === 2 ? r = "image/png" : u.tileType === 3 ? r = "image/jpeg" : u.tileType === 4 ? r = "image/webp" : u.tileType === 5 && (r = "image/avif");
}), e = true), o.getZxy(s.z, s.x, s.y, l).then((u) => {
if (u) {
let m = new Blob([u.data], { type: r }), g = window.URL.createObjectURL(m);
a.src = g;
} else a.style.display = "none";
a.cancel = void 0, i(void 0, a);
}).catch((u) => {
if (u.name !== "AbortError") throw u;
}), a;
}, "createTile"), _removeTile: d(function(s) {
let i = this._tiles[s];
i && (i.el.cancel && i.el.cancel(), i.el.src && window.URL.revokeObjectURL(i.el.src), i.el.width = 0, i.el.height = 0, i.el.deleted = true, L.DomUtil.remove(i.el), delete this._tiles[s], this.fire("tileunload", { tile: i.el, coords: this._keyToTileCoords(s) }));
}, "_removeTile") });
return new n(t);
}, "leafletRasterLayer");
var z = d((o) => (t, e) => {
if (e instanceof AbortController) return o(t, e);
let r = new AbortController();
return o(t, r).then((n) => e(void 0, n.data, n.cacheControl || "", n.expires || ""), (n) => e(n)).catch((n) => e(n)), { cancel: d(() => r.abort(), "cancel") };
}, "v3compat");
var A = class A2 {
constructor(t) {
this.tilev4 = d((t2, e) => h(this, null, function* () {
if (t2.type === "json") {
let g = t2.url.substr(10), p = this.tiles.get(g);
if (p || (p = new w(g), this.tiles.set(g, p)), this.metadata) {
let K = yield p.getTileJson(t2.url);
return e.signal.throwIfAborted(), { data: K };
}
let f = yield p.getHeader();
return e.signal.throwIfAborted(), (f.minLon >= f.maxLon || f.minLat >= f.maxLat) && console.error(`Bounds of PMTiles archive ${f.minLon},${f.minLat},${f.maxLon},${f.maxLat} are not valid.`), { data: { tiles: [`${t2.url}/{z}/{x}/{y}`], minzoom: f.minZoom, maxzoom: f.maxZoom, bounds: [f.minLon, f.minLat, f.maxLon, f.maxLat] } };
}
let r = new RegExp(/pmtiles:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/), n = t2.url.match(r);
if (!n) throw new Error("Invalid PMTiles protocol URL");
let s = n[1], i = this.tiles.get(s);
i || (i = new w(s), this.tiles.set(s, i));
let a = n[2], c = n[3], l = n[4], u = yield i == null ? void 0 : i.getZxy(+a, +c, +l, e.signal);
if (e.signal.throwIfAborted(), u) return { data: new Uint8Array(u.data), cacheControl: u.cacheControl, expires: u.expires };
let m = yield i.getHeader();
if (m.tileType === 1 || m.tileType === 6) {
if (this.errorOnMissingTile) throw new Error("Tile not found.");
return { data: new Uint8Array() };
}
return { data: null };
}), "tilev4");
this.tile = z(this.tilev4);
this.tiles = /* @__PURE__ */ new Map(), this.metadata = (t == null ? void 0 : t.metadata) || false, this.errorOnMissingTile = (t == null ? void 0 : t.errorOnMissingTile) || false;
}
add(t) {
this.tiles.set(t.source.getKey(), t);
}
get(t) {
return this.tiles.get(t);
}
};
d(A, "Protocol");
var B = A;
function b(o, t) {
return (t >>> 0) * 4294967296 + (o >>> 0);
}
d(b, "toNum");
function N(o, t) {
let e = t.buf, r = e[t.pos++], n = (r & 112) >> 4;
if (r < 128 || (r = e[t.pos++], n |= (r & 127) << 3, r < 128) || (r = e[t.pos++], n |= (r & 127) << 10, r < 128) || (r = e[t.pos++], n |= (r & 127) << 17, r < 128) || (r = e[t.pos++], n |= (r & 127) << 24, r < 128) || (r = e[t.pos++], n |= (r & 1) << 31, r < 128)) return b(o, n);
throw new Error("Expected varint not more than 10 bytes");
}
d(N, "readVarintRemainder");
function x(o) {
let t = o.buf, e = t[o.pos++], r = e & 127;
return e < 128 || (e = t[o.pos++], r |= (e & 127) << 7, e < 128) || (e = t[o.pos++], r |= (e & 127) << 14, e < 128) || (e = t[o.pos++], r |= (e & 127) << 21, e < 128) ? r : (e = t[o.pos], r |= (e & 15) << 28, N(r, o));
}
d(x, "readVarint");
function I(o, t, e, r, n) {
return n === 0 ? r !== 0 ? [o - 1 - e, o - 1 - t] : [e, t] : [t, e];
}
d(I, "rotate");
function q(o, t, e) {
if (o > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
if (t >= 1 << o || e >= 1 << o) throw new Error("tile x/y outside zoom level bounds");
let r = ((1 << o) * (1 << o) - 1) / 3, n = o - 1, [s, i] = [t, e];
for (let a = 1 << n; a > 0; a >>= 1) {
let c = s & a, l = i & a;
r += (3 * c ^ l) * (1 << n), [s, i] = I(a, s, i, c, l), n--;
}
return r;
}
d(q, "zxyToTileId");
function G(o) {
let t = 3 * o + 1;
return t < 4294967296 ? 31 - Math.clz32(t) : 63 - Math.clz32(t / 4294967296);
}
d(G, "tileIdToZ");
function ie(o) {
let t = G(o) >> 1;
if (t > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
let e = ((1 << t) * (1 << t) - 1) / 3, r = o - e, n = 0, s = 0, i = 1 << t;
for (let a = 1; a < i; a <<= 1) {
let c = a & r / 2, l = a & (r ^ c);
[n, s] = I(a, n, s, c, l), r = r / 2, n += c, s += l;
}
return [t, n, s];
}
d(ie, "tileIdToZxy");
var J = ((s) => (s[s.Unknown = 0] = "Unknown", s[s.None = 1] = "None", s[s.Gzip = 2] = "Gzip", s[s.Brotli = 3] = "Brotli", s[s.Zstd = 4] = "Zstd", s))(J || {});
function P(o, t) {
return h(this, null, function* () {
if (t === 1 || t === 0) return o;
if (t === 2) {
if (typeof globalThis.DecompressionStream == "undefined") return decompressSync(new Uint8Array(o));
let e = new Response(o).body;
if (!e) throw new Error("Failed to read response stream");
let r = e.pipeThrough(new globalThis.DecompressionStream("gzip"));
return new Response(r).arrayBuffer();
}
throw new Error("Compression method not supported");
});
}
d(P, "defaultDecompress");
var O = ((a) => (a[a.Unknown = 0] = "Unknown", a[a.Mvt = 1] = "Mvt", a[a.Png = 2] = "Png", a[a.Jpeg = 3] = "Jpeg", a[a.Webp = 4] = "Webp", a[a.Avif = 5] = "Avif", a[a.Mlt = 6] = "Mlt", a))(O || {});
function _(o) {
return o === 1 ? ".mvt" : o === 2 ? ".png" : o === 3 ? ".jpg" : o === 4 ? ".webp" : o === 5 ? ".avif" : o === 6 ? ".mlt" : "";
}
d(_, "tileTypeExt");
var Y = 127;
function Q(o, t) {
let e = 0, r = o.length - 1;
for (; e <= r; ) {
let n = r + e >> 1, s = t - o[n].tileId;
if (s > 0) e = n + 1;
else if (s < 0) r = n - 1;
else return o[n];
}
return r >= 0 && (o[r].runLength === 0 || t - o[r].tileId < o[r].runLength) ? o[r] : null;
}
d(Q, "findTile");
var T = class T2 {
constructor(t) {
this.file = t;
}
getKey() {
return this.file.name;
}
getBytes(t, e) {
return h(this, null, function* () {
return { data: yield this.file.slice(t, t + e).arrayBuffer() };
});
}
};
d(T, "FileSource");
var D = class D2 {
constructor(t, e = new Headers(), r = void 0) {
var a, c;
this.url = t, this.customHeaders = e, this.credentials = r, this.mustReload = false;
let n = "";
"navigator" in globalThis && (n = (c = (a = globalThis.navigator) == null ? void 0 : a.userAgent) != null ? c : "");
let s = n.indexOf("Windows") > -1, i = /Chrome|Chromium|Edg|OPR|Brave/.test(n);
this.chromeWindowsNoCache = false, s && i && (this.chromeWindowsNoCache = true);
}
getKey() {
return this.url;
}
setHeaders(t) {
this.customHeaders = t;
}
getBytes(t, e, r, n) {
return h(this, null, function* () {
let s, i;
r ? i = r : (s = new AbortController(), i = s.signal);
let a = new Headers(this.customHeaders);
a.set("range", `bytes=${t}-${t + e - 1}`);
let c;
this.mustReload ? c = "reload" : this.chromeWindowsNoCache && (c = "no-store");
let l = yield fetch(this.url, { signal: i, cache: c, headers: a, credentials: this.credentials });
if (t === 0 && l.status === 416) {
let p = l.headers.get("Content-Range");
if (!p || !p.startsWith("bytes */")) throw new Error("Missing content-length on 416 response");
let f = +p.substr(8);
a.set("range", `bytes=0-${f - 1}`), l = yield fetch(this.url, { signal: i, cache: "reload", headers: a, credentials: this.credentials });
}
let u = l.headers.get("Etag");
if (u != null && u.startsWith("W/") && (u = null), l.status === 416 || n && u && u !== n) throw this.mustReload = true, new v(`Server returned non-matching ETag ${n} after one retry. Check browser extensions and servers for issues that may affect correct ETag headers.`);
if (l.status >= 300) throw new Error(`Bad response code: ${l.status}`);
let m = l.headers.get("Content-Length");
if (l.status === 200 && (!m || +m > e)) throw s && s.abort(), new Error("Server returned no content-length header or content-length exceeding request. Check that your storage backend supports HTTP Byte Serving.");
return { data: yield l.arrayBuffer(), etag: u || void 0, cacheControl: l.headers.get("Cache-Control") || void 0, expires: l.headers.get("Expires") || void 0 };
});
}
};
d(D, "FetchSource");
var E = D;
function y(o, t) {
let e = o.getUint32(t + 4, true), r = o.getUint32(t + 0, true);
return e * S(2, 32) + r;
}
d(y, "getUint64");
function X(o, t) {
let e = new DataView(o), r = e.getUint8(7);
if (r > 3) throw new Error(`Archive is spec version ${r} but this library supports up to spec version 3`);
return { specVersion: r, rootDirectoryOffset: y(e, 8), rootDirectoryLength: y(e, 16), jsonMetadataOffset: y(e, 24), jsonMetadataLength: y(e, 32), leafDirectoryOffset: y(e, 40), leafDirectoryLength: y(e, 48), tileDataOffset: y(e, 56), tileDataLength: y(e, 64), numAddressedTiles: y(e, 72), numTileEntries: y(e, 80), numTileContents: y(e, 88), clustered: e.getUint8(96) === 1, internalCompression: e.getUint8(97), tileCompression: e.getUint8(98), tileType: e.getUint8(99), minZoom: e.getUint8(100), maxZoom: e.getUint8(101), minLon: e.getInt32(102, true) / 1e7, minLat: e.getInt32(106, true) / 1e7, maxLon: e.getInt32(110, true) / 1e7, maxLat: e.getInt32(114, true) / 1e7, centerZoom: e.getUint8(118), centerLon: e.getInt32(119, true) / 1e7, centerLat: e.getInt32(123, true) / 1e7, etag: t };
}
d(X, "bytesToHeader");
function Z(o) {
let t = { buf: new Uint8Array(o), pos: 0 }, e = x(t), r = [], n = 0;
for (let s = 0; s < e; s++) {
let i = x(t);
r.push({ tileId: n + i, offset: 0, length: 0, runLength: 1 }), n += i;
}
for (let s = 0; s < e; s++) r[s].runLength = x(t);
for (let s = 0; s < e; s++) r[s].length = x(t);
for (let s = 0; s < e; s++) {
let i = x(t);
i === 0 && s > 0 ? r[s].offset = r[s - 1].offset + r[s - 1].length : r[s].offset = i - 1;
}
return r;
}
d(Z, "deserializeIndex");
var R = class R2 extends Error {
};
d(R, "EtagMismatch");
var v = R;
function V(o, t) {
return h(this, null, function* () {
let e = yield o.getBytes(0, 16384);
if (new DataView(e.data).getUint16(0, true) !== 19792) throw new Error("Wrong magic number for PMTiles archive");
let n = e.data.slice(0, Y), s = X(n, e.etag), i = e.data.slice(s.rootDirectoryOffset, s.rootDirectoryOffset + s.rootDirectoryLength), a = `${o.getKey()}|${s.etag || ""}|${s.rootDirectoryOffset}|${s.rootDirectoryLength}`, c = Z(yield t(i, s.internalCompression));
return [s, [a, c.length, c]];
});
}
d(V, "getHeaderAndRoot");
function F(o, t, e, r, n, s) {
return h(this, null, function* () {
let i = yield o.getBytes(e, r, s, n.etag), a = yield t(i.data, n.internalCompression), c = Z(a);
if (c.length === 0) throw new Error("Empty directory is invalid");
return c;
});
}
d(F, "getDirectory");
var U = class U2 {
constructor(t = 100, e = true, r = P) {
this.cache = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
}
getHeader(t) {
return h(this, null, function* () {
let e = t.getKey(), r = this.cache.get(e);
if (r) return r.lastUsed = this.counter++, r.data;
let n = yield V(t, this.decompress);
return n[1] && this.cache.set(n[1][0], { lastUsed: this.counter++, data: n[1][2] }), this.cache.set(e, { lastUsed: this.counter++, data: n[0] }), this.prune(), n[0];
});
}
getDirectory(t, e, r, n, s) {
return h(this, null, function* () {
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
if (a) return a.lastUsed = this.counter++, a.data;
let c = yield F(t, this.decompress, e, r, n, s);
return this.cache.set(i, { lastUsed: this.counter++, data: c }), this.prune(), c;
});
}
prune() {
if (this.cache.size > this.maxCacheEntries) {
let t = 1 / 0, e;
this.cache.forEach((r, n) => {
r.lastUsed < t && (t = r.lastUsed, e = n);
}), e && this.cache.delete(e);
}
}
invalidate(t) {
return h(this, null, function* () {
this.cache.delete(t.getKey());
});
}
};
d(U, "ResolvedValueCache");
var M = class M2 {
constructor(t = 100, e = true, r = P) {
this.cache = /* @__PURE__ */ new Map(), this.invalidations = /* @__PURE__ */ new Map(), this.pendingFetches = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
}
getHeader(t) {
return h(this, null, function* () {
let e = t.getKey(), r = this.cache.get(e);
if (r) return r.lastUsed = this.counter++, yield r.data;
let n = new Promise((s, i) => {
V(t, this.decompress).then((a) => {
a[1] && this.cache.set(a[1][0], { lastUsed: this.counter++, data: Promise.resolve(a[1][2]) }), s(a[0]), this.prune();
}).catch((a) => {
i(a);
});
});
return this.cache.set(e, { lastUsed: this.counter++, data: n }), n;
});
}
trackSignal(t, e, r) {
e.refs++, r.addEventListener("abort", () => {
--e.refs <= 0 && this.pendingFetches.get(t) === e && (e.controller.abort(), this.cache.delete(t), this.pendingFetches.delete(t));
}, { once: true });
}
getDirectory(t, e, r, n, s) {
return h(this, null, function* () {
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
if (a) {
a.lastUsed = this.counter++;
let m = this.pendingFetches.get(i);
return m && this.trackSignal(i, m, s != null ? s : new AbortController().signal), yield a.data;
}
let c = new AbortController(), l = { controller: c, refs: 0 };
this.trackSignal(i, l, s != null ? s : new AbortController().signal), this.pendingFetches.set(i, l);
let u = new Promise((m, g) => {
F(t, this.decompress, e, r, n, c.signal).then((p) => {
this.pendingFetches.delete(i), m(p), this.prune();
}).catch((p) => {
g(p);
});
});
return this.cache.set(i, { lastUsed: this.counter++, data: u }), u;
});
}
prune() {
if (this.cache.size >= this.maxCacheEntries) {
let t = 1 / 0, e;
this.cache.forEach((r, n) => {
r.lastUsed < t && (t = r.lastUsed, e = n);
}), e && this.cache.delete(e);
}
}
invalidate(t) {
return h(this, null, function* () {
let e = t.getKey();
if (this.invalidations.get(e)) return yield this.invalidations.get(e);
this.cache.delete(t.getKey());
let r = new Promise((n, s) => {
this.getHeader(t).then((i) => {
n(), this.invalidations.delete(e);
}).catch((i) => {
s(i);
});
});
this.invalidations.set(e, r);
});
}
};
d(M, "SharedPromiseCache");
var C = M;
var H = class H2 {
constructor(t, e, r) {
typeof t == "string" ? this.source = new E(t) : this.source = t, r ? this.decompress = r : this.decompress = P, e ? this.cache = e : this.cache = new C();
}
getHeader() {
return h(this, null, function* () {
return yield this.cache.getHeader(this.source);
});
}
getZxyAttempt(t, e, r, n) {
return h(this, null, function* () {
let s = q(t, e, r), i = yield this.cache.getHeader(this.source);
if (n == null || n.throwIfAborted(), t < i.minZoom || t > i.maxZoom) return;
let a = i.rootDirectoryOffset, c = i.rootDirectoryLength;
for (let l = 0; l <= 3; l++) {
let u = yield this.cache.getDirectory(this.source, a, c, i, n);
n == null || n.throwIfAborted();
let m = Q(u, s);
if (m) {
if (m.runLength > 0) {
let g = yield this.source.getBytes(i.tileDataOffset + m.offset, m.length, n, i.etag);
return { data: yield this.decompress(g.data, i.tileCompression), cacheControl: g.cacheControl, expires: g.expires };
}
a = i.leafDirectoryOffset + m.offset, c = m.length;
} else return;
}
throw new Error("Maximum directory depth exceeded");
});
}
getZxy(t, e, r, n) {
return h(this, null, function* () {
try {
return yield this.getZxyAttempt(t, e, r, n);
} catch (s) {
if (s instanceof v) return this.cache.invalidate(this.source), yield this.getZxyAttempt(t, e, r, n);
throw s;
}
});
}
getMetadataAttempt() {
return h(this, null, function* () {
let t = yield this.cache.getHeader(this.source), e = yield this.source.getBytes(t.jsonMetadataOffset, t.jsonMetadataLength, void 0, t.etag), r = yield this.decompress(e.data, t.internalCompression), n = new TextDecoder("utf-8");
return JSON.parse(n.decode(r));
});
}
getMetadata() {
return h(this, null, function* () {
try {
return yield this.getMetadataAttempt();
} catch (t) {
if (t instanceof v) return this.cache.invalidate(this.source), yield this.getMetadataAttempt();
throw t;
}
});
}
getTileJson(t) {
return h(this, null, function* () {
let e = yield this.getHeader(), r = yield this.getMetadata(), n = _(e.tileType);
return { tilejson: "3.0.0", scheme: "xyz", tiles: [`${t}/{z}/{x}/{y}${n}`], vector_layers: r.vector_layers, attribution: r.attribution, description: r.description, name: r.name, version: r.version, bounds: [e.minLon, e.minLat, e.maxLon, e.maxLat], center: [e.centerLon, e.centerLat, e.centerZoom], minzoom: e.minZoom, maxzoom: e.maxZoom };
});
}
};
d(H, "PMTiles");
var w = H;
// src/map/pmtiles.worker.ts
var workerScope = self;
workerScope.addProtocol("pmtiles", new B().tile);
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 283 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+584
View File
@@ -0,0 +1,584 @@
#!/usr/bin/env node
/**
* به‌روزرسانی یک‌بارهٔ پالت `style.json` نقشهٔ آفلاین روی MinIO.
*
* ترتیب کار (مهم): اول نسخهٔ فعلی خوانده و «پشتیبان» می‌شود — هم روی دیسک
* (style.backup.json) و هم روی MinIO (maps/style.backup.json) — و تنها پس از
* موفقیت پشتیبان، پالت تازه نوشته و آپلود می‌شود.
*
* چرا از S3 API و نه فایل روی دیسک؟ شیءهای MinIO با فرمت داخلی xl.meta ذخیره
* می‌شوند، نه JSON خام؛ پس تنها راه درست به‌روزرسانی، PUT روی همان API است.
*
* اجرا (از پوشهٔ frontend) — هر کار با پرچم مشخص، پیش‌فرض هیچ تغییری نمی‌دهد:
* node --experimental-strip-types scripts/update-map-palette.ts --fix-glyphs
* node --experimental-strip-types scripts/update-map-palette.ts --palette
* node --experimental-strip-types scripts/update-map-palette.ts --restore
* ... [--dry-run] برای دیدن گزارش بدون نوشتن روی MinIO
*
* در همهٔ حالت‌ها (جز restore) ترتیب امن رعایت می‌شود: خواندن و اعتبارسنجی →
* پشتیبان روی دیسک و MinIO → تغییر → اعتبارسنجی دوباره → آپلود.
*
* کلیدهای MinIO از فایل .env ریشهٔ پروژه خوانده می‌شوند (MINIO_ROOT_USER /
* MINIO_ROOT_PASSWORD) و در نبودشان از متغیرهای محیطی همان نام‌ها.
*/
import { createHash, createHmac } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
/* ------------------------------------------------------------------ */
/* پالت تازه */
/* ------------------------------------------------------------------ */
/** نگاشت دسته‌ها به رنگ‌های تازهٔ برند — تنها منبع حقیقت این اسکریپت */
const PALETTE = {
background: '#181512',
urban: '#27221c',
highway: '#6a5544',
street: '#4a3a2d',
alley: '#352c25',
water: '#172c32',
parks: '#20312b',
nature: '#314238',
mainText: '#ead9c6',
subText: '#a99584',
borders: '#3e362e',
} as const
type Category = keyof typeof PALETTE
/* ------------------------------------------------------------------ */
/* قواعد دسته‌بندی لایه‌ها */
/* ------------------------------------------------------------------ */
type Layer = {
id: string
type: string
'source-layer'?: string
paint?: Record<string, unknown>
layout?: Record<string, unknown>
}
type StyleDocument = {
version?: number
layers?: Layer[]
sources?: Record<string, unknown>
/** الگوی نشانی گلیف‌ها — باید به پوشهٔ v2.0 باکت اشاره کند */
glyphs?: string
}
/** پوشهٔ فونت‌های آفلاین در باکت MinIO */
const GLYPHS_FOLDER = 'v2.0'
/** الگوی استاندارد گلیف — جانگهدارها دست‌نخورده می‌مانند */
const GLYPH_TEMPLATE = '{fontstack}/{range}.pbf'
/**
* اصلاح مسیر گلیف‌ها: هر شکل نشانی (خالی، نسبی، یا با پوشهٔ اشتباه) به
* «v2.0/{fontstack}/{range}.pbf» تبدیل می‌شود تا با ساختار پوشه‌های باکت
* بخواند. تابع خالص و idempotent است؛ اگر مسیر از قبل درست باشد چیز تغییر نمی‌کند.
*/
function fixGlyphsPath(style: StyleDocument): { from: string; to: string } | null {
const current = (style.glyphs ?? '').trim()
const marker = current.indexOf('{fontstack}')
const next = `${GLYPHS_FOLDER}/${marker >= 0 ? current.slice(marker) : GLYPH_TEMPLATE}`
if (next === current) return null
style.glyphs = next
return { from: current || '(خالی)', to: next }
}
/** آیا مقدار text-field به یک فیلد «نام» اشاره می‌کند؟ (name، name_en، name:latin و…) */
const NAME_REFERENCE = /name(:[a-z-]+)?/i
/**
* فونت‌های محلی با پوشش فارسی — با اجرای «--audit-fonts» قابل بازبینی است.
* تنها پوشه‌های Noto Sans در باکت گلیف عربی/فارسی دارند؛ Metropolis و بقیه
* فقط لاتین‌اند.
*/
const PERSIAN_CAPABLE_FONTS = ['Noto Sans Regular', 'Noto Sans Italic', 'Noto Sans Bold']
/**
* بازگردانی متن برچسب‌ها به قالب دوزبانهٔ اصلی، به‌تفکیک لایه.
* (لاتین خط اول، فارسی خط دوم — یا کنار هم برای نام راه‌ها)
*/
const TEXT_FIELD_RESTORE: Record<string, string> = {
water_name: '{name:latin}\n{name:nonlatin}',
place_other: '{name:latin}\n{name:nonlatin}',
place_suburb: '{name:latin}\n{name:nonlatin}',
place_village: '{name:latin}\n{name:nonlatin}',
place_town: '{name:latin}\n{name:nonlatin}',
place_city: '{name:latin}\n{name:nonlatin}',
place_city_large: '{name:latin}\n{name:nonlatin}',
place_state: '{name:latin}\n{name:nonlatin}',
highway_name_other: '{name:latin} {name:nonlatin}',
place_country_other: '{name:latin}',
place_country_minor: '{name:latin}',
place_country_major: '{name:latin}',
}
/** قالب پیش‌فرض برای هر لایهٔ برچسبِ دیگری که بعداً اضافه شود */
const DEFAULT_BILINGUAL_FIELD = '{name:latin}\n{name:nonlatin}'
function referencesName(value: unknown): boolean {
if (typeof value === 'string') return NAME_REFERENCE.test(value)
if (Array.isArray(value)) return value.some(referencesName)
if (value && typeof value === 'object') return Object.values(value).some(referencesName)
return false
}
/** فونت فارسی متناسب با پشتهٔ همان لایه — ایتالیک بماند ایتالیک */
function persianFontFor(stack: string[]): string {
const local = stack.find((font) => PERSIAN_CAPABLE_FONTS.includes(font))
if (local) return local
return stack.some((font) => /italic/i.test(font)) ? 'Noto Sans Italic' : 'Noto Sans Regular'
}
type LabelChange = { id: string; field?: { from: string; to: string }; font: { from: string; to: string } }
/**
* آماده‌سازی برچسب‌ها برای فارسی:
* ۱) متن به قالب دوزبانهٔ اصلی برمی‌گردد (لاتین + nonlatin که برای دادهٔ ایران فارسی است).
* ۲) پشتهٔ فونت بازچینی می‌شود تا فونت دارای گلیف فارسی (Noto Sans محلی) اول بیفتد؛
* چون مپ‌لیبر برای هر پشته فقط یک فایل گلیف می‌گیرد، همان فونت اول باید هر دو خط
* را پوشش دهد و Noto Sans لاتین را هم دارد.
*/
function fixLabels(style: StyleDocument): { changed: LabelChange[]; skipped: string[] } {
const changed: LabelChange[] = []
const skipped: string[] = []
for (const layer of style.layers ?? []) {
const layout = layer.layout
if (layer.type !== 'symbol' || !layout) continue
const stack = Array.isArray(layout['text-font']) ? (layout['text-font'] as unknown[]).map(String) : []
if (!stack.length) continue
const persianFont = persianFontFor(stack)
const restacked = [persianFont, ...stack.filter((font) => font !== persianFont)]
const fontChange = { from: stack.join(', '), to: restacked.join(', ') }
if (!('text-field' in layout)) {
// لایهٔ برچسب بدون فیلد نام (مثل شمارهٔ راه) — فقط پشتهٔ فونت اصلاح می‌شود
layout['text-font'] = restacked
changed.push({ id: layer.id, font: fontChange })
continue
}
if (!referencesName(layout['text-field'])) {
layout['text-font'] = restacked
skipped.push(layer.id)
continue
}
const from = JSON.stringify(layout['text-field']).slice(0, 48)
const to = TEXT_FIELD_RESTORE[layer.id] ?? DEFAULT_BILINGUAL_FIELD
layout['text-field'] = to
layout['text-font'] = restacked
changed.push({ id: layer.id, field: { from, to }, font: fontChange })
}
return { changed, skipped }
}
/**
* بررسی فونت‌ها: هر فونتی که در text-font لایه‌های برچسب استفاده شده باید در
* پوشهٔ v2.0 باکت موجود باشد — هیچ نشانی بیرونی و هیچ فونت غایبی مجاز نیست.
*/
async function verifyFonts(style: StyleDocument): Promise<void> {
const fonts = new Set<string>()
for (const layer of style.layers ?? []) {
if (layer.type !== 'symbol') continue
const stack = layer.layout?.['text-font']
if (Array.isArray(stack)) for (const font of stack) if (typeof font === 'string') fonts.add(font)
}
if (!fonts.size) {
console.log('• فونتی در لایه‌های برچسب پیدا نشد')
return
}
console.log(`\nبررسی ${fonts.size} فونت در پوشهٔ ${GLYPHS_FOLDER}:`)
const missing: string[] = []
for (const font of [...fonts].sort()) {
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/0-255.pbf`, ENDPOINT)
const ok = (await fetch(url, { method: 'HEAD' })).ok
if (!ok) missing.push(font)
console.log(` ${ok ? '✓' : '✗'} ${font}`)
}
console.log(
missing.length
? `✗ فونت‌های ناموجود در ${GLYPHS_FOLDER}: ${missing.join(', ')}`
: `✓ همهٔ فونت‌ها در ${GLYPHS_FOLDER} باکت موجودند`,
)
}
/* ------------------------------------------------------------------ */
/* بررسی پوشش فونت‌های محلی (کدام فونت فارسی را رندر می‌کند؟) */
/* ------------------------------------------------------------------ */
/** بازه‌های کلیدی گلیف: لاتین پایه، عربی/فارسی، فرم‌های نمایشی عربی */
const COVERAGE_RANGES = [
{ label: 'لاتین', range: '0-255' },
{ label: 'فارسی', range: '1536-1791' },
{ label: 'فرم فارسی', range: '65024-65279' },
] as const
/** فهرست پوشه‌های فونت داخل v2.0 باکت */
async function listFontFolders(): Promise<string[]> {
const response = await fetch(
new URL(`/maps/?list-type=2&prefix=${GLYPHS_FOLDER}/&delimiter=/&max-keys=1000`, ENDPOINT),
)
const xml = await response.text()
const pattern = new RegExp(`<Prefix>${GLYPHS_FOLDER}/([^<]+)/</Prefix>`, 'g')
return [...xml.matchAll(pattern)].map((match) => match[1])
}
/** وضعیت یک بازهٔ گلیف برای یک فونت: «نیست» / «خالی» / حجم واقعی */
async function rangeCoverage(font: string, range: string): Promise<string> {
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/${range}.pbf`, ENDPOINT)
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) return 'نیست'
const bytes = Number(response.headers.get('content-length') ?? 0)
// بازهٔ بدون گلیف فقط سرصفحهٔ پروتوباف است (چند ده بایت)
return bytes > 2000 ? `${Math.round(bytes / 1024)}KB` : 'خالی'
}
/** گزارش پوشش خط‌ها برای همهٔ فونت‌های باکت — ورودی تصمیم انتخاب فونت برچسب‌ها */
async function auditFonts(): Promise<void> {
const fonts = await listFontFolders()
console.log(`${fonts.length} پوشهٔ فونت در ${GLYPHS_FOLDER}:\n`)
for (const font of fonts) {
const cells: string[] = []
for (const { label, range } of COVERAGE_RANGES) cells.push(`${label}=${await rangeCoverage(font, range)}`)
console.log(` ${font.padEnd(26)} ${cells.join(' ')}`)
}
console.log('\nفونتی که «فارسی» و «فرم فارسی» آن خالی/نیست نباشد، برچسب فارسی را رندر می‌کند.')
}
/**
* اعتبارسنجی ساختار سبک — پیش و پس از تغییر اجرا می‌شود تا مطمئن شویم سند
* سالم است و اسکیمای سبک (نسخه، لایه‌ها، منابع) نشکسته است.
*/
function validateStyle(style: StyleDocument, label: string): void {
const problems: string[] = []
if (style.version !== 8) problems.push(`version باید ۸ باشد (مقدار فعلی: ${String(style.version)})`)
if (!Array.isArray(style.layers) || style.layers.length === 0) problems.push('layers باید آرایهٔ ناتهی باشد')
else {
const broken = style.layers.filter((layer) => !layer || typeof layer.id !== 'string' || typeof layer.type !== 'string')
if (broken.length) problems.push(`${broken.length} لایه بدون id/type`)
}
if (!style.sources || typeof style.sources !== 'object' || Array.isArray(style.sources)) {
problems.push('sources باید یک شیء باشد')
}
console.log(
problems.length
? `✗ اعتبارسنجی ${label}: ${problems.join(' | ')}`
: `✓ اعتبارسنجی ${label}: ساختار سالم است (${style.layers?.length ?? 0} لایه، ${Object.keys(style.sources ?? {}).length} منبع)`,
)
if (problems.length) throw new Error(`ساختار سبک نامعتبر است (${label})`)
}
type Rule = {
category: Category
/** با این الگو روی شناسهٔ لایه تطبیق داده می‌شود */
test: RegExp
/** کدام ویژگی‌های paint رنگ‌آمیزی شوند */
properties: string[]
}
/**
* جدول قواعد — به ترتیب بررسی می‌شود و نخستین تطبیق برنده است.
* رنگ‌های داخل عبارت‌ها (interpolate/match) هم بازنویسی می‌شوند تا پالت
* یکدست بماند.
*/
const RULES: Rule[] = [
{ category: 'background', test: /^background$/, properties: ['background-color'] },
// آب: خود لایه‌های آب و راه‌های آبی (برچسب‌ها قاعدهٔ جدا دارند)
{ category: 'water', test: /^(water|waterway)(_|$)/, properties: ['fill-color', 'line-color'] },
// پارک‌ها پیش از طبیعت بررسی می‌شوند تا چمن/پارک زیر «طبیعت» نرود
{ category: 'parks', test: /^(park|landcover_grass|landuse_(park|cemetery|pitch|grass))/, properties: ['fill-color', 'line-color'] },
// طبیعت: پوشش زمینِ جنگلی/علف‌زارِ بیرون از شهر
{ category: 'nature', test: /^landcover_/, properties: ['fill-color', 'line-color'] },
// کاربری شهری و ساختمان‌ها
{ category: 'urban', test: /^landuse_/, properties: ['fill-color', 'line-color'] },
{ category: 'urban', test: /^building/, properties: ['fill-color'] },
// سه ردهٔ راه — بزرگراه، خیابان، کوچه/ریل/باند فرودگاه
{ category: 'highway', test: /^highway_motorway/, properties: ['line-color', 'fill-color'] },
{ category: 'street', test: /^(highway_(major|minor)|road_)/, properties: ['line-color', 'fill-color'] },
{ category: 'alley', test: /^(highway_(path|track)|railway|aeroway)/, properties: ['line-color', 'fill-color'] },
{ category: 'borders', test: /^boundary/, properties: ['line-color', 'fill-color'] },
// برچسب‌های اصلی: شهر/شهرک و نام بزرگراه‌ها
{ category: 'mainText', test: /^(place_(city|town)|highway_name_motorway)/, properties: ['text-color'] },
// بقیهٔ برچسب‌ها (روستا، محله، نام خیابان، نام آب، نقاط) — متن فرعی
{ category: 'subText', test: /(_name|^place_|_label)/, properties: ['text-color'] },
]
/** ویژگی‌های فرعی که همیشه با رنگ پس‌زمینه هماهنگ می‌شوند تا خوانایی بماند */
const HALO_PROPERTIES = ['text-halo-color', 'icon-halo-color']
/** ویژگی‌های آیکون برچسب‌ها که با متن هم‌رنگ می‌شوند */
const ICON_PROPERTIES = ['icon-color']
const COLOR_PATTERN = /^(#|rgb|hsl)/i
const isColor = (value: unknown): value is string => typeof value === 'string' && COLOR_PATTERN.test(value.trim())
/* ------------------------------------------------------------------ */
/* بازنویسی رنگ‌ها (توابع خالص) */
/* ------------------------------------------------------------------ */
/** هر رشتهٔ رنگی داخل یک مقدار paint — حتی داخل عبارت‌ها — را با رنگ تازه عوض می‌کند */
function paintColor(value: unknown, color: string, stats: { replaced: number; seen: Set<string> }): unknown {
if (isColor(value)) {
stats.seen.add(value.trim())
stats.replaced += 1
return color
}
if (Array.isArray(value)) return value.map((item) => paintColor(item, color, stats))
if (value && typeof value === 'object') {
const result: Record<string, unknown> = {}
for (const [key, inner] of Object.entries(value)) result[key] = paintColor(inner, color, stats)
return result
}
return value
}
type LayerReport = { id: string; category: Category | null; replaced: number; colors: string[] }
/** یک لایه را بر پایهٔ قواعد رنگ‌آمیزی می‌کند و گزارش تغییرها را برمی‌گرداند */
function recolorLayer(layer: Layer): LayerReport {
const rule = RULES.find((candidate) => candidate.test.test(layer.id))
const stats = { replaced: 0, seen: new Set<string>() }
if (!layer.paint) return { id: layer.id, category: rule?.category ?? null, replaced: 0, colors: [] }
if (rule) {
for (const property of rule.properties) {
if (!(property in layer.paint)) continue
layer.paint[property] = paintColor(layer.paint[property], PALETTE[rule.category], stats)
}
}
// لایه‌های برچسب: هاله و آیکون‌ها با پالت هماهنگ می‌شوند
if (layer.type === 'symbol') {
const textColor = rule?.category === 'mainText' || rule?.category === 'subText'
? PALETTE[rule.category]
: PALETTE.subText
if (rule?.category !== 'mainText' && rule?.category !== 'subText' && 'text-color' in layer.paint) {
layer.paint['text-color'] = paintColor(layer.paint['text-color'], textColor, stats)
}
for (const property of [...HALO_PROPERTIES, ...ICON_PROPERTIES]) {
if (!(property in layer.paint)) continue
const target = property === 'icon-color' ? textColor : PALETTE.background
layer.paint[property] = paintColor(layer.paint[property], target, stats)
}
}
// لبهٔ ساختمان‌ها با رنگ مرزها هم‌خانواده می‌شود
if (layer.paint['fill-outline-color'] && /^building/.test(layer.id)) {
layer.paint['fill-outline-color'] = paintColor(layer.paint['fill-outline-color'], PALETTE.borders, stats)
}
return { id: layer.id, category: rule?.category ?? null, replaced: stats.replaced, colors: [...stats.seen] }
}
/** کل سند سبک را بازنویسی می‌کند (بدون دست‌زدن به ساختار و ویژگی‌های غیررنگی) */
function recolorStyle(style: StyleDocument): LayerReport[] {
const layers = style.layers ?? []
return layers.map(recolorLayer)
}
/** گزارش فشردهٔ تغییرهای پالت — به‌تفکیک دسته و لایه‌های دست‌نخورده */
function reportPalette(reports: LayerReport[]): void {
const byCategory = new Map<string, number>()
for (const report of reports) {
if (!report.replaced) continue
const category = report.category ?? 'بدون‌دسته'
byCategory.set(category, (byCategory.get(category) ?? 0) + report.replaced)
}
console.log('\nگزارش تغییرها:')
for (const [category, count] of [...byCategory].sort((a, b) => b[1] - a[1])) {
console.log(` ${category.padEnd(12)} ${String(count).padStart(3)} مقدار رنگ`)
}
const untouched = reports.filter((report) => !report.replaced)
if (untouched.length) {
console.log(`\nلایه‌های بدون تغییر (${untouched.length}): ${untouched.map((report) => report.id).join(', ')}`)
}
}
/* ------------------------------------------------------------------ */
/* دسترسی به MinIO (S3 Signature V4 — بدون وابستگی تازه) */
/* ------------------------------------------------------------------ */
type Credentials = { user: string; secret: string }
async function readCredentials(): Promise<Credentials> {
const envPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env')
const fromFile: Record<string, string> = {}
try {
for (const line of (await readFile(envPath, 'utf8')).split(/\r?\n/)) {
const match = line.match(/^([A-Z0-9_]+)=(.*)$/)
if (match) fromFile[match[1]] = match[2].trim()
}
} catch {
/* فایل .env اختیاری است — متغیرهای محیطی جایگزین می‌شوند */
}
const user = process.env.MINIO_ROOT_USER || fromFile.MINIO_ROOT_USER || 'aramesh'
const secret = process.env.MINIO_ROOT_PASSWORD || fromFile.MINIO_ROOT_PASSWORD
if (!secret) throw new Error('MINIO_ROOT_PASSWORD پیدا نشد (نه در .env و نه در متغیرهای محیطی)')
return { user, secret }
}
const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex')
const hmac = (key: string | Buffer, value: string) => createHmac('sha256', key).update(value).digest()
const hmacHex = (key: Buffer, value: string) => createHmac('sha256', key).update(value).digest('hex')
/** PUT امضاشدهٔ S3 — همان چیزی که کنسول MinIO و پنل ادمین هم انجام می‌دهند */
async function putObject(endpoint: string, objectPath: string, body: string, credentials: Credentials): Promise<void> {
const url = new URL(objectPath, endpoint)
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
const dateStamp = amzDate.slice(0, 8)
const payloadHash = sha256(body)
const canonicalHeaders = `host:${url.host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
const canonicalRequest = ['PUT', url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
const scope = `${dateStamp}/us-east-1/s3/aws4_request`
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonicalRequest)].join('\n')
const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.secret}`, dateStamp), 'us-east-1'), 's3'), 'aws4_request')
const authorization =
`AWS4-HMAC-SHA256 Credential=${credentials.user}/${scope}, ` +
`SignedHeaders=${signedHeaders}, Signature=${hmacHex(signingKey, stringToSign)}`
const response = await fetch(url, {
method: 'PUT',
headers: {
Authorization: authorization,
'x-amz-content-sha256': payloadHash,
'x-amz-date': amzDate,
'Content-Type': 'application/json',
},
body,
})
if (!response.ok) throw new Error(`PUT ${objectPath} → HTTP ${response.status} ${await response.text()}`)
}
/* ------------------------------------------------------------------ */
/* اجرا */
/* ------------------------------------------------------------------ */
const ENDPOINT = process.env.MAP_ENDPOINT ?? 'http://localhost:19000'
const STYLE_PATH = '/maps/style.json'
const BACKUP_PATH = '/maps/style.backup.json'
const OUTPUT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'map-palette')
const LOCAL_BACKUP = join(OUTPUT_DIR, 'style.backup.json')
/** بازگردانی: پشتیبان دقیقاً به‌جای style.json نوشته می‌شود */
async function restore(): Promise<void> {
const backup = await readFile(LOCAL_BACKUP, 'utf8')
validateStyle(JSON.parse(backup) as StyleDocument, 'نسخهٔ پشتیبان')
await putObject(ENDPOINT, STYLE_PATH, backup, await readCredentials())
const restored = await (await fetch(new URL(STYLE_PATH, ENDPOINT))).text()
console.log(
restored === backup
? '✓ style.json دقیقاً به نسخهٔ پشتیبان برگشت'
: '✗ محتوای بازگردانده‌شده با پشتیبان یکسان نیست — دستی بررسی کنید',
)
}
const ACTIONS = ['--audit-fonts', '--fix-labels', '--fix-glyphs', '--palette', '--restore'] as const
async function main(): Promise<void> {
const dryRun = process.argv.includes('--dry-run')
const action = ACTIONS.find((flag) => process.argv.includes(flag))
// پیش‌فرض امن: بدون پرچم هیچ نوشتنی انجام نمی‌شود
if (!action) {
console.log(
'کاری انجام نشد — یکی از پرچم‌ها را بدهید:\n' +
' --audit-fonts گزارش پوشش خط‌های هر فونت در v2.0 (فقط خواندن)\n' +
' --fix-labels برچسب‌های دوزبانه + پشتهٔ فونت محلی\n' +
' --fix-glyphs اصلاح مسیر گلیف‌ها به v2.0\n' +
' --palette اعمال پالت تازه\n' +
' --restore بازگردانی از پشتیبان\n' +
' [--dry-run] فقط گزارش، بدون نوشتن روی MinIO',
)
return
}
if (action === '--audit-fonts') {
await auditFonts()
return
}
if (action === '--restore') {
await restore()
return
}
// ۱) خواندن سبک فعلی
const response = await fetch(new URL(STYLE_PATH, ENDPOINT))
if (!response.ok) throw new Error(`GET ${STYLE_PATH} → HTTP ${response.status}`)
const original = await response.text()
const style = JSON.parse(original) as StyleDocument
console.log(`سبک فعلی خوانده شد — ${(original.length / 1024).toFixed(1)} کیلوبایت`)
validateStyle(style, 'نسخهٔ فعلی')
// ۲) پشتیبان‌گیری، پیش از هر تغییر
await mkdir(OUTPUT_DIR, { recursive: true })
await writeFile(LOCAL_BACKUP, original, 'utf8')
console.log(`پشتیبان روی دیسک: scripts/map-palette/style.backup.json`)
if (!dryRun) {
await putObject(ENDPOINT, BACKUP_PATH, original, await readCredentials())
console.log(`پشتیبان روی MinIO: ${BACKUP_PATH}`)
}
// ۳) تغییر درخواستی، سپس اعتبارسنجی دوباره
if (action === '--fix-labels') {
const { changed, skipped } = fixLabels(style)
console.log(`\nلایه‌های برچسب اصلاح‌شده (${changed.length}):`)
for (const layer of changed) {
if (layer.field) console.log(` ${layer.id}\n متن: ${layer.field.from} → ${JSON.stringify(layer.field.to)}`)
console.log(` فونت: ${layer.font.from} → ${layer.font.to}`)
}
if (skipped.length) {
console.log(`\nلایه‌های بدون فیلد نام (فقط فونت اصلاح شد): ${skipped.join(', ')}`)
}
await verifyFonts(style)
} else if (action === '--fix-glyphs') {
const change = fixGlyphsPath(style)
console.log(
change ? `\nمسیر گلیف‌ها: ${change.from} → ${change.to}` : '\nمسیر گلیف‌ها از قبل به v2.0 اشاره می‌کند',
)
await verifyFonts(style)
} else {
reportPalette(recolorStyle(style))
}
validateStyle(style, 'نسخهٔ تازه')
// ۴) نوشتن و آپلود نسخهٔ تازه
const updated = JSON.stringify(style, null, 2)
await writeFile(join(OUTPUT_DIR, 'style.json'), updated, 'utf8')
if (dryRun) {
console.log('\nحالت آزمایشی — هیچ چیزی روی MinIO نوشته نشد.')
return
}
await putObject(ENDPOINT, STYLE_PATH, updated, await readCredentials())
console.log(`\nسبک تازه روی MinIO نوشته شد: ${STYLE_PATH}`)
}
main().catch((error: unknown) => {
console.error('\nخطا:', error instanceof Error ? error.message : error)
process.exitCode = 1
})
+5 -1
View File
@@ -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">
+22
View File
@@ -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}
+13 -1
View File
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import type { SiteData } from '../api' import type { SiteData } from '../api'
import { useSiteData } from '../api' import { useSiteData } from '../api'
import { adminApi, getToken, HttpError, setToken } from './adminClient' import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
import { ENTITIES, isTranslatable } from './entities' import { ENTITIES, isTranslatable } from './entities'
export type SaveStatus = 'idle' | 'saving' | 'error' export type SaveStatus = 'idle' | 'saving' | 'error'
@@ -129,6 +129,18 @@ export function AdminProvider({ children }: { children: ReactNode }) {
return () => document.removeEventListener('click', onClick, true) return () => document.removeEventListener('click', onClick, true)
}, [isEditMode]) }, [isEditMode])
// توکن بیرون از این provider هم پاک می‌شود (پاسخ 401 بارگذاری سایت، خروج در زبانه
// دیگر) — وضعیت ورود همگام می‌ماند و نوار ادمین برای نشست مرده باقی نمی‌ماند
useEffect(() => {
const syncAuth = () => setIsAuthenticated(!!getToken())
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
window.addEventListener('storage', syncAuth)
return () => {
window.removeEventListener(AUTH_CHANGE_EVENT, syncAuth)
window.removeEventListener('storage', syncAuth)
}
}, [])
// در حین ذخیره‌سازی، از بسته‌شدن تصادفی صفحه جلوگیری می‌شود // در حین ذخیره‌سازی، از بسته‌شدن تصادفی صفحه جلوگیری می‌شود
useEffect(() => { useEffect(() => {
if (saveStatus !== 'saving') return if (saveStatus !== 'saving') return
+95 -2
View File
@@ -1,7 +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 { 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'
@@ -15,6 +15,7 @@ import type { CategoryOption } from '../components/admin/LuxeSelect'
import { ARTICLE_CATEGORIES } from './entities' import { ARTICLE_CATEGORIES } from './entities'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants' import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
import { formatReadingTime } from '../utils/readingTime' import { formatReadingTime } from '../utils/readingTime'
import { toArticleBodyDoc } from '../utils/tiptapDoc'
import type { Article, ArticleBodyDoc } from '../data/site' 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
@@ -53,6 +54,75 @@ function normalizeTags(value: unknown): string[] {
return [] 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> = {
idle: 'ذخیره شد', idle: 'ذخیره شد',
saving: 'در حال ذخیره…', saving: 'در حال ذخیره…',
@@ -117,6 +187,11 @@ export function ArticleWritePage() {
const { field: imageTitleField } = useController({ name: 'image_title', 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
@@ -151,13 +226,16 @@ export function ArticleWritePage() {
setDraft({ setDraft({
...(article as unknown as Draft), ...(article as unknown as Draft),
tags: normalizeTags((article as unknown as { tags?: unknown }).tags), tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc, // بدنه ذخیره‌شده (سند جدید یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
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({ reset({
title: article.title ?? '', title: article.title ?? '',
@@ -410,6 +488,7 @@ export function ArticleWritePage() {
{/* متن — ویرایشگر روی سند زبان جاری کار می‌کند */} {/* متن — ویرایشگر روی سند زبان جاری کار می‌کند */}
<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) => { onChange={(json) => {
// زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری // زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری
@@ -448,6 +527,20 @@ export function ArticleWritePage() {
/> />
</section> </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={{
+4 -4
View File
@@ -6,6 +6,7 @@ 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 { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
@@ -438,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)
+30 -1
View File
@@ -3,10 +3,14 @@ import { API_URL } from '../config'
const TOKEN_KEY = 'aramesh_admin_token' const TOKEN_KEY = 'aramesh_admin_token'
/** رویداد تغییر وضعیت ورود — لایه‌های دیگر (مثل نوار ادمین) با آن همگام می‌مانند */
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY) export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
export const setToken = (token: string | null): void => { export const setToken = (token: string | null): void => {
if (token) localStorage.setItem(TOKEN_KEY, token) if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY) else localStorage.removeItem(TOKEN_KEY)
window.dispatchEvent(new CustomEvent(AUTH_CHANGE_EVENT))
} }
export class HttpError extends Error { export class HttpError extends Error {
@@ -83,15 +87,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)
},
} }
+17
View File
@@ -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 {
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
}
}
+16 -4
View File
@@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { Article, Project, Service } from './data/site' import type { Article, Project, Service } from './data/site'
import { API_URL } from './config' import { API_URL } from './config'
import { getToken } from './admin/adminClient' import { getToken, setToken } from './admin/adminClient'
import { localizeSite } from './i18n/localize' import { localizeSite } from './i18n/localize'
import { useLang } from './i18n/lang' import { useLang } from './i18n/lang'
@@ -230,10 +230,16 @@ interface SiteContextValue {
const SiteContext = createContext<SiteContextValue>({ const SiteContext = createContext<SiteContextValue>({
data: null, data: null,
error: false, error: false,
// پیش‌فرض‌های بی‌اثر — مصرف‌کننده بیرون از Provider هم بدون خطا رندر می‌شود
// eslint-disable-next-line @typescript-eslint/no-empty-function
reload: () => {}, reload: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
updateSite: () => {}, updateSite: () => {},
}) })
/** سقف انتظار داده سایت — سرور بی‌پاسخ یعنی صفحه خطا با دکمه تلاش دوباره، نه انتظار بی‌پایان */
const SITE_TIMEOUT_MS = 15_000
export function SiteProvider({ children }: { children: ReactNode }) { export function SiteProvider({ children }: { children: ReactNode }) {
const [data, setData] = useState<SiteData | null>(null) const [data, setData] = useState<SiteData | null>(null)
const [error, setError] = useState(false) const [error, setError] = useState(false)
@@ -241,7 +247,9 @@ export function SiteProvider({ children }: { children: ReactNode }) {
const load = useCallback(() => { const load = useCallback(() => {
let cancelled = false let cancelled = false
setError(false) setError(false)
fetch(`${API_URL}/api/site`) const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
fetch(`${API_URL}/api/site`, { signal: controller.signal })
.then((res) => { .then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<SiteData> return res.json() as Promise<SiteData>
@@ -255,14 +263,17 @@ export function SiteProvider({ children }: { children: ReactNode }) {
const res = await fetch(`${API_URL}/api/admin/articles`, { const res = await fetch(`${API_URL}/api/admin/articles`, {
headers: { Authorization: `Bearer ${getToken()}` }, headers: { Authorization: `Bearer ${getToken()}` },
}) })
if (res.ok) { if (res.status === 401) {
// توکن منقضی/نامعتبر — پاک می‌شود تا بارگذاری‌های بعدی درخواست بی‌فایده نفرستند
setToken(null)
} else if (res.ok) {
const all = (await res.json()) as Article[] const all = (await res.json()) as Article[]
const published = new Set(json.articles.map((a) => a.id)) const published = new Set(json.articles.map((a) => a.id))
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id)) const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
json.articles = [...json.articles, ...drafts] json.articles = [...json.articles, ...drafts]
} }
} catch { } catch {
/* بدون پیش‌نویس‌ها ادامه بده */ /* بدون پیش‌نویس‌ها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
} }
} }
if (!cancelled) { if (!cancelled) {
@@ -275,6 +286,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
}) })
return () => { return () => {
cancelled = true cancelled = true
window.clearTimeout(timeoutId)
} }
}, []) }, [])
+13 -14
View File
@@ -1,4 +1,3 @@
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'
@@ -11,43 +10,43 @@ export function ArticleCard({ article }: { article: Article }) {
return ( return (
<Link <Link
to={href(`/blog/${article.slug}`)} to={href(`/blog/${article.slug}`)}
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper" className="group flex h-full flex-col overflow-hidden rounded-xl md:rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
aria-label={`${t('readArticlePrefix')} ${article.title}`} aria-label={`${t('readArticlePrefix')} ${article.title}`}
> >
<div className="relative overflow-hidden"> <div className="relative overflow-hidden aspect-[16/10] w-full">
<img <img
src={article.image} src={article.image}
alt={`${t('articleImagePrefix')} ${article.title}`} alt={`${t('articleImagePrefix')} ${article.title}`}
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105" className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
/> />
{/* برچسب دسته‌بندی کپسولی شیشه‌ای روی تصویر */} {/* برچسب دسته‌بندی کپسولی شیشه‌ای روی تصویر */}
<div className="absolute top-4 ltr:left-4 rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]"> <div className="absolute top-3 ltr:left-3 rtl:right-3 md:top-4 md:ltr:left-4 md:rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-2.5 py-0.5 md:px-3.5 md:py-1 text-[11px] md:text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
{article.category} {article.category}
</div> </div>
</div> </div>
<div className="flex flex-1 flex-col p-7"> <div className="flex flex-1 flex-col p-3.5 md:p-8">
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper"> <h3 className="mb-1.5 md:mb-6 text-xs md:text-xl font-bold leading-snug md:leading-relaxed text-ivory line-clamp-2 transition-colors duration-300 group-hover:text-copper">
{article.title} {article.title}
</h3> </h3>
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3"> <p className="mb-2.5 md:mb-10 text-[11px] md:text-[15px] font-light leading-relaxed md:leading-loose text-ivory/60 line-clamp-2 md:line-clamp-3">
{article.excerpt} {article.excerpt}
</p> </p>
<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-2.5 md:pt-8 text-[11px] md:text-xs font-light text-ivory/50">
<span className="flex items-center gap-3"> <span className="flex items-center gap-2.5 md:gap-4">
<span>{formatJalali(article.date)}</span> <span>{formatJalali(article.date)}</span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5 md:gap-2">
<ClockIcon className="h-3.5 w-3.5 text-copper/80" /> <ClockIcon className="h-3 w-3 md:h-3.5 md:w-3.5 text-copper/80" />
{toPersianDigits(article.read)} {toPersianDigits(article.read)}
</span> </span>
</span> </span>
{/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */} {/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */}
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]"> <span className="inline-flex items-center gap-1.5 md:gap-2 rounded-full border border-copper/40 bg-copper/5 px-2.5 py-1 md:px-3.5 md:py-1.5 text-[11px] md:text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)] rtl:group-hover:[&>svg]:-translate-x-1 ltr:group-hover:[&>svg]:translate-x-1">
{t('readMore')} {t('readMore')}
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" /> <ArrowLeftIcon className="h-3 w-3 md:h-3.5 md:w-3.5 ltr:-scale-x-100 transition-transform duration-300" />
</span> </span>
</div> </div>
</div> </div>
+65
View File
@@ -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>
)
}
-110
View File
@@ -1,110 +0,0 @@
import React from 'react'
import { Link } from 'react-router-dom'
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
type BaseProps = {
children: React.ReactNode
variant?: Variant
className?: string
}
// استایل‌های فوق‌لوکس شیشه‌ای (Glassmorphic) با رنگ‌های اختصاصی پروژه
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
copper: {
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
text: 'group-hover:text-char font-semibold',
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
},
outline: {
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبه‌ها
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
fill: 'bg-white/10',
text: 'group-hover:text-copper',
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
},
dark: {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-copper',
text: 'group-hover:text-char',
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
},
'dark-ivory': {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-ivory',
text: 'group-hover:text-char',
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
},
}
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
const style = STYLES[variant]
return (
<>
{/* خط انعکاس نوری شیشه در بالای دکمه */}
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
{/* انیمیشن پر شدن موجی و نرم */}
<span
aria-hidden="true"
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
/>
{/* متن و آیکون دکمه */}
<span
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
>
{children}
</span>
</>
)
}
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشه‌ای
const SHELL =
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
export function ButtonLink({
to,
children,
variant = 'copper',
className = '',
}: BaseProps & { to: string }) {
const style = STYLES[variant]
return (
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
<Inner variant={variant}>{children}</Inner>
</Link>
)
}
export function ButtonAnchor({
href,
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
const style = STYLES[variant]
return (
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</a>
)
}
export function Button({
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const style = STYLES[variant]
return (
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</button>
)
}
+251
View File
@@ -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>
)}
</>
);
}
+153 -23
View File
@@ -1,20 +1,21 @@
import React, { useState } from 'react' import { useState } from 'react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react' import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import { Reveal } from './Reveal' import { Reveal } from './Reveal'
import { Button } from './Button' import { PremiumButton } from './PremiumButton'
import { useSite } from '../api' import { useSite } from '../api'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { useFieldSaver } from '../admin/AdminContext' 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'))
@@ -98,18 +100,139 @@ export function ContactSection() {
} }
} }
const cleanOptionalLabel = `(${content.note_optional_label?.replace(/^\(+|\)+$/g, '') || 'اختیاری'})`
return ( return (
<section id="contact" className="relative overflow-hidden py-24 lg:py-32" aria-labelledby="contact-title"> <section id="contact" className="relative w-full max-w-full overflow-x-clip border-t border-copper/15 py-16 md:py-24 lg:py-32" aria-labelledby="contact-title">
<img <img
src={brand.contact_bg} src={brand.contact_bg}
alt="" alt=""
aria-hidden="true" aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover object-center"
/> />
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" /> <div className="absolute inset-0 bg-gradient-to-b from-ink/90 via-ink/75 to-ink backdrop-blur-[2px]" />
<div className="relative mx-auto max-w-site px-6 lg:px-10"> <div className="relative mx-auto max-w-site px-4 md:px-6 lg:px-10">
<div className="mx-auto grid max-w-6xl items-stretch gap-8 lg:grid-cols-12">
{/* Mobile View: High-Conversion Streamlined Layout */}
<div className="block md:hidden px-1 py-1">
{/* Header */}
<div className="mb-4 text-center">
<p className="text-xs font-light tracking-wide text-copper">
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
</p>
<h2 className="mt-1.5 text-xl font-bold text-ivory">
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
</h2>
<p className="mt-1.5 text-xs font-light leading-relaxed text-ivory/70 max-w-sm mx-auto">
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
</p>
</div>
{/* High-Conversion Form Box */}
<div className="rounded-2xl border border-white/10 bg-char/60 p-4 backdrop-blur-xl shadow-lg space-y-3">
{sent ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-copper">
<CheckCircle2Icon className="h-6 w-6" />
</div>
<h3 className="mt-4 text-base font-bold text-ivory">
{content.success_title}
</h3>
<p className="mt-2 text-xs font-light leading-relaxed text-ivory/70">
{content.success_text}
</p>
<button
type="button"
onClick={() => {
setSent(false)
setForm({ name: '', phone: '', note: '', website: '' })
}}
className="mt-4 rounded-xl bg-copper px-6 py-2.5 text-xs font-bold text-char transition-all cursor-pointer"
>
{content.success_button_label}
</button>
</div>
) : (
<form onSubmit={submit} noValidate className="space-y-3">
<input
type="text"
name="website_mobile"
value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
/>
<div>
<label htmlFor="contact-name-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
{content.name_label}
</label>
<input
id="contact-name-mobile"
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={content.name_placeholder}
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none"
/>
</div>
<div>
<label htmlFor="contact-phone-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
{content.phone_field_label} <span className="text-copper">*</span>
</label>
<input
id="contact-phone-mobile"
type="tel"
dir="ltr"
required
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder={content.phone_placeholder}
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none text-left font-mono"
/>
</div>
<div>
<label htmlFor="contact-note-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
{content.note_label} <span className="font-light text-ivory/50">{cleanOptionalLabel}</span>
</label>
<textarea
id="contact-note-mobile"
rows={2}
value={form.note}
onChange={(e) => setForm({ ...form, note: e.target.value })}
placeholder={content.note_placeholder}
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none resize-none leading-relaxed"
/>
</div>
<button
type="submit"
disabled={sending}
className="w-full py-3 rounded-xl bg-copper text-char font-bold text-xs shadow-md hover:brightness-110 active:scale-95 transition-all flex items-center justify-center gap-2 cursor-pointer"
>
{sending ? content.sending_label : content.submit_label}
</button>
{/* Direct Call Fallback */}
<a
href={brand.phone_href}
onClick={() => track('phone-click', { from: 'contact-section-mobile-hotline' })}
className="flex items-center justify-center gap-1.5 pt-2 text-[11px] text-ivory/60 hover:text-copper transition-colors"
>
<span>نیاز به گفتگوی فوری دارید؟</span>
<span className="font-bold text-copper dir-ltr" dir="ltr">{brand.phone}</span>
</a>
</form>
)}
</div>
</div>
{/* Desktop View */}
<div className="hidden md:grid mx-auto max-w-6xl items-stretch gap-8 lg:grid-cols-12">
{/* باکس اطلاعات تماس */} {/* باکس اطلاعات تماس */}
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5"> <div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
@@ -152,6 +275,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)} />
@@ -194,20 +318,29 @@ export function ContactSection() {
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70"> <p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} /> <InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
</p> </p>
<Button <PremiumButton
type="button"
variant="outline"
className="mt-8 font-light"
onClick={() => { onClick={() => {
setSent(false) setSent(false)
setForm({ name: '', phone: '', note: '' }) setForm({ name: '', phone: '', note: '', website: '' })
}} }}
className="mt-8 font-light"
> >
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} /> <InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
</Button> </PremiumButton>
</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)} />
@@ -240,7 +373,7 @@ export function ContactSection() {
<div className="md:col-span-2"> <div className="md:col-span-2">
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90"> <label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{content.note_optional_label}</span> <InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{cleanOptionalLabel}</span>
</label> </label>
<textarea <textarea
id="contact-note" id="contact-note"
@@ -253,26 +386,23 @@ export function ContactSection() {
</div> </div>
<div className="md:col-span-2 flex justify-start"> <div className="md:col-span-2 flex justify-start">
<Button <PremiumButton
type="submit" type="submit"
variant="copper"
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3" className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
disabled={sending} disabled={sending}
> >
{sending ? ( {sending ? (
content.sending_label content.sending_label
) : ( ) : (
<>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} /> <InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
</>
)} )}
</Button> </PremiumButton>
</div> </div>
</form> </form>
)} )}
</div> </div>
</div> </div>
</div> </div>
</section> </section>
) )
@@ -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"
> >
{/* پالس هاله نوری در پس‌زمینه مانند رادار */} {/* پالس هاله نوری در پس‌زمینه مانند رادار */}
+142 -4
View File
@@ -1,4 +1,4 @@
import React from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'; import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
import { useSite } from '../api'; import { useSite } from '../api';
@@ -17,6 +17,7 @@ export function Footer() {
const { brand, services, nav, footer } = useSite(); const { brand, services, nav, footer } = useSite();
const { t, href } = useLang(); const { t, href } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
const [openSection, setOpenSection] = useState<string | null>(null);
const saveFooter = (field: string, value: string) => const saveFooter = (field: string, value: string) =>
save('footer', footer.id, { [field]: value }, (d) => { save('footer', footer.id, { [field]: value }, (d) => {
@@ -34,8 +35,143 @@ export function Footer() {
}; };
return ( return (
<footer className="relative border-t border-white/10 bg-char/95 backdrop-blur-2xl"> <footer className="relative w-full max-w-full overflow-x-clip border-t border-white/10 bg-char/95 backdrop-blur-2xl">
<div className="mx-auto max-w-site px-6 py-16 lg:px-10"> <div className="mx-auto max-w-site px-4 py-8 md:px-6 md:py-16 lg:px-10">
{/* Mobile View: Collapsible Luxury Accordions */}
<div className="block md:hidden">
{/* Top Brand Block */}
<div className="mb-6 flex flex-col items-center text-center">
<div className="relative w-fit">
<img src={brand.logo} alt={brand.name} className="h-16 w-auto object-contain" />
<SingletonPill resource="brand" label="ویرایش برند" className="absolute top-1/2 -translate-y-1/2 ltr:-right-8 rtl:-left-8" />
</div>
<p className="mt-4 text-xs font-light leading-relaxed text-ivory/60 max-w-xs">
{footer.about_text}
</p>
{/* Social icons */}
<div className="mt-4 flex items-center justify-center gap-3">
{footer.socials.map(({ label, href, icon }) => {
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
return (
<a
key={label}
href={href}
aria-label={label}
className="group flex h-10 w-10 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:border-copper/70 hover:bg-copper/10 hover:text-copper active:scale-95"
>
<Icon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:scale-110" />
</a>
);
})}
</div>
</div>
{/* Collapsible Link Groups */}
<div className="border-t border-white/10">
{/* Pages Accordion */}
<div className="border-b border-white/10">
<button
type="button"
onClick={() => setOpenSection(openSection === 'pages' ? null : 'pages')}
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
>
<span>{footer.pages_heading}</span>
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'pages' ? 'rotate-180' : ''}`}>
▼
</span>
</button>
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'pages' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
<ul className="space-y-2 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
{nav.map((item) => (
<li key={item.id}>
<Link
to={href(item.href)}
className="block py-1 transition-colors hover:text-copper"
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* Services Accordion */}
<div className="border-b border-white/10">
<button
type="button"
onClick={() => setOpenSection(openSection === 'services' ? null : 'services')}
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
>
<span>{footer.services_heading}</span>
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'services' ? 'rotate-180' : ''}`}>
▼
</span>
</button>
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'services' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
<ul className="space-y-2 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
{services.map((service) => (
<li key={service.id} className="py-1">
{service.title}
</li>
))}
</ul>
</div>
</div>
{/* Contact Accordion */}
<div className="border-b border-white/10">
<button
type="button"
onClick={() => setOpenSection(openSection === 'contact' ? null : 'contact')}
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
>
<span>{footer.contact_heading}</span>
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'contact' ? 'rotate-180' : ''}`}>
▼
</span>
</button>
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'contact' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
<ul className="space-y-3 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
<li className="flex items-start gap-2.5">
<MapPinIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-copper" />
<span className="leading-relaxed">{brand.address}</span>
</li>
<li className="flex items-center gap-2.5">
<PhoneIcon className="h-3.5 w-3.5 shrink-0 text-copper" />
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
{brand.phone}
</a>
</li>
<li className="flex items-center gap-2.5">
<MailIcon className="h-3.5 w-3.5 shrink-0 text-copper" />
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
{brand.email}
</a>
</li>
</ul>
</div>
</div>
</div>
{/* Bottom Copyright & Meta for Mobile */}
<div className="mt-6 flex flex-col items-center gap-3 border-t border-white/10 pt-4 text-[11px] text-ivory/50 text-center">
<button
type="button"
onClick={scrollToTop}
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-3.5 py-1.5 text-xs font-medium text-ivory/70 backdrop-blur-xl transition-all hover:border-copper hover:bg-copper/10 hover:text-copper active:scale-95 cursor-pointer mb-1"
>
<span>{t('backToTop')}</span>
<ArrowUpIcon className="h-3 w-3 text-copper transition-transform duration-300 group-hover:-translate-y-0.5" />
</button>
<p>{footer.copyright}</p>
<p className="text-[10px] text-ivory/40">{footer.tagline}</p>
</div>
</div>
{/* Desktop View: Multi-Column Layout */}
<div className="hidden md:block">
<div className="grid gap-12 lg:grid-cols-12"> <div className="grid gap-12 lg:grid-cols-12">
<div className="lg:col-span-4"> <div className="lg:col-span-4">
<div className="relative w-fit"> <div className="relative w-fit">
@@ -45,7 +181,7 @@ export function Footer() {
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60"> <p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} /> <InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
</p> </p>
{/* آیکون‌های شبکه‌های اجتماعی: گوی‌های شیشه‌ای شناور */} {/* آیکون‌های شبکه‌های اجتماعی */}
<div className="mt-8 flex items-center gap-3"> <div className="mt-8 flex items-center gap-3">
{footer.socials.map(({ label, href, icon }) => { {footer.socials.map(({ label, href, icon }) => {
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon; const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
@@ -149,6 +285,8 @@ export function Footer() {
</p> </p>
</div> </div>
</div> </div>
</div>
</footer> </footer>
); );
} }
+70 -5
View File
@@ -1,6 +1,18 @@
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { LanguagesIcon } from 'lucide-react'
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang' import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
import { track } from '../analytics'
/**
* تنظیمات ثابت هر زبان.
* ترتیب اینجا = ترتیب نمایش در کپسول (فارسی سمت چپ، English سمت راست).
* بجای مقادیر پیکسلی، از translate-x-full استفاده شده تا دقیقاً به اندازه عرض خودش (50%) جابجا شود.
*/
const LANGS = {
fa: { label: 'فارسی', sliderTranslate: 'translate-x-0' },
en: { label: 'English', sliderTranslate: 'translate-x-full' },
} as const
type LangKey = keyof typeof LANGS
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */ /** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */
export function LangToggle({ className = '' }: { className?: string }) { export function LangToggle({ className = '' }: { className?: string }) {
@@ -10,7 +22,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)
@@ -22,10 +36,61 @@ export function LangToggle({ className = '' }: { className?: string }) {
onClick={switchLang} onClick={switchLang}
aria-label={t('switchLangAria')} aria-label={t('switchLangAria')}
title={t('switchLangAria')} title={t('switchLangAria')}
className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 cursor-pointer ${className}`} dir="ltr"
className={[
// ── Capsule shell ──────────────────────────────────────────────
'relative flex items-center',
'w-[152px] h-[42px] rounded-full p-1', // استفاده از p-1 (4px) به جای [4px]
'bg-[#1a1715]',
// deep inset shadow → recessed track feel
'shadow-[inset_0_3px_7px_rgba(0,0,0,0.75),inset_0_-1px_2px_rgba(255,255,255,0.04)]',
// interaction
'cursor-pointer select-none',
'active:scale-[0.975] transition-transform duration-150 ease-lux',
className,
].join(' ')}
> >
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" /> {/* ── Inner Track ───────────────────────────────────────────────
<span>{t('switchLang')}</span> این دیو داخلی باعث می‌شود پدینگ دکمه اصلی روی محاسبات 50 درصدی تاثیر نگذارد
*/}
<div className="relative w-full h-full flex items-center">
{/* ── Slider pill ─────────────────────────────────────────────── */}
<span
aria-hidden="true"
className={[
'absolute top-0 left-0', // چسبیده به لبه‌های Track داخلی
'w-1/2 h-full rounded-full', // دقیقاً 50 درصد عرض و کل ارتفاع
'bg-[#27221c] border border-[#d5874c]',
// subtle raised shadow — tactile without being flashy
'shadow-[0_2px_6px_rgba(0,0,0,0.55),0_0_0_1px_rgba(213,135,76,0.18),inset_0_1px_1px_rgba(255,255,255,0.06)]',
'transition-transform duration-[380ms] ease-lux',
LANGS[lang as LangKey].sliderTranslate,
].join(' ')}
/>
{/* ── Labels ──────────────────────────────────────────────────── */}
<span className="relative z-10 flex w-full h-full">
{(Object.keys(LANGS) as LangKey[]).map((key) => {
const isActive = lang === key
return (
<span
key={key}
className={[
// each label occupies exactly half the capsule
'w-1/2 h-full flex justify-center items-center',
'text-[11.5px] font-semibold tracking-wide leading-none',
'transition-colors duration-[380ms] ease-lux',
isActive ? 'text-[#d5874c]' : 'text-[#6a6662]',
].join(' ')}
>
{LANGS[key].label}
</span>
)
})}
</span>
</div>
</button> </button>
) )
} }
+33 -21
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect } from 'react' import React, { useCallback, useEffect } from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react' import { MdClose, MdChevronLeft, MdChevronRight } from 'react-icons/md'
type Props = { type Props = {
images: string[] images: string[]
@@ -26,8 +26,8 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
if (!isOpen) return if (!isOpen) return
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight') prev() if (e.key === 'ArrowRight') next()
if (e.key === 'ArrowLeft') next() if (e.key === 'ArrowLeft') prev()
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
@@ -43,55 +43,67 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
<motion.div <motion.div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
className="fixed inset-0 z-[90] flex items-center justify-center p-5 md:p-10" className="fixed inset-0 z-[90] flex items-center justify-center p-4 md:p-10"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
> >
{/* Solid Brand Background */}
<div <div
className="absolute inset-0 bg-ink/90 backdrop-blur-md" className="absolute inset-0 bg-[#27221c]"
onClick={onClose} onClick={onClose}
aria-hidden="true" aria-hidden="true"
/> />
{/* Close Button at Top Right */}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="absolute left-6 top-6 z-10 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10 md:top-10" className="absolute right-6 top-6 z-10 flex h-10 w-10 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
> >
<XIcon className="h-5 w-5" /> <MdClose size={24} />
</button> </button>
{/* Main Content Area */}
<div className="relative z-10 flex w-full max-w-7xl items-center justify-center gap-4 md:gap-8">
{/* Prev Button */}
<button
type="button"
onClick={prev}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
>
<MdChevronRight size={28} />
</button>
{/* Image */}
<motion.img <motion.img
key={index} key={index}
src={images[index as number]} src={images[index as number]}
alt="" alt=""
className="relative max-h-[80vh] w-auto max-w-full object-contain" className="max-h-[85vh] w-auto max-w-[calc(100%-7rem)] rounded-md object-contain shadow-2xl"
initial={{ opacity: 0, scale: 0.96 }} initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }} exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
/> />
<div className="absolute bottom-6 z-10 flex items-center gap-4 md:bottom-10"> {/* Next Button */}
<button
type="button"
onClick={prev}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
>
<ChevronRightIcon className="h-5 w-5" />
</button>
<span className="text-sm font-light text-ivory/70" dir="ltr">
{(index as number) + 1} / {images.length}
</span>
<button <button
type="button" type="button"
onClick={next} onClick={next}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-[#c57941] focus:outline-none focus:ring-2 focus:ring-copper "
> >
<ChevronLeftIcon className="h-5 w-5" /> <MdChevronLeft size={28} />
</button> </button>
</div>
<div className="absolute bottom-6 z-10 flex items-center justify-center">
<span className="text-sm font-light text-copper" dir="ltr">
{(index as number) + 1} / {images.length}
</span>
</div> </div>
</motion.div> </motion.div>
)} )}
+62
View File
@@ -0,0 +1,62 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
type Props = {
to?: string
href?: string
children: React.ReactNode
className?: string
onClick?: () => void
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
}
/**
* Premium Button replicating the cinematic, high-end documentary aesthetic.
* Features an exact cubic-bezier background fill from the bottom, transforming
* text color smoothly to maintain contrast.
*/
export function PremiumButton({ to, href, children, className = '', onClick, disabled, type = 'button' }: Props) {
const navigate = useNavigate()
// Primary: ink, Secondary: copper
const baseClasses = `
group/btn relative inline-flex items-center justify-center overflow-hidden
rounded-full px-10 py-4 text-sm font-bold tracking-widest text-copper
border border-copper bg-ink
transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg
cursor-pointer select-none
${className}
`.trim()
const innerEffect = (
<>
<span className="absolute inset-0 z-0 bg-copper scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
{children}
</span>
</>
)
const handleClick = (e: React.MouseEvent) => {
if (onClick) onClick();
if (to) {
e.preventDefault();
navigate(to);
}
}
if (href) {
return (
<a href={href} className={baseClasses} onClick={handleClick}>
{innerEffect}
</a>
)
}
return (
<button type={type} disabled={disabled} className={baseClasses} onClick={handleClick}>
{innerEffect}
</button>
)
}
+57 -28
View File
@@ -1,60 +1,89 @@
import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ArrowLeftIcon } from 'lucide-react'
import { useStatusLabel } from '../api' import { useStatusLabel } from '../api'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { PremiumButton } from './PremiumButton'
import type { StaticProject } from '../data/projectsData'
import type { ProjectCardItem } from '../data/site'
export function ProjectCard({ project }: any) { type ProjectCardProps = {
project: StaticProject | ProjectCardItem;
imageUrl?: string;
detailHref?: string;
}
export function ProjectCard({ project, imageUrl, detailHref }: ProjectCardProps) {
const isOngoing = project.status === 'progress' const isOngoing = project.status === 'progress'
const statusText = useStatusLabel(project.status) const statusText = useStatusLabel(project.status)
const { t, href, lang } = useLang() const { t, lang } = useLang()
// Type guards for backward compatibility
const isStatic = 'name' in project;
// Resolve localized texts
const title = isStatic
? (project.name[lang as 'en' | 'fa'] || project.name.en)
: (project as ProjectCardItem).title;
const locRaw = isStatic ? project.location : (project as ProjectCardItem).location;
const location = typeof locRaw === 'string' ? locRaw : (locRaw?.[lang as 'en' | 'fa'] || locRaw?.en);
const areaRaw = isStatic ? project.area : (project as ProjectCardItem).area;
const area = typeof areaRaw === 'string' ? areaRaw : (areaRaw?.[lang as 'en' | 'fa'] || areaRaw?.en);
const finalImageUrl = imageUrl || (project as ProjectCardItem).image;
const finalDetailHref = detailHref || (project as ProjectCardItem).detailHref || '#';
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60' const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90' const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
return ( return (
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"> <article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
<Link to={href(`/projects/${project.slug || '#'}`)} className="flex h-full flex-col focus:outline-none"> <Link to={finalDetailHref} className="flex flex-1 flex-col focus:outline-none">
{/* Fixed height image wrapper */}
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشه‌ای */} <div className="relative w-full overflow-hidden">
<div className="relative aspect-[4/3] w-full overflow-hidden"> {finalImageUrl && (
<img <img
src={project.image} src={finalImageUrl}
alt={project.title} alt={title}
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105" className="w-full h-44 md:h-[250px] object-cover transition-transform duration-700 ease-lux group-hover:scale-110"
/> />
)}
{/* نشانگر وضعیت کپسولی شیشه‌ای */} {/* نشانگر وضعیت کپسولی شیشه‌ای */}
<div className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}> <div
className={`absolute top-3 ltr:left-3 rtl:right-3 md:top-4 md:ltr:left-4 md:rtl:right-4 flex items-center gap-1.5 md:gap-2 rounded-full border border-white/15 bg-ink/75 px-3 py-1 md:px-4 md:py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}
>
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} /> <span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
{statusText} {statusText}
</div> </div>
{/* گوی دکمه شیشه‌ای شناور برای ورود به پروژه */}
<div className="absolute bottom-4 ltr:right-4 rtl:left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
<ArrowLeftIcon className="h-5 w-5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
</div>
</div> </div>
<div className="flex flex-1 flex-col p-7"> <div className="flex flex-1 flex-col p-4 pb-3 md:p-7 md:pb-4">
<div className="text-center"> <div className="text-center">
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper"> <h3 className="font-pinar text-lg font-bold text-ivory transition-colors duration-300 group-hover:text-copper md:text-xl">
{project.title} {title}
</h3> </h3>
<p className="mt-4 text-sm font-light text-ivory/60"> {location && <p className="mt-2 text-xs font-light text-ivory/60 md:mt-4 md:text-sm">{location}</p>}
{project.location}
</p>
</div> </div>
<div className="my-6 h-px w-full bg-white/10" /> {area && (
<>
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60"> <div className="my-3 h-px w-full bg-white/10 md:my-6" />
<span>{t('yearLabel')} {project.year}</span> <div className="mt-auto flex items-center justify-end text-xs font-light text-ivory/60 md:text-sm">
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه می‌شود */} {/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه می‌شود */}
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span> <span>{lang === 'fa' ? `${area} ${t('sqm')}` : area}</span>
</div> </div>
</>
)}
</div> </div>
</Link> </Link>
{/* پایهٔ کارت: یک دکمه واحد */}
<div className="flex flex-col gap-2 px-4 pb-4 md:px-7 md:pb-7">
<PremiumButton to={finalDetailHref} className="w-full">
{t('viewProject')}
</PremiumButton>
</div>
</article> </article>
) )
} }
@@ -0,0 +1,19 @@
import { MdApartment } from 'react-icons/md'
import { useLang } from '../i18n/lang'
/**
* جای‌نگهدار تصویر پروژه — همان زبان بصری کارت پاپ‌آپ، برای هر پروژه‌ای که
* تصویری در داده ندارد (مثل ردیف‌های شیت). تک‌منبع این ظاهر همین‌جاست.
*/
export function ProjectImagePlaceholder({ className = '' }: { className?: string }) {
const { t } = useLang()
return (
<div
className={`flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-char to-ink text-ivory/25 ${className}`}
>
<MdApartment className="h-8 w-8" aria-hidden="true" />
<span className="text-[10px] font-light">{t('mapImagePlaceholder')}</span>
</div>
)
}
+43 -12
View File
@@ -6,23 +6,49 @@ import { slugifyFa, uniqueSlug } from '../utils/slug'
export type TocItem = { id: string; text: string; level: 2 | 3 } export type TocItem = { id: string; text: string; level: 2 | 3 }
/** /**
* استخراج فهرست مطالب از HTML ویرایشگر — تیترهای H2 و H3 شناسه لنگر می‌گیرند * متن یک تیتر از HTML — همه گره‌های تودرتو پیمایش می‌شوند و <br> به فاصله تبدیل
* (با slugify و پشتیبانی فارسی) و HTML برگشتی همان است که باید رندر شود. * می‌شود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
*/ */
export function extractToc(html: string): { items: TocItem[]; html: string } { 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 } if (!html || typeof window === 'undefined') return { items: [], html }
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')
const used = new Map<string, number>() const used = new Map<string, number>()
const items: TocItem[] = [] const items: TocItem[] = []
doc.body.querySelectorAll('h2, h3').forEach((el) => { doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim() const text = headingText(element).replace(/\s+/g, ' ').trim()
if (!text) return if (!text) return
const id = uniqueSlug(slugifyFa(text), used) // تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی می‌گیرد تا لنگرش بی‌نام نماند
el.setAttribute('id', id) const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
items.push({ id, text, level: el.tagName === 'H3' ? 3 : 2 }) element.setAttribute('id', id)
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
}) })
// تبدیل اختیاری (بسته‌بندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا می‌شود
transform?.(doc.body)
return { items, html: doc.body.innerHTML } return { items, html: doc.body.innerHTML }
} }
@@ -32,7 +58,11 @@ type TableOfContentsProps = {
className?: string className?: string
} }
/** فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */ /**
* فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
* همه اندازه‌ها و ترازبندی‌ها منطقی‌اند (start/end) تا در چیدمان راست‌به‌چپ و
* چپ‌به‌راست — بر اساس جهت مقاله — درست بنشیند.
*/
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) { export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '') const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
@@ -79,7 +109,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
{title} {title}
</p> </p>
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" /> <div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-r border-ivory/10 pr-3"> {/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود می‌شود تا بریده نشود */}
<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) => { {items.map((item) => {
const isActive = item.id === activeId const isActive = item.id === activeId
return ( return (
@@ -88,8 +119,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
type="button" type="button"
onClick={(event) => go(event, item.id)} onClick={(event) => go(event, item.id)}
aria-current={isActive ? 'location' : undefined} aria-current={isActive ? 'location' : undefined}
className={`block w-full text-right text-sm font-light leading-8 transition-colors duration-200 ease-lux ${ className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'pr-4 text-[13px]' : '' item.level === 3 ? 'ps-4 text-[13px]' : ''
} ${ } ${
isActive isActive
? 'font-normal text-copper' ? 'font-normal text-copper'
+178 -9
View File
@@ -25,10 +25,13 @@ import {
} from 'lucide-react' } from 'lucide-react'
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 { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
import { ArticleImage } from './ArticleImage' import { ArticleImage } from './ArticleImage'
import { BidiTypography, bidiPluginKey } from './BidiTypography'
import { EnglishText } from './EnglishText' import { EnglishText } from './EnglishText'
import { LinkModal } from './LinkModal' import { LinkModal } from './LinkModal'
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
import type { LinkModalValue } from './LinkModal' import type { LinkModalValue } from './LinkModal'
/** /**
@@ -59,13 +62,22 @@ export const ARTICLE_EXTENSIONS = [
}), }),
LegacySmallSizeMark, LegacySmallSizeMark,
EnglishText, 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 TextDirection = 'rtl' | 'ltr'
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپ‌شده */ /** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپ‌شده */
@@ -90,6 +102,42 @@ const CLOSED_LINK_MODAL: LinkModalState = {
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپ‌شده بررسی می‌شود */ /** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپ‌شده بررسی می‌شود */
const URL_LOOKBACK = 300 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({
onClick, onClick,
active = false, active = false,
@@ -126,12 +174,14 @@ const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="t
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشن‌دار، مودال لینک سئو-محور، * ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشن‌دار، مودال لینک سئو-محور،
* کلید راست‌چین/چپ‌چین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO. * کلید راست‌چین/چپ‌چین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به 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 [direction, setDirection] = useState<TextDirection>('rtl')
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL) 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,
@@ -151,6 +201,22 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
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() ?? '' const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
if (isUrlLike(text)) { if (isUrlLike(text)) {
@@ -174,6 +240,21 @@ 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
}, },
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز می‌شود // پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز می‌شود
@@ -192,6 +273,8 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
}) })
return false return false
}, },
// Enter داخل تیتر، بلوک تازه را پاراگراف می‌کند تا قالب تیتر سرایت نکند
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
}, },
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()), onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
}) })
@@ -201,14 +284,40 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
if (!editor) return if (!editor) return
editor.view.dom.setAttribute('dir', direction) editor.view.dom.setAttribute('dir', direction)
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en') editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
// تزئین‌های دوزبانه (اندازه ۲ پیکسل کوچک‌تر متن لاتین) با جهت تازه بازمحاسبه می‌شوند
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, direction))
}, [editor, direction]) }, [editor, direction])
async function uploadAndInsert(file: File) { /**
* ترکیب (نمونه ویرایشگر، شناسه محتوا) که آخرین بار بارگذاری شده است.
* تغییرهای معمول سند (تایپ کاربر) نباید بدنه را از نو بارگذاری کنند.
*/
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)
} }
@@ -271,28 +380,28 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
<ToolButton <ToolButton
label="پاراگراف" label="پاراگراف"
active={editor.isActive('paragraph')} active={editor.isActive('paragraph')}
onClick={() => editor.chain().focus().setParagraph().run()} onClick={() => applyBlockFormat(editor.view, 'paragraph')}
> >
<PilcrowIcon className="h-4 w-4" /> <PilcrowIcon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
label="تیتر ۱" label="تیتر ۱"
active={editor.isActive('heading', { level: 1 })} active={editor.isActive('heading', { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} onClick={() => applyBlockFormat(editor.view, 1)}
> >
<Heading1Icon className="h-4 w-4" /> <Heading1Icon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
label="تیتر ۲" label="تیتر ۲"
active={editor.isActive('heading', { level: 2 })} active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} onClick={() => applyBlockFormat(editor.view, 2)}
> >
<Heading2Icon className="h-4 w-4" /> <Heading2Icon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
label="تیتر ۳" label="تیتر ۳"
active={editor.isActive('heading', { level: 3 })} active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} onClick={() => applyBlockFormat(editor.view, 3)}
> >
<Heading3Icon className="h-4 w-4" /> <Heading3Icon className="h-4 w-4" />
</ToolButton> </ToolButton>
@@ -371,12 +480,72 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (file) void uploadAndInsert(file) if (file) {
setPendingImage({ file, name: file.name.replace(/\.[^.]+$/, '') })
}
e.target.value = '' e.target.value = ''
}} }}
/> />
</div> </div>
{/* مرحله نام‌گذاری تصویر — نام خوانای ذخیره‌سازی با یک کلیک تایید می‌شود */}
{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>
)}
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاس‌های Tailwind مدیریت می‌کند */} {/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاس‌های Tailwind مدیریت می‌کند */}
{linkModal.open && ( {linkModal.open && (
<LinkModal <LinkModal
@@ -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)
}
+54 -46
View File
@@ -1,8 +1,9 @@
import { useEffect, useId, useRef, useState } from 'react' import { useId, useRef, useState } from 'react'
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react' import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { DEFAULT_IMAGE_TITLE } from '../../data/constants' 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
@@ -30,6 +31,7 @@ const META_FIELD_CLASS =
* انتخاب و آپلود تصویر به MinIO با پیش‌نمایش بزرگ. * انتخاب و آپلود تصویر به MinIO با پیش‌نمایش بزرگ.
* ترتیب فیلدها: ۱) عنوان تصویر (پیش‌فرض «Photo 1») ۲) متن جایگزین (پیش‌فرض «{عنوان} - تصویر شاخص»). * ترتیب فیلدها: ۱) عنوان تصویر (پیش‌فرض «Photo 1») ۲) متن جایگزین (پیش‌فرض «{عنوان} - تصویر شاخص»).
* هر دو «دستی یا پیش‌فرض» هستند و Alt هرگز از URL ساخته نمی‌شود. * هر دو «دستی یا پیش‌فرض» هستند و Alt هرگز از URL ساخته نمی‌شود.
* پیش‌فرض خودکار با فوکوس پاک می‌شود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمی‌گردد.
*/ */
export function ImageField({ export function ImageField({
label, label,
@@ -46,61 +48,49 @@ export function ImageField({
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const titleId = useId() const titleId = useId()
const altId = useId() const altId = useId()
const nameId = useId()
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [altText, setAltText] = useState(alt ?? '') /** نام دلخواه فایل برای ذخیره‌سازی خوانا — پیش‌فرض از نام فایل انتخابی ساخته می‌شود */
const [titleText, setTitleText] = useState(title ?? '') const [fileBaseName, setFileBaseName] = useState('')
const showTitle = title !== undefined || !!onSaveTitle const showTitle = title !== undefined || !!onSaveTitle
const showAlt = alt !== undefined || !!onSaveAlt const showAlt = alt !== undefined || !!onSaveAlt
// همگام‌سازی با مقادیر ذخیره‌شده (پس از به‌روزرسانی خوش‌بینانه در درخت داده) const titleField = useSmartTextField({
useEffect(() => { value: title ?? '',
setAltText(alt ?? '') fallback: titleFallback,
}, [alt]) hardFallback: DEFAULT_IMAGE_TITLE,
useEffect(() => { onCommit: onSaveTitle,
setTitleText(title ?? '') })
}, [title]) const altField = useSmartTextField({
value: alt ?? '',
fallback: altFallback,
hardFallback: label,
onCommit: onSaveAlt,
})
if (!isAuthenticated || !isEditMode) return null if (!isAuthenticated || !isEditMode) return null
/** عنوان نمایشی: ورودی کاربر → پیشنهاد → «Photo 1» (با تغییر عنوان مقاله زنده به‌روز می‌شود) */
const titleDisplayed = titleText.trim() || titleFallback.trim() || DEFAULT_IMAGE_TITLE
/** Alt نمایشی: ورودی کاربر → پیشنهاد عنوان‌محور (هرگز URL) */
const altDisplayed = altText.trim() || altFallback.trim()
const resolveTitle = () => titleDisplayed
const resolveAlt = () => altDisplayed || label
const persistMeta = async () => {
const nextTitle = resolveTitle()
const nextAlt = resolveAlt()
setTitleText(nextTitle)
setAltText(nextAlt)
if (showTitle && nextTitle !== (title ?? '').trim() && onSaveTitle) await onSaveTitle(nextTitle)
if (showAlt && nextAlt !== (alt ?? '').trim() && onSaveAlt) await onSaveAlt(nextAlt)
}
const pick = async (file: File | undefined) => { const pick = async (file: File | undefined) => {
if (!file) return if (!file) return
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('ذخیره نشد')
await persistMeta() // پیش‌فرض‌های Alt/Title همان لحظه ثبت می‌شوند تا تصویر بدون متادیتا نماند
if (showTitle) await titleField.commit()
if (showAlt) await altField.commit()
}) })
setFileBaseName('')
} finally { } finally {
setUploading(false) setUploading(false)
} }
} }
const commitMeta = () => {
if (!onSaveTitle && !onSaveAlt) return
void persistMeta()
}
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>
@@ -110,8 +100,8 @@ export function ImageField({
{value ? ( {value ? (
<img <img
src={value} src={value}
alt={altText || label} alt={altField.display || label}
title={titleText || undefined} title={titleField.display || undefined}
className="h-[27rem] w-full object-cover" className="h-[27rem] w-full object-cover"
/> />
) : ( ) : (
@@ -122,7 +112,7 @@ export function ImageField({
)} )}
</div> </div>
<div className="mt-3"> <div className="mt-3 flex flex-wrap items-end gap-3">
<button <button
type="button" type="button"
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
@@ -136,6 +126,22 @@ export function ImageField({
)} )}
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'} {uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
</button> </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>
{(showTitle || showAlt) && ( {(showTitle || showAlt) && (
@@ -149,10 +155,11 @@ export function ImageField({
id={titleId} id={titleId}
type="text" type="text"
dir="auto" dir="auto"
value={titleDisplayed} value={titleField.display}
onChange={(event) => setTitleText(event.target.value)} onChange={(event) => titleField.onChange(event.target.value)}
onBlur={commitMeta} onFocus={titleField.onFocus}
placeholder={titleFallback || DEFAULT_IMAGE_TITLE} onBlur={titleField.onBlur}
placeholder={titleFallback.trim() || DEFAULT_IMAGE_TITLE}
className={META_FIELD_CLASS} className={META_FIELD_CLASS}
/> />
</div> </div>
@@ -167,11 +174,12 @@ export function ImageField({
id={altId} id={altId}
type="text" type="text"
dir="auto" dir="auto"
value={altDisplayed} value={altField.display}
onChange={(event) => setAltText(event.target.value)} onChange={(event) => altField.onChange(event.target.value)}
onBlur={commitMeta} onFocus={altField.onFocus}
onBlur={altField.onBlur}
placeholder={ placeholder={
altFallback || 'توضیح کوتاه تصویر برای سئو و صفحه‌خوان‌ها' altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحه‌خوان‌ها'
} }
className={META_FIELD_CLASS} className={META_FIELD_CLASS}
/> />
+101 -23
View File
@@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { NodeViewWrapper } from '@tiptap/react' import { NodeViewWrapper } from '@tiptap/react'
import type { NodeViewProps } from '@tiptap/react' import type { NodeViewProps } from '@tiptap/react'
import { PlusIcon, XIcon } from 'lucide-react' import { CloudUploadIcon, PlusIcon, XIcon } from 'lucide-react'
import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext'
type ImageAttrs = { type ImageAttrs = {
src: string src: string
@@ -13,29 +15,72 @@ type ImageAttrs = {
const DELETE_BUTTON_CLASS = 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' '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> * <figure><img><figcaption>کپشن</figcaption></figure>
* دو دکمه حذف مستقل دارد: یکی برای خود تصویر و یکی برای حذف کامل کپشن. *
* با حذف کپشن، عنصر <figcaption> به‌طور کامل از DOM حذف می‌شود و فقط تصویر می‌ماند. * ویرایش کاملاً درون‌خطی است (بدون پاپ‌اور/مودال):
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره می‌شود.
* - دکمه X کپشن را کامل حذف می‌کند؛ «افزودن کپشن» دوباره جعبه را باز می‌کند.
* - Alt و Title تصویر (نام تصویر برای سئو/دسترس‌پذیری) پس از درج هم قابل ویرایش‌اند.
* - تصویر میزبان خارجی دکمه «میزبانی مجدد» دارد: تصویر دانلود و در Object Storage
* خود سایت ذخیره می‌شود (ترمیم تصاویر چسبانده‌شده از Outline).
* - دکمه X روی تصویر کل تصویر را حذف می‌کند.
*/ */
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) { export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
const { src, alt, title, caption } = node.attrs as ImageAttrs const { src, alt, title, caption } = node.attrs as ImageAttrs
const { runSave } = useAdmin()
const hasCaption = Boolean((caption ?? '').trim()) const hasCaption = Boolean((caption ?? '').trim())
const [editingCaption, setEditingCaption] = useState(false) const [captionOpen, setCaptionOpen] = useState(true)
const inputRef = useRef<HTMLInputElement>(null) const [rehosting, setRehosting] = useState(false)
const captionInputRef = useRef<HTMLInputElement>(null)
const focusOnOpen = useRef(false)
const showCaption = captionOpen || hasCaption
const external = isExternalImage(src)
const showCaption = hasCaption || editingCaption // فقط وقتی کاربر «افزودن کپشن» را می‌زند ورودی فوکوس می‌گیرد (نه هنگام بارگذاری مقاله)
// هنگام افزودن کپشن تازه، ورودی خودکار فوکوس می‌گیرد
useEffect(() => { useEffect(() => {
if (editingCaption && !hasCaption) inputRef.current?.focus() if (!focusOnOpen.current) return
}, [editingCaption, hasCaption]) focusOnOpen.current = false
captionInputRef.current?.focus()
}, [captionOpen])
/** حذف کپشن: مقدار صفت پاک و عنصر figcaption کاملاً unmount می‌شود */ /** حذف کامل کپشن: عنصر figcaption و ورودی‌اش از DOM ویرایشگر بیرون می‌رود */
const removeCaption = () => { const removeCaption = () => {
updateAttributes({ caption: '' }) updateAttributes({ caption: '' })
setEditingCaption(false) 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 ( return (
@@ -58,18 +103,54 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
<XIcon className="h-3.5 w-3.5" /> <XIcon className="h-3.5 w-3.5" />
</button> </button>
{showCaption && ( {/* تصویر خارجی (مثل 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}> <figcaption className="article-figure__caption" contentEditable={false}>
<input <input
ref={inputRef} ref={captionInputRef}
type="text" type="text"
value={caption ?? ''} value={caption ?? ''}
onChange={(event) => updateAttributes({ caption: event.target.value })} onChange={(event) => updateAttributes({ caption: event.target.value })}
placeholder="توضیح تصویر (کپشن)…" placeholder="توضیح تصویر (کپشن)…"
aria-label="کپشن تصویر" aria-label="کپشن تصویر"
className="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" className={INLINE_FIELD_CLASS}
/> />
{/* حذف فقط کپشن — تصویر دست‌نخورده می‌ماند */} {/* حذف کامل کپشن — تصویر دست‌نخورده می‌ماند */}
<button <button
type="button" type="button"
onClick={removeCaption} onClick={removeCaption}
@@ -80,21 +161,18 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
<XIcon className="h-3.5 w-3.5" /> <XIcon className="h-3.5 w-3.5" />
</button> </button>
</figcaption> </figcaption>
)} ) : (
</figure>
{/* وقتی کپشنی وجود ندارد، فقط یک دکمه افزودن نمایش داده می‌شود */}
{!showCaption && (
<button <button
type="button" type="button"
contentEditable={false} contentEditable={false}
onClick={() => setEditingCaption(true)} onClick={addCaption}
className="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" className={ADD_CAPTION_CLASS}
> >
<PlusIcon className="h-3 w-3" aria-hidden="true" /> <PlusIcon className="h-3 w-3" aria-hidden="true" />
افزودن کپشن افزودن کپشن
</button> </button>
)} )}
</figure>
</NodeViewWrapper> </NodeViewWrapper>
) )
} }
@@ -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 }
}
+92 -17
View File
@@ -1,13 +1,13 @@
import React from 'react'; import { Link } from 'react-router-dom';
import { ArrowLeftIcon } from 'lucide-react';
import { Reveal } from '../Reveal'; import { Reveal } from '../Reveal';
import { ButtonLink } from '../Button'; import { PremiumButton } from '../PremiumButton';
import { ArticleCard } from '../ArticleCard'; import { ArticleCard } from '../ArticleCard';
import { useSectionHeading, useSite } from '../../api'; import { useSectionHeading, useSite } from '../../api';
import { useLang } from '../../i18n/lang'; import { useLang } from '../../i18n/lang';
import { useFieldSaver } from '../../admin/AdminContext'; import { useFieldSaver } from '../../admin/AdminContext';
import { InlineEdit } from '../admin/InlineEdit'; import { InlineEdit } from '../admin/InlineEdit';
import { AddItemButton, EditableItem } from '../../admin/Editable'; import { AddItemButton, EditableItem } from '../../admin/Editable';
import { formatJalali } from '../../utils/date';
export function Articles() { export function Articles() {
const { articles } = useSite(); const { articles } = useSite();
@@ -17,6 +17,9 @@ export function Articles() {
// پیش‌نویس‌ها فقط در صفحه وبلاگ (برای ادمین) دیده می‌شوند، نه در خانه // پیش‌نویس‌ها فقط در صفحه وبلاگ (برای ادمین) دیده می‌شوند، نه در خانه
const published = articles.filter((a) => a.status !== 'draft'); const published = articles.filter((a) => a.status !== 'draft');
const topArticles = published.slice(0, 3);
const spotlight = topArticles[0];
const compactList = topArticles.slice(1, 3);
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) => const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
save('section-headings', heading.id, { [field]: value }, (d) => { save('section-headings', heading.id, { [field]: value }, (d) => {
@@ -25,24 +28,97 @@ export function Articles() {
}); });
return ( return (
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title"> <section className="w-full max-w-full overflow-x-clip bg-char py-16 md:py-24 lg:py-32" aria-labelledby="articles-title">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
<Reveal className="max-w-2xl ltr:text-left rtl:text-right"> <Reveal className="max-w-2xl ltr:text-left rtl:text-right">
<p className="text-sm font-light text-copper"> <p className="text-sm font-light text-copper">
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} /> <InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
</p> </p>
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory"> <h2 id="articles-title" className="mt-3 md:mt-5 text-section font-bold text-ivory">
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} /> <InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
</h2> </h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60"> <p className="mt-3 md:mt-5 text-sm md:text-base font-light leading-7 md:leading-9 text-ivory/60">
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} /> <InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
</p> </p>
<div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-4 md:mt-6 h-px w-24 bg-copper/70" />
</Reveal> </Reveal>
<div className="mt-14 grid gap-6 lg:grid-cols-3"> {/* Mobile View: Magazine Editorial layout (1 Featured Spotlight + Compact Rows) */}
{published.slice(0, 3).map((article, i) => <div className="block md:hidden mt-8">
<Reveal key={article.id} delay={0.06 * i} className="h-full"> {/* First Article (Spotlight) */}
{spotlight && (
<EditableItem resource="articles" item={spotlight} editHref={`/admin/write/${spotlight.id}`}>
<Link
className="block rounded-xl border border-ivory/10 bg-char/40 overflow-hidden mb-3 group"
to={href(`/blog/${spotlight.slug}`)}
>
<img
src={spotlight.image}
alt={spotlight.title}
className="w-full aspect-[16/9] object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="p-3.5">
<div className="flex items-center gap-2 text-[11px] text-copper font-medium mb-1">
<span>{spotlight.category}</span>
<span>•</span>
<span>{formatJalali(spotlight.date)}</span>
</div>
<h4 className="text-sm font-bold text-ivory leading-snug line-clamp-2 mb-1 group-hover:text-copper transition-colors">
{spotlight.title}
</h4>
<p className="text-xs text-ivory/60 line-clamp-2 leading-relaxed">
{spotlight.excerpt}
</p>
</div>
</Link>
</EditableItem>
)}
{/* Remaining Articles (Compact List Rows) */}
{compactList.length > 0 && (
<div className="divide-y divide-ivory/15 border-t border-b border-ivory/15 mb-4">
{compactList.map((article) => (
<EditableItem key={article.id} resource="articles" item={article} editHref={`/admin/write/${article.id}`}>
<Link
className="py-2.5 flex items-center gap-3 group"
to={href(`/blog/${article.slug}`)}
>
<img
src={article.image}
alt={article.title}
className="w-14 h-14 rounded-lg object-cover shrink-0 border border-ivory/10"
/>
<div className="flex-1 min-w-0">
<span className="text-[10px] text-copper block mb-0.5">{formatJalali(article.date)}</span>
<h5 className="text-xs font-semibold text-ivory line-clamp-2 leading-snug group-hover:text-copper transition-colors">
{article.title}
</h5>
</div>
</Link>
</EditableItem>
))}
</div>
)}
{/* Bottom Button ("همه مقالات") */}
{heading.cta_label && heading.cta_href && (
<Link
to={href(heading.cta_href)}
className="w-full py-2.5 rounded-xl border border-ivory/20 bg-char/60 hover:bg-copper/10 hover:border-copper/40 text-xs font-semibold text-ivory text-center block transition-all active:scale-[0.98]"
>
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
</Link>
)}
</div>
{/* Desktop View: 3-Column Grid */}
<div className="hidden md:grid mt-14 grid-cols-3 gap-6">
{topArticles.map((article, i) =>
<Reveal
key={article.id}
delay={0.06 * i}
className="h-full"
>
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}> <EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
<ArticleCard article={article} /> <ArticleCard article={article} />
</EditableItem> </EditableItem>
@@ -52,11 +128,10 @@ export function Articles() {
{heading.cta_label && heading.cta_href && ( {heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}> <Reveal delay={0.1}>
<div className="mt-12 flex justify-center"> <div className="mt-12 hidden md:flex justify-center">
<ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light"> <PremiumButton to={href(heading.cta_href)} className="font-light">
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} /> <InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" /> </PremiumButton>
</ButtonLink>
</div> </div>
</Reveal> </Reveal>
)} )}
@@ -64,6 +139,6 @@ export function Articles() {
<AddItemButton resource="articles" /> <AddItemButton resource="articles" />
</div> </div>
</div> </div>
</section>); </section>
);
} }
+9 -7
View File
@@ -18,20 +18,22 @@ export function Faq({ hideHeading = false }: FaqProps) {
return ( return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title"> <section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
<div className="mx-auto max-w-site px-4 md:px-8 lg:px-10">
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}> {/* Header — centered, full width */}
{!hideHeading && ( {!hideHeading && (
<div className="lg:col-span-4"> <div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center">
<SectionHeading <SectionHeading
title={t('fallbackFaqTitle')} title={t('fallbackFaqTitle')}
lead={t('fallbackFaqLead')} lead={t('fallbackFaqLead')}
headingKey="faq" headingKey="faq"
align="center"
/> />
</div> </div>
)} )}
<div className={hideHeading ? "" : "lg:col-span-8"}> {/* Accordion list — constrained width, direction-aware */}
<div className="w-full max-w-3xl mx-auto">
<ul> <ul>
{faqs.map((faq, i) => { {faqs.map((faq, i) => {
const isOpen = open === i const isOpen = open === i
@@ -43,9 +45,9 @@ 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 text-start ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question} {faq.question}
</span> </span>
<PlusIcon <PlusIcon
@@ -63,7 +65,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden" className="overflow-hidden"
> >
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p> <p className="pb-7 ps-10 text-sm font-light leading-9 text-ivory/60 text-start">{faq.answer}</p>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -1,22 +1,22 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button' import { PremiumButton } from '../PremiumButton'
import { ProjectCard } from '../ProjectCard' import { ProjectCard } from '../ProjectCard'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite, useStatusLabel } from '../../api' import { useSectionHeading, useSite, useStatusLabel } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable'
import type { ProjectStatus } from '../../data/site' import type { ProjectStatus } from '../../data/site'
import { projectsData } from '../../data/projectsData'
export function FeaturedProjects() { export function FeaturedProjects() {
const { projects, status_labels } = useSite() const { status_labels } = useSite()
const { t, href } = useLang() const { t, href } = useLang()
const heading = useSectionHeading('featured_projects') const heading = useSectionHeading('featured_projects')
const save = useFieldSaver() const save = useFieldSaver()
const saveStatusLabel = (key: string, value: string) => { const saveStatusLabel = (key: string, value: string) => {
const label = status_labels.find((s) => s.key === key) const label = status_labels.find((s) => s.key === key)
if (!label) return if (!label) return
@@ -30,23 +30,26 @@ export function FeaturedProjects() {
{ key: 'done', label: useStatusLabel('done') }, { key: 'done', label: useStatusLabel('done') },
{ key: 'progress', label: useStatusLabel('progress') }, { key: 'progress', label: useStatusLabel('progress') },
] ]
const [status, setStatus] = useState<ProjectStatus>('done') const [status, setStatus] = useState<ProjectStatus>('done')
const list = projects.filter((p) => p.status === status).slice(0, 3)
// Filter and slice static project data
const list = projectsData.filter((p) => p.status === status).slice(0, 3)
const BASE_IMG_URL = "/seed-media/2026/09/";
return ( return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title"> <section className="w-full max-w-full overflow-x-clip bg-ink py-16 md:py-24 lg:py-32" aria-labelledby="projects-title">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" /> <SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
{/* دکمه‌های فیلتر مینیمال و شیشه‌ای (بدون کادر) */} {/* دکمه‌های فیلتر مینیمال و شیشه‌ای (بدون کادر) */}
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}> <div className="mt-8 flex flex-wrap items-center justify-center gap-3 md:mt-10 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
{FILTERS.map((filter, index) => { {FILTERS.map((filter, index) => {
const isSelected = status === filter.key const isSelected = status === filter.key
return ( return (
<React.Fragment key={filter.key}> <React.Fragment key={filter.key}>
{/* خط جداکننده بین دکمه‌ها */}
{index > 0 && ( {index > 0 && (
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" /> <span className="hidden h-6 w-[2px] rounded-full bg-ivory/10 sm:inline-block" aria-hidden="true" />
)} )}
<button <button
@@ -54,7 +57,7 @@ export function FeaturedProjects() {
role="tab" role="tab"
aria-selected={isSelected} aria-selected={isSelected}
onClick={() => setStatus(filter.key)} onClick={() => setStatus(filter.key)}
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${ className={`group relative rounded-lg px-4 py-2 text-xs font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md md:px-6 md:py-2.5 md:text-sm ${
isSelected isSelected
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]' ? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
: 'bg-transparent text-ivory/60 hover:bg-white/5 hover:text-ivory' : 'bg-transparent text-ivory/60 hover:bg-white/5 hover:text-ivory'
@@ -72,7 +75,7 @@ export function FeaturedProjects() {
})} })}
</div> </div>
<div className="mt-10 min-h-[420px]"> <div className="mt-8 min-h-[380px] md:mt-10 md:min-h-[420px]">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={status} key={status}
@@ -80,21 +83,28 @@ export function FeaturedProjects() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }} exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="grid gap-6 lg:grid-cols-3" className="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] md:grid md:grid-cols-3 md:gap-6 md:overflow-visible md:pb-0"
> >
{list.map((project, i) => ( {list.map((project, i) => {
const dummyImageName = `${(i % 6) + 1}.png`;
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
return (
<motion.div <motion.div
key={project.id} key={project.id}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
className="h-full" className="h-full w-[82vw] max-w-[320px] shrink-0 snap-center md:w-auto md:max-w-none md:shrink"
> >
<EditableItem resource="projects" item={project} className="h-full"> <ProjectCard
<ProjectCard project={project} /> project={project}
</EditableItem> imageUrl={imageUrl}
detailHref={`/projects/${project.slug}`}
/>
</motion.div> </motion.div>
))} )
})}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div> </div>
@@ -102,12 +112,8 @@ export function FeaturedProjects() {
{/* دکمه مشاهده همه پروژه‌ها */} {/* دکمه مشاهده همه پروژه‌ها */}
{heading.cta_label && heading.cta_href && ( {heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}> <Reveal delay={0.1}>
<div className="mt-12 flex justify-center"> <div className="mt-10 flex justify-center md:mt-12">
<ButtonLink <PremiumButton to={href(heading.cta_href)}>
to={href(heading.cta_href)}
variant="outline"
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
>
<InlineEdit <InlineEdit
value={heading.cta_label} value={heading.cta_label}
onSave={(v) => onSave={(v) =>
@@ -117,10 +123,7 @@ export function FeaturedProjects() {
}) })
} }
/> />
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1 group-hover:bg-copper group-hover:text-char"> </PremiumButton>
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100" />
</span>
</ButtonLink>
</div> </div>
</Reveal> </Reveal>
)} )}
+43 -21
View File
@@ -64,7 +64,7 @@ export function Gallery() {
<AnimatePresence> <AnimatePresence>
{active !== null && gallery[active] && ( {active !== null && gallery[active] && (
<motion.div <motion.div
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6" className="fixed inset-0 z-[80] flex flex-col items-center justify-center bg-ink p-4 md:p-8"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
@@ -74,49 +74,71 @@ export function Gallery() {
aria-label={t('viewImage')} aria-label={t('viewImage')}
onClick={close} onClick={close}
> >
{/* Close Button */}
<button <button
type="button" type="button"
onClick={close} onClick={close}
className="absolute top-8 ltr:right-8 rtl:left-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper" className="absolute top-6 right-6 md:top-8 md:right-8 z-10 flex h-10 w-10 md:h-11 md:w-11 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
aria-label={t('close')} aria-label={t('close')}
> >
<XIcon className="h-5 w-5" /> <XIcon className="h-5 w-5" />
</button> </button>
{/* Flex Container for Prev, Image, Next */}
<div
className="relative z-10 flex w-full max-w-7xl flex-1 items-center justify-center gap-4 md:gap-8"
onClick={(e) => e.stopPropagation()}
>
<button <button
type="button" type="button"
onClick={(e) => { onClick={() => step(-1)}
e.stopPropagation() className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
step(-1)
}}
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:right-6 md:rtl:right-10 ltr:left-6 md:ltr:left-10"
aria-label={t('prevImage')} aria-label={t('prevImage')}
> >
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" /> <ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
</button> </button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
step(1)
}}
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:left-6 md:rtl:left-10 ltr:right-6 md:ltr:right-10"
aria-label={t('nextImage')}
>
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
</button>
<motion.img <motion.img
key={active} key={active}
src={gallery[active].src} src={gallery[active].src}
alt="" alt={gallery[active].alt}
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]" className="max-h-[75vh] w-auto max-w-[calc(100%-7rem)] rounded-lg object-contain shadow-2xl"
onClick={(e) => e.stopPropagation()}
/> />
<button
type="button"
onClick={() => step(1)}
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
aria-label={t('nextImage')}
>
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
</button>
</div>
{/* Modern Dot Pagination */}
<div
className="absolute bottom-6 md:bottom-10 flex items-center justify-center gap-2 z-10"
onClick={(e) => e.stopPropagation()}
>
{gallery.map((_, idx) => {
const isActive = idx === active;
return (
<button
key={idx}
type="button"
onClick={() => setActive(idx)}
aria-label={`${t('viewImage')} ${formatNumber(idx + 1, lang)}`}
className={`h-2.5 rounded-full transition-all duration-300 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory ${
isActive ? 'w-6 bg-copper' : 'w-2.5 bg-white/50 hover:bg-white/80'
}`}
/>
);
})}
</div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
+9 -34
View File
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { motion, useScroll, useTransform } from 'framer-motion' import { motion, useScroll, useTransform } from 'framer-motion'
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react' import { ChevronDownIcon } from 'lucide-react'
import { ButtonLink } from '../Button' import { PremiumButton } from '../PremiumButton'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
@@ -84,45 +84,20 @@ export function Hero() {
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
className="mt-10 flex flex-wrap items-center gap-4" className="mt-10 flex flex-wrap items-center gap-4"
> >
{/* دکمه اصلی: کپسولی شیشه‌ای دودی با دایره کهربایی اختصاصی برای آیکون */} {/* دکمه اصلی */}
<ButtonLink <PremiumButton to={href(hero.cta_primary_href)}>
to={href(hero.cta_primary_href)}
variant="copper"
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
>
<span className="text-sm font-medium tracking-wide text-ivory">
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} /> <InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</span> </PremiumButton>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1">
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" />
</span>
</ButtonLink>
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */} {/* دکمه ثانویه */}
<ButtonLink <PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md">
to={href(hero.cta_secondary_href)}
variant="outline"
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
>
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} /> <InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
</ButtonLink> </PremiumButton>
</motion.div> </motion.div>
</div> </div>
{/* کادر آمار شناور (گوشه پایین چپ): شیشه‌ای با لبه‌های گرد مدرن */} {/* کادر آمار شناور (گوشه پایین چپ): شیشه‌ای با لبه‌های گرد مدرن */}
<motion.div
initial={{ opacity: 0, x: -24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10"
>
<p className="text-3xl font-bold text-copper font-mono">
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
</p>
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
</p>
</motion.div>
</motion.div> </motion.div>
{/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */} {/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */}
+23 -10
View File
@@ -1,20 +1,22 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { CheckIcon, SendIcon } from 'lucide-react' import { CheckIcon } from 'lucide-react'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { Button } from '../Button' import { PremiumButton } from '../PremiumButton'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' 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 {
@@ -98,7 +101,7 @@ export function Newsletter() {
} }
return ( return (
<section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title"> <section className="relative overflow-hidden bg-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
<SingletonPill resource="newsletter" label="ویرایش متن‌های خبرنامه" className="absolute left-4 top-4" /> <SingletonPill resource="newsletter" label="ویرایش متن‌های خبرنامه" className="absolute left-4 top-4" />
{/* هاله نور پس‌زمینه برای عمق بصری */} {/* هاله نور پس‌زمینه برای عمق بصری */}
@@ -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}
@@ -142,19 +156,18 @@ export function Newsletter() {
)} )}
</div> </div>
<Button <button
type="submit" type="submit"
variant="dark"
disabled={sending} disabled={sending}
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95" className="group/btn relative inline-flex items-center justify-center overflow-hidden rounded-full px-10 py-4 text-sm font-bold tracking-widest text-white border border-ink bg-ink transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg cursor-pointer select-none disabled:opacity-50 disabled:cursor-not-allowed"
> >
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" /> <span className="absolute inset-0 z-0 bg-white scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
<span> <span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
{sending ? newsletter.sending_label : ( {sending ? newsletter.sending_label : (
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} /> <InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
)} )}
</span> </span>
</Button> </button>
</form> </form>
</Reveal> </Reveal>
</div> </div>
+14 -12
View File
@@ -1,4 +1,3 @@
import React from 'react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { ServiceIcon } from '../ServiceIcon' import { ServiceIcon } from '../ServiceIcon'
import { useSectionHeading, useSite } from '../../api' import { useSectionHeading, useSite } from '../../api'
@@ -18,32 +17,35 @@ export function Services() {
}) })
return ( return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title"> <section className="w-full max-w-full overflow-hidden bg-ink py-12 md: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-4 md: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)} />
</h2> </h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60"> <p className="mt-3 text-sm font-light leading-7 text-ivory/60 md:mt-5 md:text-base md:leading-9">
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} /> <InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
</p> </p>
<div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-4 h-px w-24 bg-copper/70 md:mt-6" />
</Reveal> </Reveal>
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4"> <div className="mt-8 grid grid-cols-2 gap-3 md:mt-14 md:gap-5 md:grid-cols-2 lg:grid-cols-4">
{services.map((service, i) => ( {services.map((service, i) => (
<Reveal key={service.id} delay={0.04 * i} className="h-full"> <Reveal key={service.id} delay={0.04 * i} className="h-full">
<EditableItem resource="services" item={service} className="h-full"> <EditableItem resource="services" item={service} className="h-full">
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform"> <article className="group flex h-full flex-col items-center rounded-xl border border-ivory/10 bg-char p-3.5 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform md:rounded-2xl md:p-8">
<div className="flex h-10 w-10 items-center justify-center md:h-14 md:w-14">
<ServiceIcon <ServiceIcon
name={service.icon} name={service.icon}
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper" className="h-10 w-10 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper md:h-14 md:w-14"
/> />
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper"> </div>
<h3 className="mt-3 text-sm font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper md:mt-6 md:text-lg">
{service.title} {service.title}
</h3> </h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p> <p className="mt-2 text-xs font-light leading-relaxed text-ivory/55 line-clamp-2 md:mt-3 md:text-sm md:leading-8 md:line-clamp-none">
{service.desc}
</p>
</article> </article>
</EditableItem> </EditableItem>
</Reveal> </Reveal>

Some files were not shown because too many files have changed in this diff Show More