diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0369333 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید. +# docker compose به‌طور خودکار این فایل را می‌خواند؛ بدون آن استک بالا نمی‌آید. + +# --- رمزهای اصلی (اجباری) --- +# تولید کلید تصادفی: python -c "import secrets; print(secrets.token_urlsafe(48))" +SECRET_KEY=change-me-to-a-long-random-string +POSTGRES_PASSWORD=change-me-db-password +MINIO_ROOT_USER=aramesh +MINIO_ROOT_PASSWORD=change-me-minio-password +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-admin-password + +# --- اختیاری --- +# پورت ورود Traefik در توسعه (پیش‌فرض 5173) +TRAEFIK_HTTP_PORT=5173 +# نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example +SITE_URL= +# آدرس‌های مجاز CORS (لیست JSON) — پیش‌فرض فقط لوکال‌هاست +# CORS_ORIGINS=["http://localhost:5173"] + +# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) --- +# DOMAIN=aramesh.example +# ACME_EMAIL=admin@aramesh.example diff --git a/.gitignore b/.gitignore index 1c8491b..da36e75 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ env/ # Environment .env +# پشتیبان‌گیری محلی +backups/ + # Node / Vite node_modules/ dist/ @@ -23,4 +26,6 @@ Thumbs.db # Database *.sqlite3 *.db -frontend/public/images/originals/ + +# تصاویر محلی — از پنل/کنسول MinIO مدیریت می‌شوند +frontend/public/images/ diff --git a/README.md b/README.md index 0afa5c3..0a77754 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,68 @@ مونوریپوی سایت گروه ساختمانی آرامش شامل: -- **`frontend/`** — سایت React + Vite + Tailwind (همه محتوا از API خوانده می‌شود) -- **`backend/`** — بک‌اند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT +- **`frontend/`** — سایت React + Vite + Tailwind (تمام متن‌ها و تصاویر از API خوانده می‌شود) +- **`backend/`** — بک‌اند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT + MinIO + +## معماری + +``` +Traefik (پورت ۵۱۷۳ — تنها نقطه ورود) +├── / → Caddy (فایل‌های استاتیک SPA) +├── /api → FastAPI (بک‌اند) +└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی) +``` + +- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانت‌اند هاردکد نشده؛ + با یک درخواست `GET /api/site` بارگذاری و از پنل مدیریت ویرایش می‌شود +- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره می‌شوند و با مسیر نسبی + `/media/...` برگردانده می‌شوند — بک‌اند ذخیره‌سازی بدون تغییر محتوای دیتابیس قابل تعویض است +- مسیریابی همه‌چیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد ## ساختار ``` ├── frontend/ # سایت React │ ├── src/ # کد منبع (api.tsx = لایه اتصال به بک‌اند) -│ ├── Dockerfile # بیلد Node + سرو با nginx -│ └── nginx.conf # پروکسی /api به بک‌اند +│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy +│ └── Caddyfile # فقط سرو فایل‌های استاتیک (مسیریابی روی Traefik است) ├── backend/ # بک‌اند FastAPI -│ ├── app/ # کد منبع +│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py) +│ ├── tests/ # تست‌های API (pytest) │ ├── requirements.txt │ ├── Dockerfile │ └── .env.example -└── docker-compose.yml # اجرای کامل با یک دستور +├── scripts/backup.sh # پشتیبان‌گیری دیتابیس و تصاویر +├── docker-compose.yml # اجرای کامل با یک دستور +└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt ``` ## اجرا با Docker (ساده‌ترین راه) ```bash +cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمی‌آید docker compose up --build ``` -- سایت: http://localhost:5173 -- مستندات API: http://localhost:8000/docs -- دیتابیس PostgreSQL در کانتینر جدا (داده‌ها در volume ذخیره می‌شوند) -- بک‌اند هنگام شروع، خودش جداول و داده‌های اولیه سایت را seed می‌کند -- nginx درخواست‌های `/api` را خودش به بک‌اند پروکسی می‌کند — نیازی به تنظیم CORS نیست +- سایت: http://localhost:5173 (از طریق Traefik) +- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی) +- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در compose +- دیتابیس PostgreSQL و آبجکت‌استور MinIO هر دو در کانتینر جدا با volume دائمی +- بک‌اند هنگام شروع، خودش جداول را می‌سازد و **فقط جدول‌های خالی** را seed می‌کند — + ارتقای نسخه داده‌های فعلی را از بین نمی‌برد +- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را می‌سازد و خواندن عمومی را فعال می‌کند + +## انتشار واقعی (TLS) + +برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید: + +```bash +# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build +``` + +- Traefik گواهی Let's Encrypt می‌گیرد و تمدید می‌کند؛ همه درخواست‌های HTTP به HTTPS هدایت می‌شوند +- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرس‌های OG مطلق شوند ## اجرای محلی (توسعه) @@ -52,34 +85,108 @@ uvicorn app.main:app --reload ```bash cd frontend npm install -npm run dev # http://localhost:5173 +npm run dev ``` -آدرس بک‌اند در `frontend/src/config.ts` از `VITE_API_URL` خوانده می‌شود؛ -اگر تنظیم نشود پیش‌فرض `http://localhost:8000` است. +در حالت توسعه، Vite درخواست‌های `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی می‌کند؛ +پس استک Docker باید در حال اجرا باشد. آدرس بک‌اند در `frontend/src/config.ts` از +`VITE_API_URL` خوانده می‌شود — پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ. ## امکانات بک‌اند -- **API عمومی**: برند، خدمات، پروژه‌ها، مقالات، نظرات، سوالات متداول، تیم، تایم‌لاین، آمار و... +- **API عمومی**: برند، منو، هیرو، خدمات، پروژه‌ها، مقالات، نظرات، سوالات متداول، تیم، + تایم‌لاین، آمار، دفاتر، مدل‌های سرمایه‌گذاری و... — همه در دیتابیس - **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter` -- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیش‌فرض: `admin` / `aramesh123`) و سپس CRUD کامل همه محتواها در `/api/admin/...` -- همه محتوا در یک درخواست: `GET /api/site` +- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیش‌فرض: `admin` / `aramesh123`) + و سپس CRUD کامل همه محتواها در `/api/admin/...` +- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است +- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد می‌شود +- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO + ذخیره و URL نسبی `/media/...` برمی‌گردد +- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسال‌شده عوض می‌شوند +- **ترتیب‌بندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}` +- همه محتوا در یک درخواست: `GET /api/site` (مقاله‌های پیش‌نویس در آن نمی‌آیند) +- **تست‌ها**: `cd backend && pip install -r requirements-dev.txt && pytest tests` -> در محیط واقعی حتماً `SECRET_KEY` و `ADMIN_PASSWORD` را تغییر دهید. +> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید +> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید. + +## ویرایش زنده سایت (دوقلوی دیجیتال) + +ادمین‌ها نیازی به داشبورد جدا ندارند — خود سایت ویرایشگر است: + +1. به `/admin/login` بروید و وارد شوید +2. نوار شناور پایین صفحه ظاهر می‌شود؛ **حالت ویرایش** را روشن کنید +3. روی هر متن کلیک کنید و ویرایش کنید — **خروج از کادر، بلافاصله ذخیره می‌شود** + (Enter ذخیره، Esc لغو)؛ وضعیت ذخیره در نوار پایین دیده می‌شود +4. آیتم‌های مجموعه‌ها (خدمات، پروژه‌ها، مقالات، نظرات، تیم و...) با هاور، دکمه‌های + ویرایش / حذف / جابه‌جایی ترتیب نشان می‌دهند؛ دکمه «افزودن» هم انتهای هر لیست هست +5. تصویرها از همان کشوها به MinIO آپلود می‌شوند + +- در حالت ویرایش، لینک‌ها کلیک نمی‌شوند تا ویرایش متن ممکن باشد +- بازدیدکننده‌های عادی هیچ اثری از این لایه نمی‌بینند (بدون توکن، همه درخواست‌های + مدیریتی `401` برمی‌گردند) +- تمام تغییرها بلافاصله در PostgreSQL ثبت می‌شوند — با Ctrl+F5 هم می‌مانند + +## وبلاگ — نوشتن با ویرایشگر WYSIWYG + +مقالات با ویرایشگر غنی **TipTap** نوشته می‌شوند (همان تجربه Medium): + +- در حالت ویرایش، روی هر مقاله در صفحه وبلاگ دکمه ویرایش را بزنید — کشوی نوشتن باز می‌شود +- تیتر (H2/H3)، بولد/ایتالیک/زیرخط، لیست نقطه‌ای و شماره‌ای، نقل‌قول، لینک و واگرد +- **درج تصویر داخل متن**: دکمه تصویر، یا چسباندن/کشیدن تصویر داخل نوشته — فایل مستقیم + در MinIO ذخیره و در متن قرار می‌گیرد +- راست‌به‌چپ کامل با فونت و تم سایت +- ذخیره خودکار: هر مکث کوتاه در تایپ، تغییرها را ذخیره می‌کند؛ بستن کشو هم ذخیره می‌کند + +**پیش‌نویس و انتشار**: مقاله تازه همیشه **پیش‌نویس** شروع می‌شود — در سایت عمومی دیده +نمی‌شود و تا انتشار، تغییرهای خودکار آن چیزی را خراب نمی‌کند. با دکمه «انتشار در سایت» +عمومی می‌شود؛ دوباره می‌توان آن را به پیش‌نویس برگرداند. پیش‌نویس‌ها فقط برای ادمین +(در صفحه وبلاگ، با برچسب «پیش‌نویس») دیده می‌شوند و در `GET /api/site` هرگز نمی‌آیند. + +**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام. +صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل می‌کند، پس فقط تگ‌های شناخته‌شده +(پاراگراف، تیتر، لیست، نقل‌قول، تصویر، لینک) تولید می‌شوند. مقالات قدیمی (لیست پاراگراف) +در اولین اجرا به‌صورت خودکار به این فرمت تبدیل می‌شوند. + +## سئو + +- **بررسی سئو در صفحه نوشتن**: فهرست سبز/نارنجی/قرمز به سبک یوست ولی سازگار با فارسی — + طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیترها)، طول چکیده، تعداد واژه، + تیتربندی H2، متن جایگزین تصاویر، پیوندها و نشانی (slug) — کلمه کلیدی هدف در دیتابیس + ذخیره می‌شود (فیلد `seo_keyphrase` مقاله) +- **سئوی هر صفحه**: در حالت ویرایش، دکمه سئو روی هیرو هر صفحه (و کنار هیرو صفحه اصلی) — + عنوان تب و توضیح متای هر صفحه از دیتابیس خوانده و اعمال می‌شود؛ اگر ردیف سئوی صفحه‌ای + هنوز ساخته نشده باشد، دکمه + همان‌جا آن را می‌سازد +- صفحه مقاله و پروژه به‌طور خودکار عنوان و توضیح متا را از محتوای خودشان می‌گیرند +- **تگ‌های OG و توییتر و canonical** برای همه صفحات از `src/seo.ts` ست می‌شود و + مقاله‌ها اسکیمای JSON-LD نوع Article می‌گیرند +- **robots.txt و sitemap.xml** از بک‌اند سرو می‌شوند و مقاله/پروژه‌های منتشرشده را + شامل می‌شوند (پیش‌نویس‌ها نمی‌آیند) — برای آدرس مطلق، `SITE_URL` را در `.env` بگذارید +- **متا برای خزنده‌های بدون جاوااسکریپت** (تلگرام، واتس‌اپ و...): بک‌اند صفحات + `/blog`، `/blog/{slug}`، `/projects` و `/projects/{slug}` را با همان `index.html` + فرانت‌اند سرو می‌کند و پیش از ارسال، title / description / OG / JSON-LD را از + دیتابیس داخل آن تزریق می‌کند (`app/seo_pages.py`) — بازدیدکننده‌های عادی همان SPA + همیشگی را می‌بینند + +## پشتیبان‌گیری + +همه محتوای سایت در PostgreSQL و تصاویر در MinIO است — هر دو را با یک دستور پشتیبان بگیرید: + +```bash +bash scripts/backup.sh # در backups/ — نگهداری پیش‌فرض ۱۴ روز +``` + +- خروجی: `backups/db-<زمان>.sql.gz` (فشرده) و `backups/images-<زمان>/` (آینه باکت) +- زمان‌بندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست +- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza` ## نکات امنیتی -- فایل `.env` هرگز commit نمی‌شود (در `.gitignore` است) -- رمز عبور ادمین با bcrypt هش می‌شود +- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml` + با سینتکس `${VAR:?}` اجباری شده‌اند — بدون `.env` استک بالا نمی‌آید +- رمز عبور ادمین با bcrypt هش می‌شود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است +- ورود با محدودیت تلاش محافظت می‌شود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429 - توکن‌های JWT امضا دارند و مدت اعتبارشان قابل تنظیم است - -## تغییرات اخیر - -### بهبود سرعت (کامیت e0ebae4) -- **حذف کامل وابستگی‌های خارجی**: همه عکس‌های سایت به‌صورت محلی در `frontend/public/images/` قرار گرفتند (بدون وابستگی به سرورهای خارجی مثل postimg و housedesign) -- **حذف فونت‌های خارجی** (Google Fonts و fontcdn که رندر صفحه را قفل می‌کردند) — استفاده از فونت سیستمی سریع -- **favicon واقعی** اضافه شد (قبلاً خراب بود) -- **کش ۳۰ روزه** برای تصاویر و فایل‌های استاتیک در nginx -- نتیجه: صفحه‌ها در چند میلی‌ثانیه لود می‌شوند و سایت هیچ درخواست خارجی ندارد - -> اگر نسخه قبلی سایت را اجرا کرده‌اید، برای دریافت نسخه جدید یک بار `docker compose down -v` و سپس `docker compose up --build -d` را اجرا کنید. +- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است +- مقاله‌های پیش‌نویس در هیچ انتهای عمومی نمی‌آیند و در HTML تزریق‌شده سئو هم افشا نمی‌شوند diff --git a/backend/.env.example b/backend/.env.example index f88754f..1429894 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -15,3 +15,15 @@ CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"] # مشخصات ادمین پیش‌فرض که هنگام seed ساخته می‌شود ADMIN_USERNAME=admin ADMIN_PASSWORD=aramesh123 + +# Object Storage (MinIO) برای آپلود تصاویر پنل مدیریت +S3_ENDPOINT=http://localhost:9000 +S3_ACCESS_KEY=aramesh +S3_SECRET_KEY=change-me-minio-secret +S3_BUCKET=aramesh-media + +# پیشوند عمومی مسیر تصاویر — پروکسی Reverse باید آن را به باکت برساند +MEDIA_PUBLIC_BASE=/media + +# حداکثر حجم آپلود تصویر (مگابایت) +MEDIA_MAX_UPLOAD_MB=10 diff --git a/backend/README.md b/backend/README.md index 1407cdb..7f6b098 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,14 +1,15 @@ # بک‌اند گروه ساختمانی آرامش (Aramesh Building Group API) -بک‌اند سایت گروه ساختمانی آرامش — ساخته‌شده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT**. +بک‌اند سایت گروه ساختمانی آرامش — ساخته‌شده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT** + **MinIO** (ذخیره تصاویر) + **Traefik** (مسیریابی). ## امکانات -- **API عمومی محتوا**: برند، خدمات، پروژه‌ها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایم‌لاین، آمار، چرا آرامش، و بخش قبل/بعد +- **API عمومی محتوا**: برند، منو، هیرو، داستان، تیتر بخش‌ها، هیرو صفحات، خدمات، پروژه‌ها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایم‌لاین، آمار، چرا آرامش، قبل/بعد، مدل‌های سرمایه‌گذاری، مراحل کار، دفاتر، گالری، فرم تماس، خبرنامه و فوتر — **تمام متن‌های سایت در دیتابیس است** - **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`) - **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`) - **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیام‌های تماس و مشترکین -- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار می‌گیرد +- **آپلود تصویر**: `POST /api/admin/upload` — فایل در MinIO ذخیره و URL نسبی `/media/...` برمی‌گردد +- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار می‌گیرد (فقط جدول‌های خالی پر می‌شوند — ارتقا بدون از دست رفتن داده) - مستندات تعاملی Swagger در مسیر `/docs` ## راه‌اندازی @@ -85,6 +86,13 @@ Authorization: Bearer > نام کاربری و رمز عبور پیش‌فرض از تنظیمات `.env` خوانده می‌شود — حتماً در محیط تولید تغییرشان دهید. +تغییر رمز (با توکن): + +``` +POST /api/auth/change-password +{ "current_password": "...", "new_password": "..." } +``` + ## فهرست انتهاپوینت‌ها ### عمومی (بدون نیاز به توکن) @@ -96,8 +104,8 @@ Authorization: Bearer | GET | `/api/services` | خدمات | | GET | `/api/projects` | همه پروژه‌ها | | GET | `/api/projects/{slug}` | جزئیات یک پروژه | -| GET | `/api/articles` | همه مقالات | -| GET | `/api/articles/{slug}` | جزئیات یک مقاله | +| GET | `/api/articles` | مقاله‌های منتشرشده (پیش‌نویس نمی‌آید) | +| GET | `/api/articles/{slug}` | جزئیات یک مقاله منتشرشده | | GET | `/api/testimonials` | نظرات مشتریان | | GET | `/api/faqs` | سوالات متداول | | GET | `/api/team` | اعضای تیم | @@ -108,7 +116,11 @@ Authorization: Bearer | GET | `/api/site` | **همه محتوا در یک درخواست** | | POST | `/api/contact` | ثبت پیام تماس | | POST | `/api/newsletter` | عضویت در خبرنامه | -| POST | `/api/auth/login` | ورود ادمین و دریافت توکن | +| POST | `/api/auth/login` | ورود ادمین و دریافت توکن — بعد از ۵ تلاش ناموفق در ۱۵ دقیقه، 429 | +| GET | `/robots.txt` | قوانین خزنده‌ها | +| GET | `/sitemap.xml` | نقشه سایت — مقاله/پروژه‌های منتشرشده | +| GET | `/blog/{slug}` | HTML صفحه مقاله با متای سئوی تزریق‌شده | +| GET | `/projects/{slug}` | HTML صفحه پروژه با متای سئوی تزریق‌شده | ### مدیریت (نیاز به توکن Bearer) @@ -118,12 +130,35 @@ Authorization: Bearer | متد | مسیر | توضیح | |---|---|---| | GET/POST | `/api/admin/{resource}` | لیست / ساخت | -| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش / حذف | +| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش کامل / حذف | +| PATCH | `/api/admin/{resource}/{id}` | **ویرایش جزئی** — فقط فیلدهای ارسال‌شده به‌روزرسانی می‌شوند | +| POST | `/api/admin/{resource}/reorder` | ذخیره ترتیب: `{"ids": [3, 1, 2, ...]}` | | GET | `/api/admin/messages` | پیام‌های تماس (با `?unread_only=true` فقط خوانده‌نشده‌ها) | | PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خوانده‌شدن | | DELETE | `/api/admin/messages/{id}` | حذف پیام | | GET | `/api/admin/subscribers` | مشترکین خبرنامه | | DELETE | `/api/admin/subscribers/{id}` | حذف مشترک | +| POST | `/api/admin/upload` | آپلود تصویر (multipart، حداکثر ۱۰MB — jpg/png/webp/gif/avif) → `{"url": "/media/..."}` | + +### بدنه مقاله — سند TipTap + +`body` مقاله یک سند ساختاریافته TipTap (ProseMirror JSON) است: `{"type": "doc", "content": [...]}`. +- فقط ساختار اعتبارسنجی می‌شود (`type: "doc"` + `content` لیست)؛ امنیت رندر بر عهده + رندر استاتیک TipTap در فرانت‌اند است که فقط تگ‌های شناخته‌شده تولید می‌کند +- ردیف‌های قدیمی (لیست پاراگراف) با مهاجرت idempotent `migrate_article_bodies` + در startup/seed خودکار به سند تبدیل می‌شوند +- seed هم بدنه‌های لیستی را قبل از درج تبدیل می‌کند + +| POST/PATCH | `/api/admin/seo-meta` | عنوان و توضیح متای صفحات — `page_key`: home / about / services / projects / investment / blog / faq / contact | + +منابعی که `reorder` دارند (ستون `position`): `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `investment-models`، `process-steps`، `offices`، `gallery`، `nav`. ستون با مهاجرت سبک idempotent هنگام startup/seed به دیتابیس‌های موجود اضافه می‌شود. + +### تصاویر و MinIO + +فایل‌های آپلودشده در باکت `aramesh-media` روی MinIO ذخیره می‌شوند و با مسیر نسبی `/media/<سال>/<ماه>/<فایل>` برگردانده می‌شوند. Traefik مسیر `/media` را به MinIO می‌برد، پس URLها مستقل از بک‌اند ذخیره‌سازی هستند — بعداً برای مهاجرت به R2/S3 فقط تنظیمات را عوض کنید، نه محتوای دیتابیس را. + +- کنسول MinIO: `http://localhost:9001` (کاربر/رمز از `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` در compose) +- تصاویر عمومی و قابل نمایش‌اند؛ آپلود فقط با توکن ادمین انجام می‌شود. ## اتصال به فرانت‌اند diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 82a8887..eedbea4 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1,40 +1,103 @@ """انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند.""" -from fastapi import APIRouter, Depends, HTTPException, status +import uuid +from datetime import datetime, timezone + +import boto3 +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from sqlalchemy import func from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from .. import models, schemas -from ..auth import create_access_token, get_current_admin, verify_password +from ..auth import create_access_token, get_current_admin, hash_password, verify_password +from ..config import settings from ..database import get_db +from ..rate_limit import clear as clear_rate_limit +from ..rate_limit import ensure_allowed, record_failure from ..schemas import ( ArticleIn, ArticleOut, + ArticlePatch, BeforeAfterIn, BeforeAfterOut, + BeforeAfterPatch, BrandIn, BrandOut, + BrandPatch, + ChangePasswordIn, + ContactSectionIn, + ContactSectionOut, + ContactSectionPatch, ContactMessageOut, FaqIn, FaqOut, + FaqPatch, + FooterIn, + FooterOut, + FooterPatch, + GalleryImageIn, + GalleryImageOut, + GalleryImagePatch, + HeroIn, + HeroOut, + HeroPatch, + InvestmentModelIn, + InvestmentModelOut, + InvestmentModelPatch, LoginIn, MessageReadIn, + NavItemIn, + NavItemOut, + NavItemPatch, + NewsletterSectionIn, + NewsletterOut, + NewsletterSectionPatch, + OfficeIn, + OfficeOut, + OfficePatch, + PageHeroIn, + PageHeroOut, + PageHeroPatch, + ProcessStepIn, + ProcessStepOut, + ProcessStepPatch, ProjectIn, ProjectOut, + ProjectPatch, + ReorderIn, + SeoMetaIn, + SeoMetaOut, + SeoMetaPatch, + SectionHeadingIn, + SectionHeadingOut, + SectionHeadingPatch, ServiceIn, ServiceOut, + ServicePatch, StatIn, StatOut, + StatPatch, + StatusLabelIn, + StatusLabelOut, + StatusLabelPatch, + StoryIn, + StoryOut, + StoryPatch, SubscriberOut, TeamMemberIn, TeamMemberOut, + TeamMemberPatch, TestimonialIn, TestimonialOut, + TestimonialPatch, TimelineItemIn, TimelineItemOut, + TimelineItemPatch, TokenOut, WhyUsItemIn, WhyUsItemOut, + WhyUsItemPatch, ) # روتر عمومی برای ورود — بدون نیاز به توکن @@ -45,26 +108,52 @@ router = APIRouter(dependencies=[Depends(get_current_admin)]) @auth_router.post("/auth/login", response_model=TokenOut) -def login(payload: LoginIn, db: Session = Depends(get_db)): +def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)): + ensure_allowed(request) user = db.query(models.AdminUser).filter(models.AdminUser.username == payload.username).first() if user is None or not verify_password(payload.password, user.password_hash): + record_failure(request) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="نام کاربری یا رمز عبور اشتباه است", ) + clear_rate_limit(request) return TokenOut(access_token=create_access_token(user.username)) -def register_crud(path: str, model, out_schema, create_schema) -> None: - """ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت.""" +@auth_router.post("/auth/change-password") +def change_password( + payload: ChangePasswordIn, + admin: models.AdminUser = Depends(get_current_admin), + db: Session = Depends(get_db), +): + """تغییر رمز ادمین واردشده — رمز فعلی باید درست باشد.""" + if not verify_password(payload.current_password, admin.password_hash): + raise HTTPException(status_code=400, detail="رمز عبور فعلی اشتباه است") + admin.password_hash = hash_password(payload.new_password) + db.commit() + return {"ok": True} + + +def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None: + """ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی، + حذف و ترتیب‌بندی) روی روتر مدیریت.""" @router.get(f"/{path}", response_model=list[out_schema]) def list_items(db: Session = Depends(get_db)): - return db.query(model).order_by(model.id).all() + query = db.query(model) + if hasattr(model, "position"): + query = query.order_by(model.position, model.id) + else: + query = query.order_by(model.id) + return query.all() @router.post(f"/{path}", response_model=out_schema, status_code=201) def create_item(payload: create_schema, db: Session = Depends(get_db)): item = model(**payload.model_dump()) + if hasattr(model, "position"): + # آیتم جدید در انتهای لیست قرار می‌گیرد + item.position = (db.query(func.max(model.position)).scalar() or 0) + 1 db.add(item) try: db.commit() @@ -96,6 +185,38 @@ def register_crud(path: str, model, out_schema, create_schema) -> None: db.refresh(item) return item + if patch_schema is not None: + + @router.patch(f"/{path}/{{item_id}}", response_model=out_schema) + def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)): + """ویرایش جزئی — فقط فیلدهای ارسال‌شده به‌روزرسانی می‌شوند (برای ویرایش زنده).""" + item = db.get(model, item_id) + if item is None: + raise HTTPException(status_code=404, detail="رکورد یافت نشد") + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(item, key, value) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)") + db.refresh(item) + return item + + @router.post(f"/{path}/reorder", response_model=list[out_schema]) + def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)): + """ذخیره ترتیب جدید آیتم‌ها بر اساس لیست idهای ارسالی.""" + if not hasattr(model, "position"): + raise HTTPException(status_code=400, detail="این منبع از ترتیب‌بندی پشتیبانی نمی‌کند") + items = db.query(model).filter(model.id.in_(payload.ids)).all() + if len(items) != len(payload.ids): + raise HTTPException(status_code=404, detail="برخی رکوردها یافت نشدند") + by_id = {item.id: item for item in items} + for pos, item_id in enumerate(payload.ids): + by_id[item_id].position = pos + db.commit() + return db.query(model).order_by(model.position, model.id).all() + @router.delete(f"/{path}/{{item_id}}", status_code=204) def delete_item(item_id: int, db: Session = Depends(get_db)): item = db.get(model, item_id) @@ -106,17 +227,87 @@ def register_crud(path: str, model, out_schema, create_schema) -> None: # CRUD محتوا -register_crud("brand", models.Brand, BrandOut, BrandIn) -register_crud("services", models.Service, ServiceOut, ServiceIn) -register_crud("projects", models.Project, ProjectOut, ProjectIn) -register_crud("articles", models.Article, ArticleOut, ArticleIn) -register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn) -register_crud("faqs", models.Faq, FaqOut, FaqIn) -register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn) -register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn) -register_crud("stats", models.Stat, StatOut, StatIn) -register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn) -register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn) +register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch) +register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch) +register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch) +register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch) +register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch) +register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch) +register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch) +register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch) +register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch) +register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch) +register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch) +register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch) +register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn, TimelineItemPatch) +register_crud("stats", models.Stat, StatOut, StatIn, StatPatch) +register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn, WhyUsItemPatch) +register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn, BeforeAfterPatch) +register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn, InvestmentModelPatch) +register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn, ProcessStepPatch) +register_crud("offices", models.Office, OfficeOut, OfficeIn, OfficePatch) +register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn, GalleryImagePatch) +register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn, StatusLabelPatch) +register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch) +register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch) +register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch) +register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch) + + +# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمی‌گرداند تا بک‌اند ذخیره‌سازی قابل تعویض باشد +ALLOWED_IMAGE_TYPES = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "image/avif": "avif", +} + + +@router.post("/upload") +async def upload_media( + file: UploadFile = File(...), + _admin: models.AdminUser = Depends(get_current_admin), +): + content_type = (file.content_type or "").lower() + ext = ALLOWED_IMAGE_TYPES.get(content_type) + if ext is None: + raise HTTPException( + status_code=415, + detail="فرمت فایل پشتیبانی نمی‌شود (مجاز: jpg، png، webp، gif، avif)", + ) + + max_bytes = settings.media_max_upload_mb * 1024 * 1024 + data = await file.read() + if len(data) == 0: + raise HTTPException(status_code=400, detail="فایل خالی است") + if len(data) > max_bytes: + raise HTTPException( + status_code=413, + detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است", + ) + + now = datetime.now(timezone.utc) + key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}" + + s3 = boto3.client( + "s3", + endpoint_url=settings.s3_endpoint, + aws_access_key_id=settings.s3_access_key, + aws_secret_access_key=settings.s3_secret_key, + region_name="us-east-1", + ) + try: + s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type) + except Exception: + raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود") + + return { + "url": f"{settings.media_public_base}/{key}", + "key": key, + "size": len(data), + "content_type": content_type, + } # پیام‌های تماس diff --git a/backend/app/api/public.py b/backend/app/api/public.py index 895111a..25a312d 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -37,12 +37,12 @@ def get_brand(db: Session = Depends(get_db)): @router.get("/services", response_model=list[ServiceOut]) def get_services(db: Session = Depends(get_db)): - return db.query(models.Service).all() + return db.query(models.Service).order_by(models.Service.position, models.Service.id).all() @router.get("/projects", response_model=list[ProjectOut]) def get_projects(db: Session = Depends(get_db)): - return db.query(models.Project).order_by(models.Project.id).all() + return db.query(models.Project).order_by(models.Project.position, models.Project.id).all() @router.get("/projects/{slug}", response_model=ProjectOut) @@ -55,12 +55,21 @@ def get_project(slug: str, db: Session = Depends(get_db)): @router.get("/articles", response_model=list[ArticleOut]) def get_articles(db: Session = Depends(get_db)): - return db.query(models.Article).order_by(models.Article.id).all() + return ( + db.query(models.Article) + .filter(models.Article.status == "published") + .order_by(models.Article.position, models.Article.id) + .all() + ) @router.get("/articles/{slug}", response_model=ArticleOut) def get_article(slug: str, db: Session = Depends(get_db)): - article = db.query(models.Article).filter(models.Article.slug == slug).first() + article = ( + db.query(models.Article) + .filter(models.Article.slug == slug, models.Article.status == "published") + .first() + ) if article is None: raise HTTPException(status_code=404, detail="مقاله یافت نشد") return article @@ -68,32 +77,32 @@ def get_article(slug: str, db: Session = Depends(get_db)): @router.get("/testimonials", response_model=list[TestimonialOut]) def get_testimonials(db: Session = Depends(get_db)): - return db.query(models.Testimonial).all() + return db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all() @router.get("/faqs", response_model=list[FaqOut]) def get_faqs(db: Session = Depends(get_db)): - return db.query(models.Faq).all() + return db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all() @router.get("/team", response_model=list[TeamMemberOut]) def get_team(db: Session = Depends(get_db)): - return db.query(models.TeamMember).all() + return db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all() @router.get("/timeline", response_model=list[TimelineItemOut]) def get_timeline(db: Session = Depends(get_db)): - return db.query(models.TimelineItem).order_by(models.TimelineItem.id).all() + return db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all() @router.get("/stats", response_model=list[StatOut]) def get_stats(db: Session = Depends(get_db)): - return db.query(models.Stat).all() + return db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all() @router.get("/why-us", response_model=list[WhyUsItemOut]) def get_why_us(db: Session = Depends(get_db)): - return db.query(models.WhyUsItem).all() + return db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all() @router.get("/before-after", response_model=BeforeAfterOut) @@ -109,14 +118,32 @@ def get_site(db: Session = Depends(get_db)): """همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند.""" return { "brand": db.query(models.Brand).first(), - "services": db.query(models.Service).all(), - "projects": db.query(models.Project).order_by(models.Project.id).all(), - "articles": db.query(models.Article).order_by(models.Article.id).all(), - "testimonials": db.query(models.Testimonial).all(), - "faqs": db.query(models.Faq).all(), - "team": db.query(models.TeamMember).all(), - "timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(), - "stats": db.query(models.Stat).all(), - "why_us": db.query(models.WhyUsItem).all(), + "nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(), + "hero": db.query(models.Hero).first(), + "story": db.query(models.StorySection).first(), + "section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(), + "page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(), + "services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(), + "projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(), + # فقط مقاله‌های منتشرشده — پیش‌نویس‌ها از انتهای مدیریتی خوانده می‌شوند + "articles": db.query(models.Article) + .filter(models.Article.status == "published") + .order_by(models.Article.position, models.Article.id) + .all(), + "testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(), + "faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(), + "team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(), + "timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all(), + "stats": db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all(), + "why_us": db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all(), "before_after": db.query(models.BeforeAfter).first(), + "investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.position, models.InvestmentModel.id).all(), + "process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.position, models.ProcessStep.id).all(), + "offices": db.query(models.Office).order_by(models.Office.position, models.Office.id).all(), + "gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.position, models.GalleryImage.id).all(), + "status_labels": db.query(models.StatusLabel).order_by(models.StatusLabel.id).all(), + "contact_section": db.query(models.ContactSectionContent).first(), + "newsletter": db.query(models.NewsletterSectionContent).first(), + "footer": db.query(models.FooterContent).first(), + "seo_meta": db.query(models.SeoMeta).order_by(models.SeoMeta.id).all(), } diff --git a/backend/app/config.py b/backend/app/config.py index 641e5aa..2d97aeb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -18,5 +18,19 @@ class Settings(BaseSettings): admin_username: str = "admin" admin_password: str = "aramesh123" + # Object Storage (MinIO / هر S3 سازگار) برای آپلود تصاویر + s3_endpoint: str = "http://localhost:9000" + s3_access_key: str = "aramesh" + s3_secret_key: str = "aramesh-minio-secret" + s3_bucket: str = "aramesh-media" + # پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی می‌کند + media_public_base: str = "/media" + media_max_upload_mb: int = 10 + + # نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG + site_url: str = "" + # مسیر بیلد فرانت‌اند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات + frontend_dist: str = "../frontend/dist" + settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index a5c8a02..e04fea7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,8 +5,11 @@ from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت from .api import admin, forms, public from .config import settings from .database import Base, engine +from .migrations import run_all as run_migrations +from .seo_pages import router as seo_router Base.metadata.create_all(bind=engine) +run_migrations() app = FastAPI( title="Aramesh Building Group API", @@ -31,6 +34,8 @@ app.include_router(public.router, prefix="/api", tags=["عمومی"]) app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"]) app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"]) app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"]) +# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه +app.include_router(seo_router, tags=["سئو"]) @app.get("/") diff --git a/backend/app/migrations.py b/backend/app/migrations.py new file mode 100644 index 0000000..3e4cd48 --- /dev/null +++ b/backend/app/migrations.py @@ -0,0 +1,71 @@ +"""مهاجرت‌های سبک دیتابیس — DDL و تبدیل داده‌های قدیمی؛ همه idempotent. + +هم main.py (startup) و هم seed.py این‌ها را صدا می‌زنند تا هر مسیر اجرایی +دیتابیس موجود را بدون از دست رفتن داده ارتقا دهد. +""" + +from sqlalchemy import text + +from .database import SessionLocal, engine + +# جدول‌های مجموعه‌ای که ستون position دارند +POSITION_TABLES = [ + "services", "projects", "articles", "testimonials", "faqs", "team", + "timeline", "stats", "why_us", "investment_models", "process_steps", + "offices", "gallery_images", "nav_items", +] + + +def run_ddl_migrations() -> None: + """ستون‌های جدید — برای PostgreSQL با ADD COLUMN IF NOT EXISTS.""" + if engine.dialect.name != "postgresql": + return + with engine.begin() as conn: + for table in POSITION_TABLES: + conn.execute( + text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0") + ) + # مقالات موجود منتشر شده‌اند؛ فقط مقاله‌های تازه می‌توانند پیش‌نویس باشند + conn.execute( + text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'published'") + ) + conn.execute( + text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''") + ) + + +def paragraphs_to_doc(paragraphs: list) -> dict: + """تبدیل لیست پاراگراف‌های متنی (فرمت قدیمی بدنه مقاله) به سند TipTap.""" + content = [] + for para in paragraphs: + if isinstance(para, str) and para.strip(): + content.append({ + "type": "paragraph", + "content": [{"type": "text", "text": para}], + }) + if not content: + content = [{"type": "paragraph"}] + return {"type": "doc", "content": content} + + +def migrate_article_bodies() -> None: + """تبدیل بدنه مقالات از لیست پاراگراف به سند JSON — idempotent.""" + from . import models # lazy — جلوگیری از import حلقه‌ای + + db = SessionLocal() + try: + changed = 0 + for article in db.query(models.Article).all(): + if isinstance(article.body, list): + article.body = paragraphs_to_doc(article.body) + changed += 1 + db.commit() + if changed: + print(f"مقالات تبدیل‌شده به فرمت سند: {changed}") + finally: + db.close() + + +def run_all() -> None: + run_ddl_migrations() + migrate_article_bodies() diff --git a/backend/app/models.py b/backend/app/models.py index d8b3d02..f5929a7 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -35,6 +35,8 @@ class Brand(Base): class Service(Base): __tablename__ = "services" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(256)) desc: Mapped[str] = mapped_column(Text, default="") @@ -44,6 +46,8 @@ class Service(Base): class Project(Base): __tablename__ = "projects" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True) title: Mapped[str] = mapped_column(String(256)) @@ -63,6 +67,8 @@ class Project(Base): class Article(Base): __tablename__ = "articles" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True) title: Mapped[str] = mapped_column(String(256)) @@ -72,11 +78,17 @@ class Article(Base): read: Mapped[str] = mapped_column(String(32), default="") excerpt: Mapped[str] = mapped_column(Text, default="") body: Mapped[list] = mapped_column(JSON, default=list) + # draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود + status: Mapped[str] = mapped_column(String(16), default="published") + # کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود + seo_keyphrase: Mapped[str] = mapped_column(String(128), default="") class Testimonial(Base): __tablename__ = "testimonials" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(128)) role: Mapped[str] = mapped_column(String(128), default="") @@ -86,6 +98,8 @@ class Testimonial(Base): class Faq(Base): __tablename__ = "faqs" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) question: Mapped[str] = mapped_column(Text) answer: Mapped[str] = mapped_column(Text, default="") @@ -94,6 +108,8 @@ class Faq(Base): class TeamMember(Base): __tablename__ = "team" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(128)) role: Mapped[str] = mapped_column(String(128), default="") @@ -103,6 +119,8 @@ class TeamMember(Base): class TimelineItem(Base): __tablename__ = "timeline" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) year: Mapped[str] = mapped_column(String(32)) title: Mapped[str] = mapped_column(String(256)) @@ -113,6 +131,8 @@ class TimelineItem(Base): class Stat(Base): __tablename__ = "stats" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) value: Mapped[int] = mapped_column(Integer) suffix: Mapped[str] = mapped_column(String(16), default="") @@ -122,6 +142,8 @@ class Stat(Base): class WhyUsItem(Base): __tablename__ = "why_us" + position: Mapped[int] = mapped_column(Integer, default=0) + id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(256)) desc: Mapped[str] = mapped_column(Text, default="") @@ -139,6 +161,200 @@ class BeforeAfter(Base): after_label: Mapped[str] = mapped_column(String(128), default="") +class NavItem(Base): + __tablename__ = "nav_items" + + position: Mapped[int] = mapped_column(Integer, default=0) + + id: Mapped[int] = mapped_column(primary_key=True) + label: Mapped[str] = mapped_column(String(64)) + href: Mapped[str] = mapped_column(String(128)) + + +class Hero(Base): + """محتوای هیرو صفحه اصلی — رکورد یگانه.""" + + __tablename__ = "hero" + + id: Mapped[int] = mapped_column(primary_key=True) + kicker: Mapped[str] = mapped_column(Text, default="") + title: Mapped[str] = mapped_column(Text, default="") + title_highlight: Mapped[str] = mapped_column(String(128), default="") + subtitle: Mapped[str] = mapped_column(Text, default="") + badge_value: Mapped[str] = mapped_column(String(32), default="") + badge_label: Mapped[str] = mapped_column(Text, default="") + cta_primary_label: Mapped[str] = mapped_column(String(128), default="") + cta_primary_href: Mapped[str] = mapped_column(String(128), default="") + cta_secondary_label: Mapped[str] = mapped_column(String(128), default="") + cta_secondary_href: Mapped[str] = mapped_column(String(128), default="") + scroll_label: Mapped[str] = mapped_column(String(64), default="") + + +class StorySection(Base): + """بخش «درباره ما» در خانه و صفحه درباره — رکورد یگانه.""" + + __tablename__ = "story" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(256), default="") + paragraphs: Mapped[list] = mapped_column(JSON, default=list) + quote: Mapped[str] = mapped_column(Text, default="") + quote_author: Mapped[str] = mapped_column(Text, default="") + stat1_value: Mapped[str] = mapped_column(String(32), default="") + stat1_label: Mapped[str] = mapped_column(String(128), default="") + stat2_value: Mapped[str] = mapped_column(String(32), default="") + stat2_label: Mapped[str] = mapped_column(String(128), default="") + link_label: Mapped[str] = mapped_column(String(128), default="") + link_href: Mapped[str] = mapped_column(String(128), default="") + bento: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}] + + +class SectionHeadingContent(Base): + """تیتر و توضیح بخش‌ها — فرانت‌اند بر اساس key آن را پیدا می‌کند.""" + + __tablename__ = "section_headings" + + id: Mapped[int] = mapped_column(primary_key=True) + key: Mapped[str] = mapped_column(String(64), unique=True, index=True) + label: Mapped[str] = mapped_column(String(64), default="") + title: Mapped[str] = mapped_column(String(256), default="") + lead: Mapped[str] = mapped_column(Text, default="") + image: Mapped[str] = mapped_column(Text, default="") + cta_label: Mapped[str] = mapped_column(String(128), default="") + cta_href: Mapped[str] = mapped_column(String(128), default="") + + +class PageHeroContent(Base): + """هیرو بالای هر صفحه — key: about | services | projects | investment | blog | faq | contact.""" + + __tablename__ = "page_heroes" + + id: Mapped[int] = mapped_column(primary_key=True) + key: Mapped[str] = mapped_column(String(32), unique=True, index=True) + title: Mapped[str] = mapped_column(String(256), default="") + lead: Mapped[str] = mapped_column(Text, default="") + image: Mapped[str] = mapped_column(Text, default="") + + +class InvestmentModel(Base): + __tablename__ = "investment_models" + + position: Mapped[int] = mapped_column(Integer, default=0) + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(256)) + desc: Mapped[str] = mapped_column(Text, default="") + points: Mapped[list] = mapped_column(JSON, default=list) + + +class ProcessStep(Base): + __tablename__ = "process_steps" + + position: Mapped[int] = mapped_column(Integer, default=0) + + id: Mapped[int] = mapped_column(primary_key=True) + step: Mapped[str] = mapped_column(String(16), default="") + title: Mapped[str] = mapped_column(String(256)) + desc: Mapped[str] = mapped_column(Text, default="") + + +class Office(Base): + __tablename__ = "offices" + + position: Mapped[int] = mapped_column(Integer, default=0) + + id: Mapped[int] = mapped_column(primary_key=True) + city: Mapped[str] = mapped_column(String(64)) + address: Mapped[str] = mapped_column(Text, default="") + hours: Mapped[str] = mapped_column(String(128), default="") + + +class GalleryImage(Base): + __tablename__ = "gallery_images" + + position: Mapped[int] = mapped_column(Integer, default=0) + + id: Mapped[int] = mapped_column(primary_key=True) + src: Mapped[str] = mapped_column(Text, default="") + alt: Mapped[str] = mapped_column(Text, default="") + + +class StatusLabel(Base): + """برچسب فارسی وضعیت پروژه — key: done | progress.""" + + __tablename__ = "status_labels" + + id: Mapped[int] = mapped_column(primary_key=True) + key: Mapped[str] = mapped_column(String(16), unique=True, index=True) + label: Mapped[str] = mapped_column(String(64)) + + +class ContactSectionContent(Base): + """بخش فرم تماس — رکورد یگانه.""" + + __tablename__ = "contact_section" + + id: Mapped[int] = mapped_column(primary_key=True) + label: Mapped[str] = mapped_column(String(64), default="") + title: Mapped[str] = mapped_column(String(256), default="") + lead: Mapped[str] = mapped_column(Text, default="") + address_label: Mapped[str] = mapped_column(String(64), default="") + phone_label: Mapped[str] = mapped_column(String(64), default="") + email_label: Mapped[str] = mapped_column(String(64), default="") + name_label: Mapped[str] = mapped_column(String(64), default="") + name_placeholder: Mapped[str] = mapped_column(String(128), default="") + phone_field_label: Mapped[str] = mapped_column(String(64), default="") + phone_placeholder: Mapped[str] = mapped_column(String(64), default="") + note_label: Mapped[str] = mapped_column(String(64), default="") + note_optional_label: Mapped[str] = mapped_column(String(64), default="") + note_placeholder: Mapped[str] = mapped_column(String(256), default="") + submit_label: Mapped[str] = mapped_column(String(128), default="") + sending_label: Mapped[str] = mapped_column(String(64), default="") + success_title: Mapped[str] = mapped_column(String(256), default="") + success_text: Mapped[str] = mapped_column(Text, default="") + success_button_label: Mapped[str] = mapped_column(String(128), default="") + + +class NewsletterSectionContent(Base): + """بخش خبرنامه — رکورد یگانه.""" + + __tablename__ = "newsletter" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(256), default="") + lead: Mapped[str] = mapped_column(Text, default="") + placeholder: Mapped[str] = mapped_column(String(128), default="") + button_label: Mapped[str] = mapped_column(String(64), default="") + sending_label: Mapped[str] = mapped_column(String(64), default="") + success_text: Mapped[str] = mapped_column(String(256), default="") + + +class FooterContent(Base): + """متن‌های فوتر — رکورد یگانه.""" + + __tablename__ = "footer" + + id: Mapped[int] = mapped_column(primary_key=True) + about_text: Mapped[str] = mapped_column(Text, default="") + pages_heading: Mapped[str] = mapped_column(String(64), default="") + services_heading: Mapped[str] = mapped_column(String(64), default="") + contact_heading: Mapped[str] = mapped_column(String(64), default="") + copyright: Mapped[str] = mapped_column(String(256), default="") + tagline: Mapped[str] = mapped_column(String(256), default="") + socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}] + + +class SeoMeta(Base): + """عنوان و توضیح سئوی هر صفحه — page_key: home / about / services / ...""" + + __tablename__ = "seo_meta" + + id: Mapped[int] = mapped_column(primary_key=True) + page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) + title: Mapped[str] = mapped_column(String(256), default="") + description: Mapped[str] = mapped_column(Text, default="") + + class ContactMessage(Base): __tablename__ = "contact_messages" diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py new file mode 100644 index 0000000..ec35dc3 --- /dev/null +++ b/backend/app/rate_limit.py @@ -0,0 +1,49 @@ +"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login. + +پیش‌فرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک می‌کند. +شمارنده در حافظه همین پروسه است — با ری‌استارت بک‌اند پاک می‌شود و همین کافی است. +""" + +import time + +from fastapi import HTTPException, Request, status + +MAX_FAILED_ATTEMPTS = 5 +WINDOW_SECONDS = 15 * 60 + +_failed: dict[str, list[float]] = {} + + +def client_ip(request: Request) -> str: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def _prune(key: str, now: float) -> list[float]: + recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS] + _failed[key] = recent + return recent + + +def ensure_allowed(request: Request) -> None: + """اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد می‌شود.""" + key = client_ip(request) + recent = _prune(key, time.monotonic()) + if len(recent) >= MAX_FAILED_ATTEMPTS: + retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0]))) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="تلاش‌های ناموفق زیاد است — بعداً دوباره امتحان کنید", + headers={"Retry-After": str(retry_after)}, + ) + + +def record_failure(request: Request) -> None: + _failed.setdefault(client_ip(request), []).append(time.monotonic()) + + +def clear(request: Request) -> None: + """با ورود موفق، تاریخچه تلاش‌های ناموفق IP پاک می‌شود.""" + _failed.pop(client_ip(request), None) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d9815db..0e5811e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,7 +1,7 @@ from datetime import datetime -from typing import Literal +from typing import Literal, Optional -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator # ---------- پایه ---------- @@ -58,7 +58,9 @@ class ArticleOut(ORMModel): image: str read: str excerpt: str - body: list[str] + body: dict + status: str + seo_keyphrase: str class TestimonialOut(ORMModel): @@ -112,6 +114,144 @@ class BeforeAfterOut(ORMModel): after_label: str +class NavItemOut(ORMModel): + id: int + label: str + href: str + + +class HeroOut(ORMModel): + id: int + kicker: str + title: str + title_highlight: str + subtitle: str + badge_value: str + badge_label: str + cta_primary_label: str + cta_primary_href: str + cta_secondary_label: str + cta_secondary_href: str + scroll_label: str + + +class StoryOut(ORMModel): + id: int + title: str + paragraphs: list[str] + quote: str + quote_author: str + stat1_value: str + stat1_label: str + stat2_value: str + stat2_label: str + link_label: str + link_href: str + bento: list[dict] + + +class SectionHeadingOut(ORMModel): + id: int + key: str + label: str + title: str + lead: str + image: str + cta_label: str + cta_href: str + + +class PageHeroOut(ORMModel): + id: int + key: str + title: str + lead: str + image: str + + +class InvestmentModelOut(ORMModel): + id: int + title: str + desc: str + points: list[str] + + +class ProcessStepOut(ORMModel): + id: int + step: str + title: str + desc: str + + +class OfficeOut(ORMModel): + id: int + city: str + address: str + hours: str + + +class GalleryImageOut(ORMModel): + id: int + src: str + alt: str + + +class StatusLabelOut(ORMModel): + id: int + key: str + label: str + + +class ContactSectionOut(ORMModel): + id: int + label: str + title: str + lead: str + address_label: str + phone_label: str + email_label: str + name_label: str + name_placeholder: str + phone_field_label: str + phone_placeholder: str + note_label: str + note_optional_label: str + note_placeholder: str + submit_label: str + sending_label: str + success_title: str + success_text: str + success_button_label: str + + +class NewsletterOut(ORMModel): + id: int + title: str + lead: str + placeholder: str + button_label: str + sending_label: str + success_text: str + + +class FooterOut(ORMModel): + id: int + about_text: str + pages_heading: str + services_heading: str + contact_heading: str + copyright: str + tagline: str + socials: list[dict] + + +class SeoMetaOut(ORMModel): + id: int + page_key: str + title: str + description: str + + class ContactMessageOut(ORMModel): id: int name: str @@ -154,6 +294,11 @@ class TokenOut(BaseModel): token_type: str = "bearer" +class ChangePasswordIn(BaseModel): + current_password: str + new_password: str = Field(min_length=8, max_length=128) + + class BrandIn(BaseModel): name: str = "" logo: str = "" @@ -187,6 +332,18 @@ class ProjectIn(BaseModel): body: list[str] = [] +EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]} + + +def validate_doc_body(value: object) -> dict: + """بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد.""" + if not value: + return dict(EMPTY_DOC) + if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list): + raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}") + return value + + class ArticleIn(BaseModel): slug: str = Field(min_length=1, max_length=128) title: str = Field(min_length=1, max_length=256) @@ -195,7 +352,10 @@ class ArticleIn(BaseModel): image: str = "" read: str = "" excerpt: str = "" - body: list[str] = [] + body: dict = Field(default_factory=lambda: dict(EMPTY_DOC)) + status: Literal["draft", "published"] = "draft" + seo_keyphrase: str = Field(default="", max_length=128) + _validate_body = field_validator("body")(validate_doc_body) class TestimonialIn(BaseModel): @@ -242,5 +402,187 @@ class BeforeAfterIn(BaseModel): after_label: str = "" +class NavItemIn(BaseModel): + label: str = Field(min_length=1, max_length=64) + href: str = Field(min_length=1, max_length=128) + + +class HeroIn(BaseModel): + kicker: str = "" + title: str = "" + title_highlight: str = "" + subtitle: str = "" + badge_value: str = "" + badge_label: str = "" + cta_primary_label: str = "" + cta_primary_href: str = "" + cta_secondary_label: str = "" + cta_secondary_href: str = "" + scroll_label: str = "" + + +class StoryIn(BaseModel): + title: str = "" + paragraphs: list[str] = [] + quote: str = "" + quote_author: str = "" + stat1_value: str = "" + stat1_label: str = "" + stat2_value: str = "" + stat2_label: str = "" + link_label: str = "" + link_href: str = "" + bento: list[dict] = [] + + +class SectionHeadingIn(BaseModel): + key: str = Field(min_length=1, max_length=64) + label: str = "" + title: str = "" + lead: str = "" + image: str = "" + cta_label: str = "" + cta_href: str = "" + + +class PageHeroIn(BaseModel): + key: str = Field(min_length=1, max_length=32) + title: str = "" + lead: str = "" + image: str = "" + + +class InvestmentModelIn(BaseModel): + title: str = Field(min_length=1, max_length=256) + desc: str = "" + points: list[str] = [] + + +class ProcessStepIn(BaseModel): + step: str = "" + title: str = Field(min_length=1, max_length=256) + desc: str = "" + + +class OfficeIn(BaseModel): + city: str = Field(min_length=1, max_length=64) + address: str = "" + hours: str = "" + + +class GalleryImageIn(BaseModel): + src: str = "" + alt: str = "" + + +class StatusLabelIn(BaseModel): + key: str = Field(min_length=1, max_length=16) + label: str = Field(min_length=1, max_length=64) + + +class ContactSectionIn(BaseModel): + label: str = "" + title: str = "" + lead: str = "" + address_label: str = "" + phone_label: str = "" + email_label: str = "" + name_label: str = "" + name_placeholder: str = "" + phone_field_label: str = "" + phone_placeholder: str = "" + note_label: str = "" + note_optional_label: str = "" + note_placeholder: str = "" + submit_label: str = "" + sending_label: str = "" + success_title: str = "" + success_text: str = "" + success_button_label: str = "" + + +class NewsletterSectionIn(BaseModel): + title: str = "" + lead: str = "" + placeholder: str = "" + button_label: str = "" + sending_label: str = "" + success_text: str = "" + + +class FooterIn(BaseModel): + about_text: str = "" + pages_heading: str = "" + services_heading: str = "" + contact_heading: str = "" + copyright: str = "" + tagline: str = "" + socials: list[dict] = [] + + +class SeoMetaIn(BaseModel): + page_key: str = Field(min_length=1, max_length=32) + title: str = "" + description: str = "" + + +class SeoMetaPatch(BaseModel): + page_key: Optional[str] = None + title: Optional[str] = None + description: Optional[str] = None + + class MessageReadIn(BaseModel): is_read: bool = True + + +# ---------- اسکیماهای PATCH (ویرایش جزئی — همه فیلدها اختیاری) ---------- + + +def _make_patch(name: str, model_cls: type[BaseModel]) -> type[BaseModel]: + """از اسکیمای ورودی، اسکیمای PATCH می‌سازد؛ فیلدهای ارسال‌نشده دست‌نخورده می‌مانند.""" + fields = {fname: (Optional[f.annotation], None) for fname, f in model_cls.model_fields.items()} + return create_model(name, **fields) + + +BrandPatch = _make_patch("BrandPatch", BrandIn) +ServicePatch = _make_patch("ServicePatch", ServiceIn) +ProjectPatch = _make_patch("ProjectPatch", ProjectIn) +class ArticlePatch(BaseModel): + slug: Optional[str] = None + title: Optional[str] = None + date: Optional[str] = None + category: Optional[str] = None + image: Optional[str] = None + read: Optional[str] = None + excerpt: Optional[str] = None + body: Optional[dict] = None + status: Optional[Literal["draft", "published"]] = None + seo_keyphrase: Optional[str] = None + _validate_body = field_validator("body")(validate_doc_body) +TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn) +FaqPatch = _make_patch("FaqPatch", FaqIn) +TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn) +TimelineItemPatch = _make_patch("TimelineItemPatch", TimelineItemIn) +StatPatch = _make_patch("StatPatch", StatIn) +WhyUsItemPatch = _make_patch("WhyUsItemPatch", WhyUsItemIn) +BeforeAfterPatch = _make_patch("BeforeAfterPatch", BeforeAfterIn) +NavItemPatch = _make_patch("NavItemPatch", NavItemIn) +HeroPatch = _make_patch("HeroPatch", HeroIn) +StoryPatch = _make_patch("StoryPatch", StoryIn) +SectionHeadingPatch = _make_patch("SectionHeadingPatch", SectionHeadingIn) +PageHeroPatch = _make_patch("PageHeroPatch", PageHeroIn) +InvestmentModelPatch = _make_patch("InvestmentModelPatch", InvestmentModelIn) +ProcessStepPatch = _make_patch("ProcessStepPatch", ProcessStepIn) +OfficePatch = _make_patch("OfficePatch", OfficeIn) +GalleryImagePatch = _make_patch("GalleryImagePatch", GalleryImageIn) +StatusLabelPatch = _make_patch("StatusLabelPatch", StatusLabelIn) +ContactSectionPatch = _make_patch("ContactSectionPatch", ContactSectionIn) +NewsletterSectionPatch = _make_patch("NewsletterSectionPatch", NewsletterSectionIn) +FooterPatch = _make_patch("FooterPatch", FooterIn) + + +class ReorderIn(BaseModel): + """ترتیب جدید آیتم‌های یک مجموعه بر اساس id.""" + + ids: list[int] diff --git a/backend/app/seed.py b/backend/app/seed.py index 545f561..07b091e 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -1,16 +1,50 @@ """اسکریپت seed دیتابیس. اجرا: - python -m app.seed # فقط اگر دیتابیس خالی باشد + python -m app.seed # فقط جدول‌های خالی پر می‌شوند python -m app.seed --force # حذف کامل داده‌ها و ساخت مجدد + +پر کردن هر جدول مستقل است: روی یک دیتابیس موجود، فقط جدول‌های جدید یا خالی +seed می‌شوند و داده‌های قبلی دست‌نخورده می‌مانند. """ import argparse +from typing import Any from . import models, seed_data from .auth import hash_password from .config import settings from .database import Base, SessionLocal, engine +from .migrations import paragraphs_to_doc, run_all as run_migrations + +# (مدل، داده‌های اولیه، برچسب گزارش) +CONTENT: list[tuple[Any, list[dict], str]] = [ + (models.Brand, [seed_data.BRAND], "برند"), + (models.Hero, [seed_data.HERO], "هیرو صفحه اصلی"), + (models.StorySection, [seed_data.STORY], "داستان ما"), + (models.SectionHeadingContent, seed_data.SECTION_HEADINGS, "تیتر بخش‌ها"), + (models.PageHeroContent, seed_data.PAGE_HEROES, "هیرو صفحات"), + (models.NavItem, seed_data.NAV_ITEMS, "منوی ناوبری"), + (models.InvestmentModel, seed_data.INVESTMENT_MODELS, "مدل‌های سرمایه‌گذاری"), + (models.ProcessStep, seed_data.PROCESS_STEPS, "مراحل فرآیند کار"), + (models.Office, seed_data.OFFICES, "دفاتر"), + (models.GalleryImage, seed_data.GALLERY_IMAGES, "گالری تصاویر"), + (models.StatusLabel, seed_data.STATUS_LABELS, "برچسب وضعیت پروژه"), + (models.ContactSectionContent, [seed_data.CONTACT_SECTION], "بخش تماس"), + (models.NewsletterSectionContent, [seed_data.NEWSLETTER], "خبرنامه"), + (models.FooterContent, [seed_data.FOOTER], "فوتر"), + (models.Service, seed_data.SERVICES, "خدمات"), + (models.Stat, seed_data.STATS, "آمار"), + (models.Project, seed_data.PROJECTS, "پروژه‌ها"), + (models.Article, seed_data.ARTICLES, "مقالات"), + (models.Testimonial, seed_data.TESTIMONIALS, "نظرات"), + (models.Faq, seed_data.FAQS, "سوالات متداول"), + (models.TeamMember, seed_data.TEAM, "تیم"), + (models.TimelineItem, seed_data.TIMELINE, "تایم‌لاین"), + (models.WhyUsItem, seed_data.WHY, "چرا آرامش"), + (models.BeforeAfter, [seed_data.BEFORE_AFTER], "قبل/بعد"), + (models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"), +] def seed(force: bool = False) -> None: @@ -21,32 +55,19 @@ def seed(force: bool = False) -> None: print("جداول قبلی حذف شدند.") Base.metadata.create_all(bind=engine) + run_migrations() - if not force and db.query(models.Service).count() > 0: - print("دیتابیس قبلاً seed شده است. برای بازنشانی کامل: python -m app.seed --force") - return - - db.add(models.Brand(**seed_data.BRAND)) - - for item in seed_data.SERVICES: - db.add(models.Service(**item)) - for item in seed_data.STATS: - db.add(models.Stat(**item)) - for item in seed_data.PROJECTS: - db.add(models.Project(**item)) - for item in seed_data.ARTICLES: - db.add(models.Article(**item)) - for item in seed_data.TESTIMONIALS: - db.add(models.Testimonial(**item)) - for item in seed_data.FAQS: - db.add(models.Faq(**item)) - for item in seed_data.TEAM: - db.add(models.TeamMember(**item)) - for item in seed_data.TIMELINE: - db.add(models.TimelineItem(**item)) - for item in seed_data.WHY: - db.add(models.WhyUsItem(**item)) - db.add(models.BeforeAfter(**seed_data.BEFORE_AFTER)) + for model, rows, label in CONTENT: + if db.query(model).count() > 0: + continue + for idx, row in enumerate(rows): + payload = dict(row) + if hasattr(model, "position") and "position" not in payload: + payload["position"] = idx + # بدنه مقالات در seed به‌صورت لیست پاراگراف است — به سند تبدیل می‌شود + if model is models.Article and isinstance(payload.get("body"), list): + payload["body"] = paragraphs_to_doc(payload["body"]) + db.add(model(**payload)) if db.query(models.AdminUser).count() == 0: db.add( @@ -59,19 +80,8 @@ def seed(force: bool = False) -> None: db.commit() - counts = { - "خدمات": db.query(models.Service).count(), - "پروژه‌ها": db.query(models.Project).count(), - "مقالات": db.query(models.Article).count(), - "نظرات": db.query(models.Testimonial).count(), - "سوالات متداول": db.query(models.Faq).count(), - "تیم": db.query(models.TeamMember).count(), - "تایم‌لاین": db.query(models.TimelineItem).count(), - "آمار": db.query(models.Stat).count(), - "چرا آرامش": db.query(models.WhyUsItem).count(), - } - for label, count in counts.items(): - print(f" {label}: {count} رکورد") + for model, _, label in CONTENT: + print(f" {label}: {db.query(model).count()} رکورد") print("✔ seed با موفقیت انجام شد.") finally: db.close() diff --git a/backend/app/seed_data.py b/backend/app/seed_data.py index 848619d..803bf8d 100644 --- a/backend/app/seed_data.py +++ b/backend/app/seed_data.py @@ -2,9 +2,9 @@ BRAND = { "name": "گروه ساختمانی آرامش", - "logo": "/images/logo.png", - "hero": "/images/hero.png", - "contact_bg": "/images/contact.png", + "logo": "https://i.postimg.cc/XYNrsHsM/Chat-GPT-Image-Aug-6-2026-04-34-46-PM.png", + "hero": "https://i.postimg.cc/J0bKgQVg/Gemini-Generated-Image-mdh969mdh969mdh9.png", + "contact_bg": "https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png", "phone": "۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰", "phone_href": "tel:+982188742210", "email": "info@arameshbuildinggroup.com", @@ -12,11 +12,11 @@ BRAND = { } IMAGES = [ - "/images/img1.png", - "/images/img2.png", - "/images/contact.png", - "/images/img4.png", - "/images/img5.png", + "https://i.postimg.cc/wTQ0QFkG/Gemini-Generated-Image-1tjszd1tjszd1tjs.png", + "https://i.postimg.cc/1zfBPS1G/Gemini-Generated-Image-at69yhat69yhat69.png", + "https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png", + "https://i.postimg.cc/4xFvW0HX/Gemini-Generated-Image-n7mhonn7mhonn7mh.png", + "https://i.postimg.cc/N036cr4s/Gemini-Generated-Image-ytbhvvytbhvvytbh.png", ] SERVICES = [ @@ -154,8 +154,8 @@ PROJECTS = [ BEFORE_AFTER = { "title": "از کانسپت تا خلق واقعیت", "lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل می‌شود.", - "before": "/images/before.webp", - "after": "/images/after.webp", + "before": "https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_before.webp", + "after": "https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_after.webp", "before_label": "کانسپت اولیه", "after_label": "پروژه تکمیل‌شده", } @@ -289,3 +289,170 @@ FAQS = [ {"question": "خدمات پس از تحویل شامل چه مواردی می‌شود؟", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دوره‌ای سالانه و تضمین اجرای نما و عایق‌کاری."}, {"question": "پروژه‌ها در چه شهرهایی اجرا می‌شوند؟", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژه‌ای نیز در جزیره کیش در دست ساخت است."}, ] + +# ---------- محتوای قبلاً هاردکد‌شده در فرانت‌اند (الان در دیتابیس) ---------- + +NAV_ITEMS = [ + {"label": "صفحه اصلی", "href": "/"}, + {"label": "درباره ما", "href": "/about"}, + {"label": "خدمات", "href": "/services"}, + {"label": "پروژه‌ها", "href": "/projects"}, + {"label": "سرمایه‌گذاری", "href": "/investment"}, + {"label": "وبلاگ", "href": "/blog"}, + {"label": "سوالات متداول", "href": "/faq"}, + {"label": "تماس با ما", "href": "/contact"}, +] + +HERO = { + "kicker": "از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال", + "title": "ساختمانی که آرامش را معماری می‌کند", + "title_highlight": "معماری", + "subtitle": "طراحی، ساخت و سرمایه‌گذاری در پروژه‌های مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده می‌شود و در سال‌ها دوام می‌آورد.", + "badge_value": "۴۷", + "badge_label": "پروژه تحویل‌شده در بیست‌ویک سال فعالیت حرفه‌ای", + "cta_primary_label": "مشاهده پروژه‌ها", + "cta_primary_href": "/projects", + "cta_secondary_label": "مشاوره سرمایه‌گذاری", + "cta_secondary_href": "/contact", + "scroll_label": "اسکرول کنید", +} + +STORY = { + "title": "درباره ی ما", + "paragraphs": [ + "گروه ساختمانی آرامش با یک دفتر کوچک معماری آغاز شد و امروز یکی از مجموعه‌های شناخته‌شده ساخت‌وساز لوکس در ایران است. در تمام این سال‌ها یک اصل تغییر نکرده است: ساختمان باید برای زندگی ساخته شود، نه برای فروش سریع.", + "هر پروژه با مطالعه زمین، نور، اقلیم و الگوی زندگی ساکنان آغاز می‌شود؛ سپس تیم طراحی، سازه و اجرا در یک فرآیند واحد آن را تا تحویل کلید پیش می‌برند.", + ], + "quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی می‌سازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»", + "quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش", + "stat1_value": "۹۴٪", + "stat1_label": "تحویل به‌موقع پروژه‌ها", + "stat2_value": "۵ سال", + "stat2_label": "پشتیبانی پس از تحویل", + "link_label": "داستان کامل ما", + "link_href": "/about", + "bento": [ + {"src": IMAGES[0], "alt": "نمای برج مسکونی آرامش در تهران"}, + {"src": IMAGES[1], "alt": "آتریوم مرکزی مجتمع تجاری پارسه"}, + {"src": IMAGES[2], "alt": "ویلاهای ساخته‌شده در سواحل شمال"}, + {"src": IMAGES[3], "alt": "نمای شبانه برج دوقلوی نیاوران"}, + ], +} + +SECTION_HEADINGS = [ + {"key": "services", "title": "خدمات", "lead": "از نخستین خط روی کاغذ تا تحویل کلید، مدیریت سرمایه و طراحی محوطه؛ همه مراحل در یک مجموعه و زیر یک نظام کنترل کیفیت."}, + {"key": "featured_projects", "title": "پروژه‌های شاخص", "lead": "کارهایی که بیشترین چیز را درباره استاندارد ما می‌گویند؛ چه آن‌هایی که تحویل شده‌اند و چه آن‌هایی که همین امروز در حال ساخت‌اند.", "cta_label": "همه پروژه‌ها", "cta_href": "/projects"}, + {"key": "gallery", "title": "گالری تصاویر", "lead": "قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش."}, + {"key": "timeline", "title": "مسیر ما", "lead": "از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید."}, + {"key": "why_us", "title": "چرا گروه ساختمانی آرامش", "lead": "شش دلیلی که کارفرمایان و سرمایه‌گذاران بار دوم هم به ما برمی‌گردند.", "image": IMAGES[3]}, + {"key": "testimonials", "title": "نظرات مشتریان"}, + {"key": "team", "title": "تیم ما", "lead": "کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند."}, + {"key": "articles", "label": "وبلاگ", "title": "آخرین مقالات", "lead": "یادداشت‌های تیم معماری، اجرا و سرمایه‌گذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار می‌گذرد.", "cta_label": "همه مقالات", "cta_href": "/blog"}, + {"key": "faq", "title": "سوالات متداول", "lead": "پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران."}, + {"key": "investment_models", "title": "مدل‌های همکاری", "lead": "بسته به میزان سرمایه، افق زمانی و سطح ریسکی که می‌پذیرید، یکی از این سه مسیر مناسب شماست."}, + {"key": "process", "title": "فرآیند کار ما", "lead": "چهار مرحله شفاف، از نخستین جلسه تا تحویل کلید."}, +] + +PAGE_HEROES = [ + {"key": "about", "title": "درباره ما", "lead": "گروه ساختمانی آرامش مجموعه‌ای از معماران، مهندسان سازه و مدیران پروژه است که از سال ۱۳۸۳ در حوزه ساخت‌وساز لوکس فعالیت می‌کند.", "image": IMAGES[3]}, + {"key": "services", "title": "خدمات", "lead": "هشت حوزه تخصصی که چرخه کامل یک پروژه ساختمانی را از ایده تا بهره‌برداری پوشش می‌دهند.", "image": IMAGES[1]}, + {"key": "projects", "title": "پروژه‌ها", "lead": "مجموعه‌ای از برج‌های مسکونی، مراکز تجاری و ویلاهای اختصاصی؛ از پروژه‌های تحویل‌شده تا کارگاه‌های فعال امروز.", "image": IMAGES[0]}, + {"key": "investment", "title": "سرمایه‌گذاری", "lead": "سه مسیر روشن برای ورود به بازار ساخت‌وساز لوکس، با ساختار مالی شفاف و گزارش‌دهی منظم.", "image": IMAGES[4]}, + {"key": "blog", "title": "وبلاگ", "lead": "یادداشت‌های تیم معماری، اجرا و سرمایه‌گذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار می‌گذرد.", "image": IMAGES[2]}, + {"key": "faq", "title": "سوالات متداول", "lead": "پیش از تماس، شاید پاسخ پرسش شما همین‌جا باشد. اگر نبود، خوشحال می‌شویم مستقیم صحبت کنیم.", "image": IMAGES[1]}, + {"key": "contact", "title": "تماس با ما", "lead": "برای مشاوره ساخت، سرمایه‌گذاری یا بازدید از پروژه‌ها، تیم ما در سه شهر آماده پاسخ‌گویی است.", "image": IMAGES[1]}, +] + +INVESTMENT_MODELS = [ + { + "title": "مشارکت در ساخت", + "desc": "مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک می‌شوند؛ اجرا، مجوزها و فروش بر عهده ماست.", + "points": ["ارزیابی رایگان زمین", "قرارداد شفاف با ضمانت اجرا", "گزارش ماهانه پیشرفت"], + }, + { + "title": "صندوق پروژه‌محور", + "desc": "سرمایه‌گذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبه‌شده در پایان دوره.", + "points": ["حداقل دوره ۲۴ ماه", "گزارش ارزش‌گذاری فصلی", "امکان انتقال واحد سرمایه"], + }, + { + "title": "پیش‌خرید واحد", + "desc": "خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحله‌ای.", + "points": ["پرداخت مرحله‌ای متناسب با پیشرفت", "امکان شخصی‌سازی پلان", "تضمین زمان تحویل"], + }, +] + +PROCESS_STEPS = [ + {"step": "۰۱", "title": "مطالعه و امکان‌سنجی", "desc": "بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه."}, + {"step": "۰۲", "title": "طراحی و فاز اجرایی", "desc": "کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشه‌های اجرایی."}, + {"step": "۰۳", "title": "اجرا و کنترل کیفیت", "desc": "ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه."}, + {"step": "۰۴", "title": "تحویل و پشتیبانی", "desc": "تحویل کلید، آموزش بهره‌برداری و پنج سال پشتیبانی فنی."}, +] + +OFFICES = [ + {"city": "تهران", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸"}, + {"city": "اصفهان", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷"}, + {"city": "نوشهر", "address": "بلوار دریا، دفتر پروژه دریاکنار", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶"}, +] + +GALLERY_IMAGES = [ + {"src": IMAGES[0], "alt": "تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ۱"}, + {"src": IMAGES[1], "alt": "تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ۲"}, + {"src": IMAGES[2], "alt": "تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ۳"}, + {"src": IMAGES[3], "alt": "تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ۴"}, + {"src": IMAGES[4], "alt": "تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ۵"}, + {"src": IMAGES[1], "alt": "تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ۶"}, +] + +STATUS_LABELS = [ + {"key": "done", "label": "انجام شده"}, + {"key": "progress", "label": "در حال انجام"}, +] + +CONTACT_SECTION = { + "label": "تماس با ما", + "title": "پروژه‌تان را با ما در میان بگذارید", + "lead": "شماره‌تان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس می‌گیرد.", + "address_label": "دفتر مرکزی", + "phone_label": "تلفن", + "email_label": "ایمیل", + "name_label": "نام", + "name_placeholder": "نام و نام خانوادگی", + "phone_field_label": "شماره تلفن", + "phone_placeholder": "09xx xxx xxxx", + "note_label": "توضیحات", + "note_optional_label": "(اختیاری)", + "note_placeholder": "درباره پروژه یا درخواست خود بنویسید…", + "submit_label": "ارسال درخواست مشاوره", + "sending_label": "در حال ارسال…", + "success_title": "درخواست شما ثبت شد", + "success_text": "از اعتماد شما سپاسگزاریم. کارشناسان ما به‌زودی با شما تماس می‌گیرند.", + "success_button_label": "ثبت درخواست جدید", +} + +NEWSLETTER = { + "title": "خبرنامه گروه ساختمانی آرامش", + "lead": "هر ماه یک ایمیل: پروژه‌های جدید، فرصت‌های سرمایه‌گذاری و یادداشت‌های تیم معماری.", + "placeholder": "نشانی ایمیل شما", + "button_label": "عضویت", + "sending_label": "در حال ارسال…", + "success_text": "عضویت شما ثبت شد.", +} + +FOOTER = { + "about_text": "گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمان‌هایی می‌سازد که قرار است چند نسل در آن‌ها زندگی کنند؛ معماری آرام، اجرای دقیق و سرمایه‌گذاری شفاف.", + "pages_heading": "صفحات", + "services_heading": "خدمات", + "contact_heading": "ارتباط با ما", + "copyright": "© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.", + "tagline": "طراحی و ساخت با دقتِ معماری", + "socials": [ + {"label": "اینستاگرام", "href": "#", "icon": "instagram"}, + {"label": "لینکدین", "href": "#", "icon": "linkedin"}, + {"label": "تلگرام", "href": "#", "icon": "telegram"}, + ], +} + +SEO_META = [ + {"page_key": key, "title": "", "description": ""} + for key in ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"] +] diff --git a/backend/app/seo_pages.py b/backend/app/seo_pages.py new file mode 100644 index 0000000..6df8cea --- /dev/null +++ b/backend/app/seo_pages.py @@ -0,0 +1,239 @@ +"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریق‌شده. + +فرانت‌اند یک SPA است؛ خزنده‌های اجتماعی (تلگرام، واتس‌اپ و...) جاوااسکریپت اجرا نمی‌کنند. +این ماژول همان index.html بیلد فرانت‌اند را می‌خواند و پیش از ارسال، تگ‌های +title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق می‌کند — +بازدیدکننده‌های عادی همان SPA همیشگی را می‌بینند و خزنده‌ها متای درست را می‌خوانند. +""" + +import html +import json +import re +from pathlib import Path + +from fastapi import APIRouter, Depends, Response +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from . import models +from .config import settings +from .database import get_db + +router = APIRouter() + +STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"] + + +def _absolute(path: str) -> str: + if not path: + return "" + if path.startswith(("http://", "https://")): + return path + site = settings.site_url.rstrip("/") + return f"{site}{path}" if site else path + + +def _read_index() -> str | None: + try: + return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8") + except OSError: + return None + + +def _doc_text(doc: object, limit: int = 300) -> str: + """متن ساده سند TipTap — برای noscript و پیش‌نمایش.""" + words: list[str] = [] + + def walk(node: object) -> None: + if not isinstance(node, dict): + return + if node.get("type") == "text" and isinstance(node.get("text"), str): + words.append(node["text"]) + for child in node.get("content") or []: + walk(child) + + if isinstance(doc, dict): + for block in doc.get("content") or []: + walk(block) + text = " ".join(words).strip() + return text[:limit] + ("…" if len(text) > limit else "") + + +def _shell( + *, + title: str, + description: str, + path: str, + og_image: str = "", + og_type: str = "website", + jsonld: dict | None = None, + noscript: str = "", +) -> HTMLResponse: + """index.html فرانت‌اند با متای تزریق‌شده؛ اگر بیلد در دسترس نبود، صفحه حداقلی.""" + title = title.strip() or "گروه ساختمانی آرامش" + description = description.strip() + canonical = _absolute(path) + image = _absolute(og_image) + esc = html.escape + + # تگ title جای تگ موجود index.html را می‌گیرد و در بلوک تکرار نمی‌شود + tags = [ + f'', + f'', + f'', + f'', + f'', + f'', + ] + if image: + tags.append(f'') + if jsonld: + payload = json.dumps(jsonld, ensure_ascii=False).replace("{payload}') + block = "\n ".join(tags) + + raw = _read_index() + if raw is None: + # بیلد فرانت‌اند سوار نشده — صفحه حداقلی با خودِ متا تا خزنده‌ها دست خالی نمانند + body = f"

{esc(title)}

{esc(description)}

" + return HTMLResponse( + f'' + f"{esc(title)}{block}{body}" + ) + + raw = re.sub(r".*?", f"{esc(title)}", raw, count=1, flags=re.S) + raw = raw.replace("", f" {block}\n ", 1) + if noscript: + raw = raw.replace("", f"\n ", 1) + return HTMLResponse(raw) + + +def _brand_name(db: Session) -> str: + brand = db.query(models.Brand).first() + return brand.name if brand else "گروه ساختمانی آرامش" + + +def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None: + return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first() + + +# ---------- robots.txt و sitemap.xml ---------- + + +@router.get("/robots.txt", response_class=Response) +def robots(db: Session = Depends(get_db)): + lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""] + if settings.site_url: + lines.append(f"Sitemap: {_absolute('/sitemap.xml')}") + return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8") + + +@router.get("/sitemap.xml", response_class=Response) +def sitemap(db: Session = Depends(get_db)): + urls = list(STATIC_PATHS) + urls += [ + f"/blog/{a.slug}" + for a in db.query(models.Article) + .filter(models.Article.status == "published") + .order_by(models.Article.position, models.Article.id) + .all() + ] + urls += [ + f"/projects/{p.slug}" + for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all() + ] + items = "\n".join( + f" {html.escape(_absolute(u))}" for u in urls + ) + xml = ( + '\n' + '\n' + f"{items}\n\n" + ) + return Response(content=xml, media_type="application/xml; charset=utf-8") + + +# ---------- صفحات وبلاگ ---------- + + +@router.get("/blog", response_class=HTMLResponse) +def blog_list(db: Session = Depends(get_db)): + meta = _page_meta(db, "blog") + return _shell( + title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db), + description=meta.description if meta else "", + path="/blog", + og_image=meta.image if meta else "", + ) + + +@router.get("/blog/{slug}", response_class=HTMLResponse) +def blog_detail(slug: str, db: Session = Depends(get_db)): + article = ( + db.query(models.Article) + .filter(models.Article.slug == slug, models.Article.status == "published") + .first() + ) + if article is None: + # مقاله ناموجود یا پیش‌نویس — پوسته ساده بدون متا؛ SPA پیام «یافت نشد» را نشان می‌دهد + return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}") + + brand = _brand_name(db) + first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2]) + jsonld = { + "@context": "https://schema.org", + "@type": "Article", + "headline": article.title, + "description": article.excerpt, + "image": _absolute(article.image), + "inLanguage": "fa", + "author": {"@type": "Organization", "name": brand}, + "publisher": {"@type": "Organization", "name": brand}, + "mainEntityOfPage": _absolute(f"/blog/{article.slug}"), + } + noscript = f"

{html.escape(article.title)}

{html.escape(first_text)}

" + return _shell( + title=f"{article.title} | {brand}", + description=article.excerpt, + path=f"/blog/{article.slug}", + og_image=article.image, + og_type="article", + jsonld=jsonld, + noscript=noscript, + ) + + +# ---------- صفحات پروژه ---------- + + +@router.get("/projects", response_class=HTMLResponse) +def projects_list(db: Session = Depends(get_db)): + meta = _page_meta(db, "projects") + return _shell( + title=meta.title if meta and meta.title else "پروژه‌ها | " + _brand_name(db), + description=meta.description if meta else "", + path="/projects", + ) + + +@router.get("/projects/{slug}", response_class=HTMLResponse) +def project_detail(slug: str, db: Session = Depends(get_db)): + project = db.query(models.Project).filter(models.Project.slug == slug).first() + if project is None: + return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}") + + brand = _brand_name(db) + return _shell( + title=f"{project.title} | {brand}", + description=project.summary, + path=f"/projects/{project.slug}", + og_image=project.image, + jsonld={ + "@context": "https://schema.org", + "@type": "WebPage", + "name": project.title, + "description": project.summary, + "image": _absolute(project.image), + "inLanguage": "fa", + }, + ) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..80e9d4a --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +# وابستگی‌های توسعه و تست — pip install -r requirements-dev.txt +-r requirements.txt +pytest==8.3.4 +httpx==0.27.2 diff --git a/backend/requirements.txt b/backend/requirements.txt index 76b1ecc..bc23690 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,9 +1,13 @@ -fastapi>=0.115.8 -uvicorn[standard]>=0.34.0 -sqlalchemy>=2.0.36 -psycopg[binary]>=3.2.3 -pydantic>=2.10.4 -pydantic-settings>=2.6.1 -PyJWT>=2.10.1 -bcrypt>=4.2.1 -email-validator>=2.2.0 +# نسخه‌ها دقیق پین شده‌اند تا ارتقای خودکار یک وابستگی، استقرار را نشکند. +# ارتقا: نسخه را دستی بالا ببرید و روی محیط آماده‌سازی تست کنید. +fastapi==0.115.8 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.36 +psycopg[binary]==3.2.3 +pydantic==2.10.4 +pydantic-settings==2.6.1 +PyJWT==2.10.1 +bcrypt==4.2.1 +email-validator==2.2.0 +boto3==1.35.0 +python-multipart==0.0.18 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..7277948 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,93 @@ +"""پیکربندی تست‌ها — دیتابیس SQLite فایل‌محور و داده‌های حداقلی. + +متغیرهای محیطی پیش از ایمپورت app تنظیم می‌شوند تا settings همان‌ها را بخواند. +""" + +import os +import pathlib + +os.environ["DATABASE_URL"] = "sqlite:///./test.db?check_same_thread=false" +os.environ["SECRET_KEY"] = "test-secret-key" +os.environ["ADMIN_USERNAME"] = "admin" +os.environ["ADMIN_PASSWORD"] = "test-admin-pass-1" +os.environ["SITE_URL"] = "https://test.example" + +import pytest +from fastapi.testclient import TestClient + +from app import models +from app.auth import hash_password +from app.config import settings +from app.database import Base, SessionLocal, engine +from app.main import app + +TEST_DB = pathlib.Path("test.db") + +DOC = {"type": "doc", "content": [{"type": "paragraph"}]} + + +@pytest.fixture(scope="session", autouse=True) +def seed_db(tmp_path_factory): + # اتصالات ساخت جدول در ایمپورت ماژول باید بسته شوند تا فایل قدیمی حذف شود + engine.dispose() + if TEST_DB.exists(): + TEST_DB.unlink() + Base.metadata.create_all(bind=engine) + # dist ساختگی فرانت‌اند برای تست تزریق متا در صفحات جزئیات + dist = tmp_path_factory.mktemp("dist") + (dist / "index.html").write_text( + "Shell" + "
", + encoding="utf-8", + ) + settings.frontend_dist = str(dist) + settings.site_url = "https://test.example" + + db = SessionLocal() + try: + db.add(models.AdminUser(username="admin", password_hash=hash_password("test-admin-pass-1"))) + db.add(models.Brand(name="گروه ساختمانی آرامش")) + db.add( + models.Article( + slug="published-post", title="مقاله منتشرشده", status="published", + excerpt="چکیده مقاله", body=DOC, position=1, + ) + ) + db.add( + models.Article( + slug="draft-post", title="پیش‌نویس", status="draft", + excerpt="چکیده پیش‌نویس", body=DOC, position=2, + ) + ) + db.commit() + finally: + db.close() + yield + db.close() + engine.dispose() + if TEST_DB.exists(): + TEST_DB.unlink() + + +@pytest.fixture(autouse=True) +def reset_rate_limiter(): + from app import rate_limit + + rate_limit._failed.clear() + yield + rate_limit._failed.clear() + + +@pytest.fixture() +def client(): + return TestClient(app) + + +@pytest.fixture() +def auth_client(client): + res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"}) + assert res.status_code == 200, res.text + token = res.json()["access_token"] + client.headers.update({"Authorization": f"Bearer {token}"}) + yield client + client.headers.pop("Authorization", None) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..953e734 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,182 @@ +"""تست‌های API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیش‌نویس مقاله و سئو.""" + +import re + +DOC = {"type": "doc", "content": [{"type": "paragraph"}]} + + +# ---------- عمومی ---------- + + +def test_health(client): + res = client.get("/api/health") + assert res.status_code == 200 + assert res.json()["status"] == "ok" + + +def test_site_hides_draft_articles(client): + res = client.get("/api/site") + assert res.status_code == 200 + slugs = [a["slug"] for a in res.json()["articles"]] + assert "published-post" in slugs + assert "draft-post" not in slugs + + +def test_public_articles_hide_drafts(client): + res = client.get("/api/articles") + assert all(a["status"] == "published" for a in res.json()) + assert client.get("/api/articles/draft-post").status_code == 404 + + +def test_admin_endpoints_reject_anonymous(client): + assert client.get("/api/admin/articles").status_code == 401 + + +# ---------- احراز هویت ---------- + + +def test_login_success_and_failure(client): + assert ( + client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}).status_code + == 401 + ) + res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"}) + assert res.status_code == 200 + assert res.json()["access_token"] + + +def test_change_password(auth_client, client): + res = auth_client.post( + "/api/auth/change-password", + json={"current_password": "wrong", "new_password": "brand-new-pass-9"}, + ) + assert res.status_code == 400 + + res = auth_client.post( + "/api/auth/change-password", + json={"current_password": "test-admin-pass-1", "new_password": "brand-new-pass-9"}, + ) + assert res.status_code == 200 + + # رمز قدیمی رد و رمز جدید قبول می‌شود + assert ( + client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"}).status_code + == 401 + ) + assert ( + client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"}).status_code + == 200 + ) + + # بازگرداندن رمز برای تست‌های بعدی + res = client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"}) + auth = {"Authorization": f"Bearer {res.json()['access_token']}"} + res = client.post( + "/api/auth/change-password", + json={"current_password": "brand-new-pass-9", "new_password": "test-admin-pass-1"}, + headers=auth, + ) + assert res.status_code == 200 + + +def test_login_rate_limited(client): + for _ in range(5): + res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}) + assert res.status_code == 401 + res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}) + assert res.status_code == 429 + assert "Retry-After" in res.headers + + +# ---------- CRUD مدیریت ---------- + + +def test_article_crud_and_draft_flow(auth_client, client): + created = auth_client.post( + "/api/admin/articles", + json={"slug": "flow-post", "title": "مقاله جریان", "body": DOC}, + ) + assert created.status_code == 201, created.text + article = created.json() + # مقاله تازه پیش‌نویس است و در سایت عمومی دیده نمی‌شود + assert article["status"] == "draft" + assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()] + + res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"seo_keyphrase": "ساختمان"}) + assert res.json()["seo_keyphrase"] == "ساختمان" + + res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"status": "published"}) + assert res.status_code == 200 + assert "flow-post" in [a["slug"] for a in client.get("/api/articles").json()] + + assert auth_client.delete(f"/api/admin/articles/{article['id']}").status_code == 204 + assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()] + + +def test_service_roundtrip_and_reorder(auth_client): + ids = [] + for name in ("خدمت الف", "خدمت ب"): + res = auth_client.post("/api/admin/services", json={"title": name}) + assert res.status_code == 201 + ids.append(res.json()["id"]) + + res = auth_client.post("/api/admin/services/reorder", json={"ids": ids[::-1]}) + assert res.status_code == 200 + ordered = [s["id"] for s in res.json() if s["id"] in ids] + assert ordered == ids[::-1] + + res = auth_client.patch(f"/api/admin/services/{ids[0]}", json={"desc": "توضیح تازه"}) + assert res.json()["desc"] == "توضیح تازه" + + for i in ids: + assert auth_client.delete(f"/api/admin/services/{i}").status_code == 204 + + +def test_seo_meta_create_and_patch(auth_client): + res = auth_client.post( + "/api/admin/seo-meta", json={"page_key": "test-page", "title": "عنوان", "description": "توضیح"} + ) + assert res.status_code == 201 + meta_id = res.json()["id"] + res = auth_client.patch(f"/api/admin/seo-meta/{meta_id}", json={"title": "عنوان تازه"}) + assert res.json()["title"] == "عنوان تازه" + assert auth_client.delete(f"/api/admin/seo-meta/{meta_id}").status_code == 204 + + +# ---------- سئو ---------- + + +def test_robots_txt(client): + res = client.get("/robots.txt") + assert res.status_code == 200 + assert "Disallow: /admin" in res.text + assert "Sitemap: https://test.example/sitemap.xml" in res.text + + +def test_sitemap_includes_published_only(client): + res = client.get("/sitemap.xml") + assert res.status_code == 200 + assert "/blog/published-post" in res.text + assert "/blog/draft-post" not in res.text + assert res.text.startswith("", res.text)) == 1 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..4784fa5 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,57 @@ +# لایه تولید — TLS با Let's Encrypt و انتشار روی پورت‌های استاندارد ۸۰/۴۴۳. +# پیش‌نیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ به‌علاوه رکورد DNS دامنه به این سرور. +# +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build +# +# همه درخواست‌های HTTP به HTTPS هدایت می‌شوند و مسیریابی‌ها فقط روی دامنه DOMAIN پاسخ می‌دهند. + +services: + traefik: + command: !override + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --entrypoints.websecure.address=:443 + - --certificatesresolvers.le.acme.email=${ACME_EMAIL:?ACME_EMAIL را در .env تنظیم کنید} + - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.le.acme.httpchallenge=true + - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web + ports: !override + - "80:80" + - "443:443" + volumes: !override + - /var/run/docker.sock:/var/run/docker.sock:ro + - letsencrypt_data:/letsencrypt + + backend: + environment: + # سایت هم‌مبدأ است — CORS لازم نیست + CORS_ORIGINS: '[]' + SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید} + labels: + - traefik.enable=true + - traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`) + - traefik.http.routers.api.entrypoints=websecure + - traefik.http.routers.api.tls.certresolver=le + - traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)) + - traefik.http.routers.seo.entrypoints=websecure + - traefik.http.routers.seo.tls.certresolver=le + + minio: + labels: + - traefik.enable=true + - traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`) + - traefik.http.routers.media.entrypoints=websecure + - traefik.http.routers.media.tls.certresolver=le + + frontend: + labels: + - traefik.enable=true + - traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`) + - traefik.http.routers.web.entrypoints=websecure + - traefik.http.routers.web.tls.certresolver=le + +volumes: + letsencrypt_data: diff --git a/docker-compose.yml b/docker-compose.yml index c6468be..ceb7466 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,28 @@ +# مقادیر محرمانه از فایل .env ریشه خوانده می‌شوند — از .env.example کپی کنید: +# cp .env.example .env +# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README). + services: + # نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بک‌اند) و /media (MinIO) + traefik: + image: traefik:v3 + restart: unless-stopped + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + ports: + # پورت به 8080 تغییر کرد تا با پورت 5173 سرور لوکال Vite تداخل نکند + - "${TRAEFIK_HTTP_PORT:-8080}:80" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + db: image: postgres:17-alpine restart: unless-stopped environment: POSTGRES_USER: aramesh - POSTGRES_PASSWORD: aramesh + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید} POSTGRES_DB: arameshplaza volumes: - db_data:/var/lib/postgresql/data @@ -14,31 +32,127 @@ services: timeout: 5s retries: 12 + minio: + image: minio/minio:latest + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید} + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 12 + ports: + - "9001:9001" # کنسول MinIO + - "9000:9000" # دسترسی لوکال به تصاویر و مدیا + labels: + - traefik.enable=true + - traefik.http.routers.media.rule=PathPrefix(`/media`) + - traefik.http.routers.media.entrypoints=web + - traefik.http.routers.media.priority=100 + - traefik.http.routers.media.middlewares=media-rewrite + - traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*) + - traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1 + - traefik.http.services.media.loadbalancer.server.port=9000 + + # یک‌بار اجرا می‌شود: ساخت باکت و بازگذاشتن خواندن عمومی برای سرو تصاویر + minio-setup: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید} + MINIO_BUCKET: aramesh-media + restart: "no" + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD && + mc mb --ignore-existing local/$$MINIO_BUCKET && + mc anonymous set download local/$$MINIO_BUCKET && + echo 'minio-setup: bucket ready' + " + backend: build: ./backend restart: unless-stopped + ports: + - "8000:8000" # اتصال مستقیم بک‌اند به ویندوز برای فرانت‌اند لوکال environment: - DATABASE_URL: postgresql+psycopg://aramesh:aramesh@db:5432/arameshplaza - SECRET_KEY: change-me-to-a-long-random-string - CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]' - ADMIN_USERNAME: admin - ADMIN_PASSWORD: aramesh123 + DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید} + CORS_ORIGINS: ${CORS_ORIGINS:-'["http://localhost:5173","http://127.0.0.1:5173"]'} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD را در .env تنظیم کنید} + S3_ENDPOINT: http://minio:9000 + S3_ACCESS_KEY: ${MINIO_ROOT_USER:-aramesh} + S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید} + S3_BUCKET: aramesh-media + MEDIA_PUBLIC_BASE: /media + # نشانی عمومی سایت — برای sitemap و آدرس مطلق OG (مثل https://aramesh.example) + SITE_URL: ${SITE_URL:-} + # بیلد فرانت‌اند برای تزریق متای سئو در صفحات /blog و /projects + FRONTEND_DIST: /srv/frontend + volumes: + - ./frontend/dist:/srv/frontend:ro depends_on: db: condition: service_healthy - ports: - - "8000:8000" + minio: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + labels: + - traefik.enable=true + - traefik.http.routers.api.rule=PathPrefix(`/api`) + - traefik.http.routers.api.entrypoints=web + - traefik.http.routers.api.priority=100 + - traefik.http.services.api.loadbalancer.server.port=8000 + # robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA + - traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`) + - traefik.http.routers.seo.entrypoints=web + - traefik.http.routers.seo.priority=50 + - traefik.http.routers.seo.service=api + # فقط سرو فایل‌های استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است frontend: - build: - context: ./frontend - args: - VITE_API_URL: "" + build: ./frontend restart: unless-stopped depends_on: - backend - ports: - - "5173:80" + - minio + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost/"] + interval: 15s + timeout: 5s + retries: 5 + labels: + - traefik.enable=true + - traefik.http.routers.web.rule=PathPrefix(`/`) + - traefik.http.routers.web.entrypoints=web + - traefik.http.routers.web.priority=1 + - traefik.http.services.web.loadbalancer.server.port=80 + + # ابزار پشتیبان‌گیری — فقط با پروفایل tools اجرا می‌شود (scripts/backup.sh این کار را می‌کند) + mc: + image: minio/mc:latest + profiles: ["tools"] + depends_on: + minio: + condition: service_healthy + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید} volumes: db_data: + minio_data: \ No newline at end of file diff --git a/docs/Aramesh_Plaza_Optimization_Report.pdf b/docs/Aramesh_Plaza_Optimization_Report.pdf deleted file mode 100644 index ac4c85c..0000000 Binary files a/docs/Aramesh_Plaza_Optimization_Report.pdf and /dev/null differ diff --git a/frontend/Caddyfile b/frontend/Caddyfile new file mode 100644 index 0000000..71db35c --- /dev/null +++ b/frontend/Caddyfile @@ -0,0 +1,12 @@ +:80 { + root * /srv + encode gzip + + # مسیریابی SPA — مسیرهای ناشناخته به index.html برمی‌گردند + try_files {path} /index.html + file_server + + # کش بلندمدت برای فایل‌های هش‌دار بیلد + @assets path /assets/* + header @assets Cache-Control "public, immutable, max-age=31536000" +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 846d933..496113a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,23 +1,10 @@ -# ---------- مرحله ساخت ---------- -FROM node:22-alpine AS build +# ---------- فرانت‌اند فقط سرو می‌کند ---------- +# بیلد روی هاست انجام می‌شود (npm install && npm run build) و خروجی dist/ +# داخل ایمیج کپی می‌شود — شبکه کانتینر بیلد برای npm قابل‌اعتماد نیست. -WORKDIR /app +FROM caddy:2-alpine -COPY package.json package-lock.json ./ -RUN npm ci - -COPY . . - -# آدرس API در زمان بیلد؛ پیش‌فرض خالی یعنی درخواست‌های نسبی (/api) که nginx آن‌ها را به بک‌اند پروکسی می‌کند -ARG VITE_API_URL="" -ENV VITE_API_URL=$VITE_API_URL - -RUN npm run build - -# ---------- مرحله سرو ---------- -FROM nginx:alpine - -COPY --from=build /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY Caddyfile /etc/caddy/Caddyfile +COPY dist /srv EXPOSE 80 diff --git a/frontend/README.md b/frontend/README.md index 426c3d1..ac41333 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,33 @@ -# Magic Patterns - Vite Template +# فرانت‌اند گروه ساختمانی آرامش -This code was generated by [Magic Patterns](https://magicpatterns.com) for this design: [Source Design](https://www.magicpatterns.com/c/ka4i8ikygtakmdobhws6nu) +سایت React + Vite + Tailwind — تمام متن‌ها و تصاویر از بک‌اند (`GET /api/site`) خوانده می‌شود. -## Getting Started +## اجرا -1. Run `npm install` -2. Run `npm run dev` +```bash +npm install +npm run dev +``` + +در حالت توسعه، Vite درخواست‌های `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی می‌کند — +پس استک Docker باید در حال اجرا باشد. آدرس بک‌اند در `src/config.ts` از `VITE_API_URL` +خوانده می‌شود؛ پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ. + +## ساخت + +بیلد روی هاست انجام می‌شود (کانتینر فقط با Caddy فایل‌های `dist/` را سرو می‌کند): + +```bash +npm run build +``` + +> نکته: بک‌اند برای تزریق متای سئو در صفحات `/blog` و `/projects` همین پوشه `dist/` را +> (در compose به `/srv/frontend` سوار است) می‌خواند — بعد از هر تغییر فرانت، `npm run build` +> و سپس ری‌استارت بک‌اند کافی است. + +## بخش‌های کلیدی + +- `src/api.tsx` — بارگذاری داده سایت و ادغام پیش‌نویس مقاله‌ها برای ادمین +- `src/admin/` — لایه ویرایش زنده (دوقلوی دیجیتال): نوار مدیریت، کشوی موجودیت‌ها، صفحه نوشتن مقاله +- `src/seo.ts` — تگ‌های title / description / OG / canonical / JSON-LD هر صفحه +- `src/data/site.ts` — انواع داده مشترک diff --git a/frontend/index.html b/frontend/index.html index c64991d..94f729c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,18 @@ - + - + - Aramesh Building Group Luxury Website + گروه ساختمانی آرامش + + + + + +
diff --git a/frontend/nginx.conf b/frontend/nginx.conf deleted file mode 100644 index c0c9879..0000000 --- a/frontend/nginx.conf +++ /dev/null @@ -1,34 +0,0 @@ -server { - listen 80; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - # مسیریابی SPA — همه مسیرها به index.html برمی‌گردند - location / { - try_files $uri $uri/ /index.html; - } - - # پروکسی درخواست‌های API به سرویس بک‌اند - location /api/ { - proxy_pass http://backend:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # تصاویر: بدون کش طولانی — تا تغییرات سریع دیده شوند - location ~* ^/images/ { - add_header Cache-Control "no-cache"; - try_files $uri =404; - } - - # کش استاتیک - location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|ico|woff2?)$ { - expires 30d; - add_header Cache-Control "public, immutable"; - try_files $uri =404; - } -} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0f673e2..b866068 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,12 @@ "version": "0.0.1", "dependencies": { "@emotion/react": "^11.13.3", + "@tiptap/extension-image": "^3.31.3", + "@tiptap/extension-link": "^3.31.3", + "@tiptap/pm": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", + "@tiptap/static-renderer": "^3.31.3", "framer-motion": "^11.5.4", "lucide-react": "0.522.0", "react": "^18.3.1", @@ -922,6 +928,34 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT", + "optional": true + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1427,6 +1461,465 @@ "win32" ] }, + "node_modules/@tiptap/core": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz", + "integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz", + "integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz", + "integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz", + "integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz", + "integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz", + "integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz", + "integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz", + "integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz", + "integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz", + "integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz", + "integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz", + "integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz", + "integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz", + "integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.31.3.tgz", + "integrity": "sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz", + "integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz", + "integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz", + "integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz", + "integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz", + "integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz", + "integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz", + "integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz", + "integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz", + "integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz", + "integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz", + "integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz", + "integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.42.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz", + "integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.31.3", + "@tiptap/extension-floating-menu": "^3.31.3" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz", + "integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/extension-blockquote": "3.31.3", + "@tiptap/extension-bold": "3.31.3", + "@tiptap/extension-bullet-list": "3.31.3", + "@tiptap/extension-code": "3.31.3", + "@tiptap/extension-code-block": "3.31.3", + "@tiptap/extension-document": "3.31.3", + "@tiptap/extension-dropcursor": "3.31.3", + "@tiptap/extension-gapcursor": "3.31.3", + "@tiptap/extension-hard-break": "3.31.3", + "@tiptap/extension-heading": "3.31.3", + "@tiptap/extension-horizontal-rule": "3.31.3", + "@tiptap/extension-italic": "3.31.3", + "@tiptap/extension-link": "3.31.3", + "@tiptap/extension-list": "3.31.3", + "@tiptap/extension-list-item": "3.31.3", + "@tiptap/extension-list-keymap": "3.31.3", + "@tiptap/extension-ordered-list": "3.31.3", + "@tiptap/extension-paragraph": "3.31.3", + "@tiptap/extension-strike": "3.31.3", + "@tiptap/extension-text": "3.31.3", + "@tiptap/extension-underline": "3.31.3", + "@tiptap/extensions": "3.31.3", + "@tiptap/pm": "3.31.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/static-renderer": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/static-renderer/-/static-renderer-3.31.3.tgz", + "integrity": "sha512-TzbN/aSTQBhGZL94aaEnfbwZw/gIiQd8/q3TucTYuzdbsrsZNfBMg7CCA9W9dOg4vlKxCTLRVas21ivsrUm7og==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1506,14 +1999,12 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1524,7 +2015,6 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -1537,6 +2027,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", @@ -2578,6 +3074,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz", + "integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3168,6 +3673,12 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3393,6 +3904,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3721,6 +4238,145 @@ "node": ">= 0.8.0" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz", + "integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz", + "integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.3", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz", + "integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3952,6 +4608,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4393,6 +5055,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4460,6 +5131,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8e09180..de07a6f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,12 @@ }, "dependencies": { "@emotion/react": "^11.13.3", + "@tiptap/extension-image": "^3.31.3", + "@tiptap/extension-link": "^3.31.3", + "@tiptap/pm": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", + "@tiptap/static-renderer": "^3.31.3", "framer-motion": "^11.5.4", "lucide-react": "0.522.0", "react": "^18.3.1", diff --git a/frontend/public/images/after.webp b/frontend/public/images/after.webp deleted file mode 100644 index f06bf19..0000000 Binary files a/frontend/public/images/after.webp and /dev/null differ diff --git a/frontend/public/images/before.webp b/frontend/public/images/before.webp deleted file mode 100644 index 184e79b..0000000 Binary files a/frontend/public/images/before.webp and /dev/null differ diff --git a/frontend/public/images/contact.png b/frontend/public/images/contact.png deleted file mode 100644 index 9bfcba8..0000000 Binary files a/frontend/public/images/contact.png and /dev/null differ diff --git a/frontend/public/images/hero.png b/frontend/public/images/hero.png deleted file mode 100644 index cd1db77..0000000 Binary files a/frontend/public/images/hero.png and /dev/null differ diff --git a/frontend/public/images/img1.png b/frontend/public/images/img1.png deleted file mode 100644 index f4b2ffa..0000000 Binary files a/frontend/public/images/img1.png and /dev/null differ diff --git a/frontend/public/images/img2.png b/frontend/public/images/img2.png deleted file mode 100644 index c035758..0000000 Binary files a/frontend/public/images/img2.png and /dev/null differ diff --git a/frontend/public/images/img4.png b/frontend/public/images/img4.png deleted file mode 100644 index 95792c5..0000000 Binary files a/frontend/public/images/img4.png and /dev/null differ diff --git a/frontend/public/images/img5.png b/frontend/public/images/img5.png deleted file mode 100644 index f852249..0000000 Binary files a/frontend/public/images/img5.png and /dev/null differ diff --git a/frontend/public/images/logo.png b/frontend/public/images/logo.png deleted file mode 100644 index f8cfaba..0000000 Binary files a/frontend/public/images/logo.png and /dev/null differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6187972..3fa715f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,11 @@ import { ArticleDetail } from './pages/ArticleDetail'; import { FaqPage } from './pages/FaqPage'; import { ContactPage } from './pages/ContactPage'; import { SiteProvider, useSiteData } from './api'; +import { applySeo, seoKeyForPath } from './seo'; +import { AdminProvider, useAdmin } from './admin/AdminContext'; +import { AdminBar } from './admin/AdminBar'; +import { AdminLoginPage } from './admin/AdminLoginPage'; +import { ArticleWritePage } from './admin/ArticleWritePage'; function ScrollToTop() { const { pathname } = useLocation(); @@ -35,8 +40,11 @@ function AnimatedRoutes() { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}> - + + } /> + } /> + } /> } /> } /> } /> @@ -54,6 +62,38 @@ function AnimatedRoutes() { } +function AppChrome() { + const { pathname } = useLocation(); + const { isAuthenticated } = useAdmin(); + const { data } = useSiteData(); + const isAdminPage = pathname.startsWith('/admin'); + + // عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحه‌های جزئیات که خودشان ست می‌کنند + useEffect(() => { + if (isAdminPage || !data) return; + const key = seoKeyForPath(pathname); + if (!key) return; + const meta = data.seo_meta?.find((s) => s.page_key === key); + const brand = data.brand.name; + const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label; + applySeo({ + title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand), + description: meta?.description ?? '', + path: pathname, + }); + }, [pathname, data, isAdminPage]); + + return ( + <> + {!isAdminPage &&
} + + {!isAdminPage &&