From b31124e1e4d36141c71068060caeae10e19954f6 Mon Sep 17 00:00:00 2001 From: MMD Date: Mon, 7 Sep 2026 04:26:18 +0330 Subject: [PATCH] =?UTF-8?q?=D9=88=D8=A8=D9=84=D8=A7=DA=AF=20=D9=88=20?= =?UTF-8?q?=D8=B3=D8=A6=D9=88:=20=D9=BE=DB=8C=D8=B4=E2=80=8C=D9=86=D9=88?= =?UTF-8?q?=DB=8C=D8=B3/=D8=A7=D9=86=D8=AA=D8=B4=D8=A7=D8=B1=20=D9=85?= =?UTF-8?q?=D9=82=D8=A7=D9=84=D9=87=20+=20=DA=A9=D9=84=D9=85=D9=87=20?= =?UTF-8?q?=DA=A9=D9=84=DB=8C=D8=AF=DB=8C=20=D8=B3=D8=A6=D9=88=20=D8=AF?= =?UTF-8?q?=D8=B1=20=D8=AF=DB=8C=D8=AA=D8=A7=D8=A8=DB=8C=D8=B3=20+=20OG/ca?= =?UTF-8?q?nonical/JSON-LD=20=D9=88=20=D9=85=D8=AA=D8=A7=DB=8C=20=D8=B3?= =?UTF-8?q?=D9=85=D8=AA=20=D8=B3=D8=B1=D9=88=D8=B1=20+=20=D8=AA=D8=BA?= =?UTF-8?q?=DB=8C=DB=8C=D8=B1=20=D8=B1=D9=85=D8=B2=20=D9=88=20=D9=85=D8=AD?= =?UTF-8?q?=D8=AF=D9=88=D8=AF=D8=B3=D8=A7=D8=B2=DB=8C=20=D9=88=D8=B1=D9=88?= =?UTF-8?q?=D8=AF=20+=20=D8=AA=D8=B3=D8=AA=E2=80=8C=D9=87=D8=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - مقاله‌ها ستون status (draft/published) گرفتند — GET /api/site و همه انتهای عمومی فقط منتشرشده را برمی‌گردانند؛ autosave دیگر پیش‌نویس نیمه‌کاره را زنده نمی‌کند - کلمه کلیدی هدف چک‌لیست سئو فیلد seo_keyphrase شد (قبلاً localStorage بود) - robots.txt و sitemap.xml از بک‌اند + سرو /blog و /projects با تزریق title/description/OG/JSON-LD از دیتابیس برای خزنده‌های بدون JS (app/seo_pages.py) - فرانت: applySeo در seo.ts (OG/توییتر/canonical/JSON-LD)، ادغام پیش‌نویس برای ادمین، برچسب پیش‌نویس در وبلاگ، حالت خالی وبلاگ، دکمه انتشار در صفحه نوشتن - تغییر رمز ادمین POST /api/auth/change-password + دیالوگ «رمز» در نوار مدیریت - محدودسازی ورود: ۵ تلاش ناموفق در ۱۵ دقیقه از هر IP → 429 - ساخت دکمه + برای ردیف سئوی صفحه‌های بدون ردیف (SingletonPill) - تست‌های pytest (۱۵ مورد) + پین‌کردن requirements + حذف package.json سرگردان فرانت --- backend/README.md | 17 +- backend/app/api/admin.py | 26 ++- backend/app/api/public.py | 19 +- backend/app/config.py | 7 +- backend/app/main.py | 3 + backend/app/migrations.py | 7 + backend/app/models.py | 4 + backend/app/rate_limit.py | 49 +++++ backend/app/schemas.py | 11 + backend/app/seo_pages.py | 239 ++++++++++++++++++++++ backend/requirements-dev.txt | 4 + backend/requirements.txt | 24 ++- backend/tests/conftest.py | 93 +++++++++ backend/tests/test_api.py | 182 ++++++++++++++++ frontend/README.md | 35 +++- frontend/index.html | 8 + frontend/src/App.tsx | 9 +- frontend/src/admin/AdminBar.tsx | 214 +++++++++++++++---- frontend/src/admin/AdminContext.tsx | 8 +- frontend/src/admin/ArticleWritePage.tsx | 111 +++++++--- frontend/src/admin/Editable.tsx | 52 ++++- frontend/src/admin/adminClient.ts | 11 + frontend/src/api.tsx | 21 +- frontend/src/components/PageHero.tsx | 2 + frontend/src/components/home/Articles.tsx | 5 +- frontend/src/data/site.ts | 6 + frontend/src/package.json | 14 -- frontend/src/pages/ArticleDetail.tsx | 26 ++- frontend/src/pages/Blog.tsx | 55 ++++- frontend/src/pages/ProjectDetail.tsx | 12 +- frontend/src/seo.ts | 80 +++++++- 31 files changed, 1202 insertions(+), 152 deletions(-) create mode 100644 backend/app/rate_limit.py create mode 100644 backend/app/seo_pages.py create mode 100644 backend/requirements-dev.txt create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_api.py delete mode 100644 frontend/src/package.json diff --git a/backend/README.md b/backend/README.md index 05538bc..7f6b098 100644 --- a/backend/README.md +++ b/backend/README.md @@ -86,6 +86,13 @@ Authorization: Bearer > نام کاربری و رمز عبور پیش‌فرض از تنظیمات `.env` خوانده می‌شود — حتماً در محیط تولید تغییرشان دهید. +تغییر رمز (با توکن): + +``` +POST /api/auth/change-password +{ "current_password": "...", "new_password": "..." } +``` + ## فهرست انتهاپوینت‌ها ### عمومی (بدون نیاز به توکن) @@ -97,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` | اعضای تیم | @@ -109,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) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index ab539c3..eedbea4 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -4,15 +4,17 @@ import uuid from datetime import datetime, timezone import boto3 -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +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, @@ -23,6 +25,7 @@ from ..schemas import ( BrandIn, BrandOut, BrandPatch, + ChangePasswordIn, ContactSectionIn, ContactSectionOut, ContactSectionPatch, @@ -105,16 +108,33 @@ 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)) +@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 (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی، حذف و ترتیب‌بندی) روی روتر مدیریت.""" diff --git a/backend/app/api/public.py b/backend/app/api/public.py index 3ff7e3f..25a312d 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -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.position, 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 @@ -116,7 +125,11 @@ def get_site(db: Session = Depends(get_db)): "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).order_by(models.Article.position, models.Article.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(), diff --git a/backend/app/config.py b/backend/app/config.py index e425adc..2d97aeb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -23,9 +23,14 @@ class Settings(BaseSettings): s3_access_key: str = "aramesh" s3_secret_key: str = "aramesh-minio-secret" s3_bucket: str = "aramesh-media" - # پیشوند عمومی مسیر تصاویر — nginx آن را به باکت پروکسی می‌کند + # پیشوند عمومی مسیر تصاویر — 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 e63b138..e04fea7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,6 +6,7 @@ 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() @@ -33,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 index afcaf0c..3e4cd48 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -25,6 +25,13 @@ def run_ddl_migrations() -> None: 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: diff --git a/backend/app/models.py b/backend/app/models.py index 743816d..f5929a7 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -78,6 +78,10 @@ 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): 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 97d09b3..0e5811e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -59,6 +59,8 @@ class ArticleOut(ORMModel): read: str excerpt: str body: dict + status: str + seo_keyphrase: str class TestimonialOut(ORMModel): @@ -292,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 = "" @@ -346,6 +353,8 @@ class ArticleIn(BaseModel): read: str = "" excerpt: 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) @@ -548,6 +557,8 @@ class ArticlePatch(BaseModel): 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) 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 b941c1b..bc23690 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,11 +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 -boto3>=1.35.0 -python-multipart>=0.0.18 +# نسخه‌ها دقیق پین شده‌اند تا ارتقای خودکار یک وابستگی، استقرار را نشکند. +# ارتقا: نسخه را دستی بالا ببرید و روی محیط آماده‌سازی تست کنید. +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/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 6523b88..94f729c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,14 @@ گروه ساختمانی آرامش + + + + + +
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d800128..3fa715f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,7 +16,7 @@ import { ArticleDetail } from './pages/ArticleDetail'; import { FaqPage } from './pages/FaqPage'; import { ContactPage } from './pages/ContactPage'; import { SiteProvider, useSiteData } from './api'; -import { seoKeyForPath, setMetaDescription } from './seo'; +import { applySeo, seoKeyForPath } from './seo'; import { AdminProvider, useAdmin } from './admin/AdminContext'; import { AdminBar } from './admin/AdminBar'; import { AdminLoginPage } from './admin/AdminLoginPage'; @@ -76,8 +76,11 @@ function AppChrome() { 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; - document.title = meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand); - setMetaDescription(meta?.description ?? ''); + applySeo({ + title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand), + description: meta?.description ?? '', + path: pathname, + }); }, [pathname, data, isAdminPage]); return ( diff --git a/frontend/src/admin/AdminBar.tsx b/frontend/src/admin/AdminBar.tsx index a6df5be..2e83be1 100644 --- a/frontend/src/admin/AdminBar.tsx +++ b/frontend/src/admin/AdminBar.tsx @@ -1,4 +1,8 @@ +import { useState } from 'react' +import Swal from 'sweetalert2' +import { KeyRoundIcon, XIcon } from 'lucide-react' import { useAdmin } from './AdminContext' +import { adminApi } from './adminClient' const STATUS_TEXT: Record = { idle: 'همگام‌سازی شد', @@ -12,53 +16,175 @@ const STATUS_COLOR: Record = { error: 'bg-red-400', } -/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */ -export function AdminBar() { - const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin() +const SWAL_BASE = { + background: '#1e1a16', + color: '#F5F3EF', + iconColor: '#d5874c', + buttonsStyling: false, + customClass: { + popup: 'font-pinar border border-ivory/10 rounded-none', + title: 'font-pinar font-bold text-lg text-ivory', + htmlContainer: 'font-pinar font-light text-sm text-ivory/70', + confirmButton: 'bg-copper text-char font-bold px-8 py-2.5', + }, +} + +function ChangePasswordModal({ onClose }: { onClose: () => void }) { + const [current, setCurrent] = useState('') + const [next, setNext] = useState('') + const [confirm, setConfirm] = useState('') + const [busy, setBusy] = useState(false) + + const submit = async () => { + if (next.length < 8) { + void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'رمز جدید کوتاه است', text: 'رمز جدید باید دست‌کم ۸ نویسه باشد.', confirmButtonText: 'باشه' }) + return + } + if (next !== confirm) { + void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'تکرار رمز مطابق نیست', text: 'رمز جدید و تکرار آن یکسان نیستند.', confirmButtonText: 'باشه' }) + return + } + setBusy(true) + try { + await adminApi.changePassword(current, next) + void Swal.fire({ ...SWAL_BASE, icon: 'success', title: 'رمز عبور عوض شد', text: 'از این به بعد با رمز جدید وارد شوید.', iconColor: '#34d399', confirmButtonText: 'باشه' }) + onClose() + } catch (error) { + void Swal.fire({ + ...SWAL_BASE, + icon: 'error', + title: 'تغییر رمز ناموفق بود', + text: error instanceof Error ? error.message : 'دوباره تلاش کنید', + confirmButtonText: 'باشه', + }) + } finally { + setBusy(false) + } + } + + const field = + 'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none' return ( -
- - - حالت مدیریت - - - +
+
+ setCurrent(e.target.value)} + placeholder="رمز عبور فعلی" + aria-label="رمز عبور فعلی" + autoComplete="current-password" + className={field} /> - - {isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'} - - - - - - + setNext(e.target.value)} + placeholder="رمز جدید (دست‌کم ۸ نویسه)" + aria-label="رمز جدید" + autoComplete="new-password" + className={field} + /> + setConfirm(e.target.value)} + placeholder="تکرار رمز جدید" + aria-label="تکرار رمز جدید" + autoComplete="new-password" + className={field} + /> +
+ + ) } + +/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */ +export function AdminBar() { + const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin() + const [pwOpen, setPwOpen] = useState(false) + + return ( + <> +
+ + + حالت مدیریت + + + + + + + + + + +
+ + {pwOpen && setPwOpen(false)} />} + + ) +} diff --git a/frontend/src/admin/AdminContext.tsx b/frontend/src/admin/AdminContext.tsx index a47ea7e..8bed752 100644 --- a/frontend/src/admin/AdminContext.tsx +++ b/frontend/src/admin/AdminContext.tsx @@ -67,14 +67,18 @@ export function AdminProvider({ children }: { children: ReactNode }) { const login = useCallback(async (username: string, password: string) => { await adminApi.login(username, password) setIsAuthenticated(true) - }, []) + // داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس مقاله‌ها هم ادغام شوند + reload() + }, [reload]) const logout = useCallback(() => { setToken(null) setIsAuthenticated(false) setIsEditMode(false) sessionStorage.removeItem('aramesh_edit_mode') - }, []) + // داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس‌ها از درخت داده حذف شوند + reload() + }, [reload]) const runSave = useCallback( async (action: () => Promise): Promise => { diff --git a/frontend/src/admin/ArticleWritePage.tsx b/frontend/src/admin/ArticleWritePage.tsx index f514bac..a8bc8ea 100644 --- a/frontend/src/admin/ArticleWritePage.tsx +++ b/frontend/src/admin/ArticleWritePage.tsx @@ -22,6 +22,8 @@ type Draft = { read: string excerpt: string body: ArticleBodyDoc + status: 'draft' | 'published' + seo_keyphrase: string } const STATUS_TEXT: Record = { @@ -41,34 +43,24 @@ export function ArticleWritePage() { const { data, updateSite } = useSiteData() const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined + const [fetched, setFetched] = useState(null) - const [draft, setDraft] = useState(() => - existing - ? { - ...existing, - body: (Array.isArray(existing.body) ? EMPTY_DOC : existing.body) as ArticleBodyDoc, - } - : { - id: 0, - slug: `article-${Date.now()}`, - title: '', - date: new Date().toLocaleDateString('fa-IR'), - category: 'یادداشت', - image: '', - read: '۵ دقیقه', - excerpt: '', - body: EMPTY_DOC, - }, - ) - const [createdId, setCreatedId] = useState(existing?.id ?? null) + // مقاله تازه همیشه پیش‌نویس شروع می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود + const [draft, setDraft] = useState({ + id: 0, + slug: `article-${Date.now()}`, + title: '', + date: new Date().toLocaleDateString('fa-IR'), + category: 'یادداشت', + image: '', + read: '۵ دقیقه', + excerpt: '', + body: EMPTY_DOC, + status: 'draft', + seo_keyphrase: '', + }) + const [createdId, setCreatedId] = useState(null) const [deleting, setDeleting] = useState(false) - // کلمه کلیدی هدف فقط راهنمای سئو است — در localStorage می‌ماند، نه در دیتابیس - const [keyphrase, setKeyphrase] = useState(() => localStorage.getItem(`seo-keyphrase-${id ?? 'new'}`) ?? '') - - useEffect(() => { - localStorage.setItem(`seo-keyphrase-${id ?? 'new'}`, keyphrase) - }, [keyphrase, id]) - const draftRef = useRef(draft) draftRef.current = draft const createdRef = useRef(createdId) @@ -76,7 +68,35 @@ export function ArticleWritePage() { const pendingRef = useRef | null>(null) const timerRef = useRef | null>(null) - if (!isAuthenticated) return + // اگر مقاله در داده سایت نبود (مثلاً پیش از ادغام پیش‌نویس‌ها) مستقیم از انتهای مدیریتی خوانده می‌شود + useEffect(() => { + if (!id || existing) return + let cancelled = false + adminApi + .get('articles', Number(id)) + .then((a) => { + if (!cancelled) setFetched(a) + }) + .catch(() => { + /* مقاله وجود ندارد — فرم «مقاله جدید» می‌ماند */ + }) + return () => { + cancelled = true + } + }, [id, existing]) + + const article = existing ?? fetched + + // وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود + useEffect(() => { + if (!article || createdRef.current) return + setDraft({ + ...article, + body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc, + }) + setCreatedId(article.id) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [article?.id]) const doSave = async (changes: Record) => { if (createdRef.current) { @@ -86,7 +106,7 @@ export function ArticleWritePage() { if (a) Object.assign(a, changes) }) } else { - // اولین ذخیره = انتشار: مقاله با همه فیلدهای فعلی ساخته می‌شود + // اولین ذخیره = ساخت پیش‌نویس: مقاله ساخته می‌شود اما تا «انتشار» در سایت عمومی دیده نمی‌شود const payload = { ...draftRef.current, ...changes } if (!payload.title) payload.title = 'بدون عنوان' const created = await adminApi.create('articles', payload) @@ -133,6 +153,9 @@ export function ArticleWritePage() { const forceSave = () => void runSave(flush) + // محافظ مسیر — پس از همه هوک‌ها تا ترتیب فراخوانی هوک‌ها همیشه یکسان بماند + if (!isAuthenticated) return + const remove = async () => { if (!createdId) return const result = await Swal.fire({ @@ -193,8 +216,28 @@ export function ArticleWritePage() { > ذخیره + + {draft.status === 'draft' ? 'پیش‌نویس' : 'منتشر شده'} + {createdId && ( <> +

- {createdId ? 'ویرایش مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود' : 'نوشتن مقاله جدید — با اولین ذخیره منتشر می‌شود'} + {!createdId + ? 'نوشتن مقاله جدید — به‌صورت پیش‌نویس ذخیره می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود' + : draft.status === 'draft' + ? 'پیش‌نویس — تا انتشار در سایت عمومی دیده نمی‌شود؛ تغییرها ذخیره می‌شوند' + : 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}

{/* عنوان */} @@ -260,7 +307,7 @@ export function ArticleWritePage() { slug: draft.slug, excerpt: draft.excerpt, body: draft.body, - keyphrase, + keyphrase: draft.seo_keyphrase, }} /> @@ -272,8 +319,8 @@ export function ArticleWritePage() { setKeyphrase(e.target.value)} + value={draft.seo_keyphrase} + onChange={(e) => queueSave({ seo_keyphrase: e.target.value })} className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none" /> diff --git a/frontend/src/admin/Editable.tsx b/frontend/src/admin/Editable.tsx index e0b6ea7..84fb0e1 100644 --- a/frontend/src/admin/Editable.tsx +++ b/frontend/src/admin/Editable.tsx @@ -223,38 +223,68 @@ export function AddItemButton({ ) } -/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش */ +/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش. + * اگر رکورد هنوز در دیتابیس ساخته نشده باشد و createDefaults داده شود، دکمه + آن را می‌سازد. */ export function SingletonPill({ resource, label, className = '', select, + createDefaults, }: { resource: string label: string className?: string /** انتخاب رکورد از درخت داده — برای مجموعه‌هایی که یک رکورد خاص اینجاست (مثل سئو) */ select?: (data: NonNullable['data']>) => ItemWithId | undefined + /** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */ + createDefaults?: Record | (() => Record) }) { - const { isAuthenticated, isEditMode } = useAdmin() + const { isAuthenticated, isEditMode, runSave } = useAdmin() const config = ENTITIES[resource] - const { data } = useSiteData() + const { data, updateSite } = useSiteData() const [drawerOpen, setDrawerOpen] = useState(false) + const [created, setCreated] = useState(null) if (!config || !isAuthenticated || !isEditMode) return null - const item = select - ? select(data!) - : (data?.[config.dataKey as keyof NonNullable] as ItemWithId | undefined) - if (!item) return null + const item = + created ?? + (select + ? select(data!) + : (data?.[config.dataKey as keyof NonNullable] as ItemWithId | undefined)) + if (!item && !createDefaults) return null + + const create = () => { + const defaults = + typeof createDefaults === 'function' ? createDefaults() : (createDefaults ?? {}) + void runSave(async () => { + const rec = await adminApi.create(config.resource, defaults) + // درج خوش‌بینانه در درخت داده — دفعه بعد از داده انتخاب می‌شود + updateSite((d) => { + const list = d[config.dataKey as keyof NonNullable] as unknown as ItemWithId[] + if (Array.isArray(list)) list.push(rec) + }) + setCreated(rec) + setDrawerOpen(true) + }) + } return ( <> - setDrawerOpen(true)} label={label}> - - + {item ? ( + setDrawerOpen(true)} label={label}> + + + ) : ( + + + + )} - {drawerOpen && setDrawerOpen(false)} />} + {drawerOpen && item && ( + setDrawerOpen(false)} /> + )} ) } diff --git a/frontend/src/admin/adminClient.ts b/frontend/src/admin/adminClient.ts index 030099a..1461b19 100644 --- a/frontend/src/admin/adminClient.ts +++ b/frontend/src/admin/adminClient.ts @@ -69,6 +69,11 @@ export const adminApi = { patch: (resource: string, id: number, partial: Record) => authFetch(`/api/admin/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(partial) }), + list: (resource: string) => authFetch(`/api/admin/${resource}`), + + get: (resource: string, id: number) => + authFetch(`/api/admin/${resource}/${id}`), + create: (resource: string, data: Record) => authFetch(`/api/admin/${resource}`, { method: 'POST', body: JSON.stringify(data) }), @@ -83,4 +88,10 @@ export const adminApi = { fd.append('file', file) return authFetch('/api/admin/upload', { method: 'POST', body: fd }) }, + + changePassword: (currentPassword: string, newPassword: string) => + authFetch<{ ok: boolean }>('/api/auth/change-password', { + method: 'POST', + body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), + }), } diff --git a/frontend/src/api.tsx b/frontend/src/api.tsx index ee87e2f..e2e8dbf 100644 --- a/frontend/src/api.tsx +++ b/frontend/src/api.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea import type { ReactNode } from 'react' import type { Article, Project, Service } from './data/site' import { API_URL } from './config' +import { getToken } from './admin/adminClient' // انواع داده‌هایی که بک‌اند در GET /api/site برمی‌گرداند export type BrandInfo = { @@ -183,7 +184,25 @@ export function SiteProvider({ children }: { children: ReactNode }) { if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() as Promise }) - .then((json) => { + .then(async (json) => { + if (cancelled) return + // برای ادمین واردشده، پیش‌نویس مقاله‌ها از انتهای مدیریتی خوانده و ادغام می‌شوند — + // بک‌اند در GET /api/site فقط مقاله‌های منتشرشده را برمی‌گرداند + if (getToken()) { + try { + const res = await fetch(`${API_URL}/api/admin/articles`, { + headers: { Authorization: `Bearer ${getToken()}` }, + }) + if (res.ok) { + const all = (await res.json()) as Article[] + const published = new Set(json.articles.map((a) => a.id)) + const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id)) + json.articles = [...json.articles, ...drafts] + } + } catch { + /* بدون پیش‌نویس‌ها ادامه بده */ + } + } if (!cancelled) { if (json.brand && json.hero && json.footer && json.nav) setData(json) else setError(true) diff --git a/frontend/src/components/PageHero.tsx b/frontend/src/components/PageHero.tsx index cd3c863..98eb147 100644 --- a/frontend/src/components/PageHero.tsx +++ b/frontend/src/components/PageHero.tsx @@ -78,6 +78,8 @@ export function PageHero({ pageKey }: PageHeroProps) { resource="seo-meta" label="عنوان و توضیح سئوی این صفحه" select={(d) => d.seo_meta.find((s) => s.page_key === pageKey)} + // اگر ردیف سئوی این صفحه هنوز ساخته نشده باشد، همین‌جا ساخته می‌شود + createDefaults={() => ({ page_key: pageKey, title: '', description: '' })} /> saveField('image', url)} /> diff --git a/frontend/src/components/home/Articles.tsx b/frontend/src/components/home/Articles.tsx index 4424259..3e933b7 100644 --- a/frontend/src/components/home/Articles.tsx +++ b/frontend/src/components/home/Articles.tsx @@ -13,6 +13,9 @@ export function Articles() { const heading = useSectionHeading('articles'); const save = useFieldSaver(); + // پیش‌نویس‌ها فقط در صفحه وبلاگ (برای ادمین) دیده می‌شوند، نه در خانه + const published = articles.filter((a) => a.status !== 'draft'); + const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) => save('section-headings', heading.id, { [field]: value }, (d) => { const h = d.section_headings.find((s) => s.id === heading.id); @@ -36,7 +39,7 @@ export function Articles() {
- {articles.slice(0, 3).map((article, i) => + {published.slice(0, 3).map((article, i) => diff --git a/frontend/src/data/site.ts b/frontend/src/data/site.ts index 086e0b3..5b45e7e 100644 --- a/frontend/src/data/site.ts +++ b/frontend/src/data/site.ts @@ -29,9 +29,15 @@ export type Project = { /** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */ export type ArticleBodyDoc = { type: 'doc'; content: unknown[] }; +export type ArticleStatus = 'draft' | 'published'; + export type Article = { id: number; slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string; /** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */ body: ArticleBodyDoc | string[]; + /** پیش‌نویس فقط برای ادمین (از انتهای مدیریتی) می‌آید؛ بازدیدکننده نمی‌بیند */ + status: ArticleStatus; + /** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */ + seo_keyphrase: string; }; diff --git a/frontend/src/package.json b/frontend/src/package.json deleted file mode 100644 index 5665b39..0000000 --- a/frontend/src/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "magic-patterns-project", - "private": true, - "dependencies": { - "react": "18.3.1", - "react-dom": "18.3.1", - "react-router-dom": "6.30.2", - "lucide-react": "0.577.0", - "framer-motion": "11.18.2", - "@radix-ui/react-icons": "1.3.2", - "date-fns": "4.1.0", - "tailwind-merge": "2.6.1" - } -} diff --git a/frontend/src/pages/ArticleDetail.tsx b/frontend/src/pages/ArticleDetail.tsx index 4276061..df19d57 100644 --- a/frontend/src/pages/ArticleDetail.tsx +++ b/frontend/src/pages/ArticleDetail.tsx @@ -7,7 +7,7 @@ import { ButtonLink } from '../components/Button'; import { ArticleCard } from '../components/ArticleCard'; import { Newsletter } from '../components/home/Newsletter'; import { useSite } from '../api'; -import { setMetaDescription } from '../seo'; +import { applySeo } from '../seo'; import { useEffect } from 'react'; import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; import type { Article } from '../data/site'; @@ -34,11 +34,27 @@ export function ArticleDetail() { const { articles, brand } = useSite(); const article = articles.find((a) => a.slug === slug); - // عنوان تب و توضیح متا از خود مقاله + // عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله useEffect(() => { if (!article) return; - document.title = `${article.title} | ${brand}`; - setMetaDescription(article.excerpt); + applySeo({ + title: `${article.title} | ${brand}`, + description: article.excerpt, + path: `/blog/${article.slug}`, + image: article.image, + type: 'article', + jsonLd: { + '@context': 'https://schema.org', + '@type': 'Article', + headline: article.title, + description: article.excerpt, + image: article.image ? new URL(article.image, window.location.origin).href : undefined, + inLanguage: 'fa', + author: { '@type': 'Organization', name: brand }, + publisher: { '@type': 'Organization', name: brand }, + mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href, + }, + }); }, [article?.id]); if (!article) { @@ -55,7 +71,7 @@ export function ArticleDetail() { } const { html, legacy } = renderBody(article); - const related = articles.filter((a) => a.slug !== article.slug).slice(0, 3); + const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3); return ( <> diff --git a/frontend/src/pages/Blog.tsx b/frontend/src/pages/Blog.tsx index dc367ed..9c5febb 100644 --- a/frontend/src/pages/Blog.tsx +++ b/frontend/src/pages/Blog.tsx @@ -9,9 +9,46 @@ import { useSite } from '../api' import { EditableItem } from '../admin/Editable' import { PlusIcon } from 'lucide-react' +function DraftBadge({ className = '' }: { className?: string }) { + return ( + + پیش‌نویس + + ) +} + export function Blog() { const { articles } = useSite() - const [featured, ...rest] = articles + + // حالت خالی — وبلاگ بدون مقاله نباید کرش کند + if (articles.length === 0) { + return ( + <> + +
+
+

هنوز مقاله‌ای منتشر نشده است

+

+ به‌زودی نخستین نوشته‌های ما اینجا منتشر می‌شود. +

+ + + نوشتن مقاله جدید + +
+
+ + + ) + } + + const [featured, ...rest] = articles return ( <> @@ -44,7 +81,12 @@ export function Blog() { />
-

مقاله ویژه — {featured.category}

+

+ مقاله ویژه — {featured.category} + {featured.status === 'draft' && ( + + )} +

{featured.title}

@@ -71,7 +113,12 @@ export function Blog() { {rest.map((article, i) => ( - +
+ {article.status === 'draft' && ( + + )} + +
))} @@ -82,4 +129,4 @@ export function Blog() { ) -} \ No newline at end of file +} diff --git a/frontend/src/pages/ProjectDetail.tsx b/frontend/src/pages/ProjectDetail.tsx index 53d7a15..1010439 100644 --- a/frontend/src/pages/ProjectDetail.tsx +++ b/frontend/src/pages/ProjectDetail.tsx @@ -8,7 +8,7 @@ import { ContactSection } from '../components/ContactSection' import { Lightbox } from '../components/Lightbox' import { useEffect } from 'react'; import { useSite, useStatusLabel } from '../api'; -import { setMetaDescription } from '../seo'; +import { applySeo } from '../seo'; export function ProjectDetail() { const { slug } = useParams<{ slug: string }>() @@ -17,11 +17,15 @@ export function ProjectDetail() { const statusLabel = useStatusLabel(project?.status ?? '') const [lightbox, setLightbox] = useState(null) - // عنوان تب و توضیح متا از خود پروژه + // عنوان تب، توضیح متا و OG از خود پروژه useEffect(() => { if (!project) return; - document.title = `${project.title} | ${brand}`; - setMetaDescription(project.summary); + applySeo({ + title: `${project.title} | ${brand}`, + description: project.summary, + path: `/projects/${project.slug}`, + image: project.image, + }); }, [project?.id]); if (!project) { diff --git a/frontend/src/seo.ts b/frontend/src/seo.ts index 08884ba..31e12eb 100644 --- a/frontend/src/seo.ts +++ b/frontend/src/seo.ts @@ -1,11 +1,11 @@ -// تنظیم عنوان صفحه و توضیح متا — محتوای سئو از دیتابیس می‌آید +// تگ‌های سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical و JSON-LD. +// محتوا از دیتابیس می‌آید؛ برای خزنده‌های بدون جاوااسکریپت، بک‌اند همان تگ‌ها را +// سمت سرور داخل index.html تزریق می‌کند (app/seo_pages.py). export function setMetaDescription(content: string): void { + // بدون توضیح از دیتابیس، پیش‌فرض استاتیک index.html دست‌نخورده می‌ماند + if (!content.trim()) return let el = document.querySelector('meta[name="description"]') - if (!content.trim()) { - el?.remove() - return - } if (!el) { el = document.createElement('meta') el.setAttribute('name', 'description') @@ -22,3 +22,73 @@ export function seoKeyForPath(pathname: string): string | null { // مسیرهای جزئی مثل /blog/{slug} شامل نمی‌شوند تا اثر خود صفحه جزئیات برسانند return known.includes(seg) && pathname === `/${seg}` ? seg : null } + +function upsertMeta(attr: 'name' | 'property', key: string, content: string): void { + let el = document.querySelector(`meta[${attr}="${key}"]`) + if (!el) { + el = document.createElement('meta') + el.setAttribute(attr, key) + document.head.appendChild(el) + } + el.setAttribute('content', content) +} + +function upsertCanonical(path: string): void { + let el = document.querySelector('link[rel="canonical"]') + if (!el) { + el = document.createElement('link') + el.setAttribute('rel', 'canonical') + document.head.appendChild(el) + } + el.setAttribute('href', new URL(path, window.location.origin).href) +} + +function setJsonLd(data: Record | null): void { + const id = 'seo-jsonld' + let el = document.getElementById(id) + if (!data) { + el?.remove() + return + } + if (!el) { + el = document.createElement('script') + el.setAttribute('type', 'application/ld+json') + el.id = id + document.head.appendChild(el) + } + el.textContent = JSON.stringify(data) +} + +export type SeoInput = { + title: string + description?: string + /** مسیر canonical — پیش‌فرض مسیر جاری مرورگر */ + path?: string + /** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل می‌شود */ + image?: string + type?: 'website' | 'article' + jsonLd?: Record | null +} + +/** تنظیم همه تگ‌های سئوی صفحه در یک فراخوانی */ +export function applySeo(input: SeoInput): void { + const title = input.title.trim() + const description = (input.description ?? '').trim() + const path = input.path ?? window.location.pathname + const image = (input.image ?? '').trim() + + document.title = title + setMetaDescription(description) + upsertCanonical(path) + upsertMeta('property', 'og:title', title) + upsertMeta('property', 'og:description', description) + upsertMeta('property', 'og:type', input.type ?? 'website') + upsertMeta('property', 'og:url', new URL(path, window.location.origin).href) + upsertMeta('name', 'twitter:card', 'summary_large_image') + if (image) { + upsertMeta('property', 'og:image', new URL(image, window.location.origin).href) + } else { + document.querySelector('meta[property="og:image"]')?.remove() + } + setJsonLd(input.jsonLd ?? null) +}