وبلاگ و سئو: پیشنویس/انتشار مقاله + کلمه کلیدی سئو در دیتابیس + OG/canonical/JSON-LD و متای سمت سرور + تغییر رمز و محدودسازی ورود + تستها
- مقالهها ستون 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 سرگردان فرانت
This commit is contained in:
+14
-3
@@ -86,6 +86,13 @@ Authorization: Bearer <access_token>
|
|||||||
|
|
||||||
> نام کاربری و رمز عبور پیشفرض از تنظیمات `.env` خوانده میشود — حتماً در محیط تولید تغییرشان دهید.
|
> نام کاربری و رمز عبور پیشفرض از تنظیمات `.env` خوانده میشود — حتماً در محیط تولید تغییرشان دهید.
|
||||||
|
|
||||||
|
تغییر رمز (با توکن):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/change-password
|
||||||
|
{ "current_password": "...", "new_password": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
## فهرست انتهاپوینتها
|
## فهرست انتهاپوینتها
|
||||||
|
|
||||||
### عمومی (بدون نیاز به توکن)
|
### عمومی (بدون نیاز به توکن)
|
||||||
@@ -97,8 +104,8 @@ Authorization: Bearer <access_token>
|
|||||||
| GET | `/api/services` | خدمات |
|
| GET | `/api/services` | خدمات |
|
||||||
| GET | `/api/projects` | همه پروژهها |
|
| GET | `/api/projects` | همه پروژهها |
|
||||||
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
|
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
|
||||||
| GET | `/api/articles` | همه مقالات |
|
| GET | `/api/articles` | مقالههای منتشرشده (پیشنویس نمیآید) |
|
||||||
| GET | `/api/articles/{slug}` | جزئیات یک مقاله |
|
| GET | `/api/articles/{slug}` | جزئیات یک مقاله منتشرشده |
|
||||||
| GET | `/api/testimonials` | نظرات مشتریان |
|
| GET | `/api/testimonials` | نظرات مشتریان |
|
||||||
| GET | `/api/faqs` | سوالات متداول |
|
| GET | `/api/faqs` | سوالات متداول |
|
||||||
| GET | `/api/team` | اعضای تیم |
|
| GET | `/api/team` | اعضای تیم |
|
||||||
@@ -109,7 +116,11 @@ Authorization: Bearer <access_token>
|
|||||||
| GET | `/api/site` | **همه محتوا در یک درخواست** |
|
| GET | `/api/site` | **همه محتوا در یک درخواست** |
|
||||||
| POST | `/api/contact` | ثبت پیام تماس |
|
| POST | `/api/contact` | ثبت پیام تماس |
|
||||||
| POST | `/api/newsletter` | عضویت در خبرنامه |
|
| 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)
|
### مدیریت (نیاز به توکن Bearer)
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import boto3
|
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 import func
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import models, schemas
|
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 ..config import settings
|
||||||
from ..database import get_db
|
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 (
|
from ..schemas import (
|
||||||
ArticleIn,
|
ArticleIn,
|
||||||
ArticleOut,
|
ArticleOut,
|
||||||
@@ -23,6 +25,7 @@ from ..schemas import (
|
|||||||
BrandIn,
|
BrandIn,
|
||||||
BrandOut,
|
BrandOut,
|
||||||
BrandPatch,
|
BrandPatch,
|
||||||
|
ChangePasswordIn,
|
||||||
ContactSectionIn,
|
ContactSectionIn,
|
||||||
ContactSectionOut,
|
ContactSectionOut,
|
||||||
ContactSectionPatch,
|
ContactSectionPatch,
|
||||||
@@ -105,16 +108,33 @@ router = APIRouter(dependencies=[Depends(get_current_admin)])
|
|||||||
|
|
||||||
|
|
||||||
@auth_router.post("/auth/login", response_model=TokenOut)
|
@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()
|
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):
|
if user is None or not verify_password(payload.password, user.password_hash):
|
||||||
|
record_failure(request)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="نام کاربری یا رمز عبور اشتباه است",
|
detail="نام کاربری یا رمز عبور اشتباه است",
|
||||||
)
|
)
|
||||||
|
clear_rate_limit(request)
|
||||||
return TokenOut(access_token=create_access_token(user.username))
|
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:
|
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
|
||||||
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
||||||
حذف و ترتیببندی) روی روتر مدیریت."""
|
حذف و ترتیببندی) روی روتر مدیریت."""
|
||||||
|
|||||||
@@ -55,12 +55,21 @@ def get_project(slug: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/articles", response_model=list[ArticleOut])
|
@router.get("/articles", response_model=list[ArticleOut])
|
||||||
def get_articles(db: Session = Depends(get_db)):
|
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)
|
@router.get("/articles/{slug}", response_model=ArticleOut)
|
||||||
def get_article(slug: str, db: Session = Depends(get_db)):
|
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:
|
if article is None:
|
||||||
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||||
return article
|
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(),
|
"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(),
|
"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(),
|
"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(),
|
"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(),
|
"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(),
|
"team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(),
|
||||||
|
|||||||
@@ -23,9 +23,14 @@ class Settings(BaseSettings):
|
|||||||
s3_access_key: str = "aramesh"
|
s3_access_key: str = "aramesh"
|
||||||
s3_secret_key: str = "aramesh-minio-secret"
|
s3_secret_key: str = "aramesh-minio-secret"
|
||||||
s3_bucket: str = "aramesh-media"
|
s3_bucket: str = "aramesh-media"
|
||||||
# پیشوند عمومی مسیر تصاویر — nginx آن را به باکت پروکسی میکند
|
# پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی میکند
|
||||||
media_public_base: str = "/media"
|
media_public_base: str = "/media"
|
||||||
media_max_upload_mb: int = 10
|
media_max_upload_mb: int = 10
|
||||||
|
|
||||||
|
# نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
|
||||||
|
site_url: str = ""
|
||||||
|
# مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
|
||||||
|
frontend_dist: str = "../frontend/dist"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from .api import admin, forms, public
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import Base, engine
|
from .database import Base, engine
|
||||||
from .migrations import run_all as run_migrations
|
from .migrations import run_all as run_migrations
|
||||||
|
from .seo_pages import router as seo_router
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
run_migrations()
|
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(forms.router, prefix="/api", tags=["فرمها"])
|
||||||
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
|
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
|
||||||
app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"])
|
app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"])
|
||||||
|
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
|
||||||
|
app.include_router(seo_router, tags=["سئو"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ def run_ddl_migrations() -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0")
|
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:
|
def paragraphs_to_doc(paragraphs: list) -> dict:
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ class Article(Base):
|
|||||||
read: Mapped[str] = mapped_column(String(32), default="")
|
read: Mapped[str] = mapped_column(String(32), default="")
|
||||||
excerpt: Mapped[str] = mapped_column(Text, default="")
|
excerpt: Mapped[str] = mapped_column(Text, default="")
|
||||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
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):
|
class Testimonial(Base):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -59,6 +59,8 @@ class ArticleOut(ORMModel):
|
|||||||
read: str
|
read: str
|
||||||
excerpt: str
|
excerpt: str
|
||||||
body: dict
|
body: dict
|
||||||
|
status: str
|
||||||
|
seo_keyphrase: str
|
||||||
|
|
||||||
|
|
||||||
class TestimonialOut(ORMModel):
|
class TestimonialOut(ORMModel):
|
||||||
@@ -292,6 +294,11 @@ class TokenOut(BaseModel):
|
|||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordIn(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
class BrandIn(BaseModel):
|
class BrandIn(BaseModel):
|
||||||
name: str = ""
|
name: str = ""
|
||||||
logo: str = ""
|
logo: str = ""
|
||||||
@@ -346,6 +353,8 @@ class ArticleIn(BaseModel):
|
|||||||
read: str = ""
|
read: str = ""
|
||||||
excerpt: str = ""
|
excerpt: str = ""
|
||||||
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
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)
|
_validate_body = field_validator("body")(validate_doc_body)
|
||||||
|
|
||||||
|
|
||||||
@@ -548,6 +557,8 @@ class ArticlePatch(BaseModel):
|
|||||||
read: Optional[str] = None
|
read: Optional[str] = None
|
||||||
excerpt: Optional[str] = None
|
excerpt: Optional[str] = None
|
||||||
body: Optional[dict] = None
|
body: Optional[dict] = None
|
||||||
|
status: Optional[Literal["draft", "published"]] = None
|
||||||
|
seo_keyphrase: Optional[str] = None
|
||||||
_validate_body = field_validator("body")(validate_doc_body)
|
_validate_body = field_validator("body")(validate_doc_body)
|
||||||
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
|
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
|
||||||
FaqPatch = _make_patch("FaqPatch", FaqIn)
|
FaqPatch = _make_patch("FaqPatch", FaqIn)
|
||||||
|
|||||||
@@ -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'<meta name="description" content="{esc(description)}" />',
|
||||||
|
f'<meta property="og:title" content="{esc(title)}" />',
|
||||||
|
f'<meta property="og:description" content="{esc(description)}" />',
|
||||||
|
f'<meta property="og:type" content="{esc(og_type)}" />',
|
||||||
|
f'<meta property="og:url" content="{esc(canonical)}" />',
|
||||||
|
f'<meta name="twitter:card" content="summary_large_image" />',
|
||||||
|
]
|
||||||
|
if image:
|
||||||
|
tags.append(f'<meta property="og:image" content="{esc(image)}" />')
|
||||||
|
if jsonld:
|
||||||
|
payload = json.dumps(jsonld, ensure_ascii=False).replace("</", "<\\/")
|
||||||
|
tags.append(f'<script type="application/ld+json">{payload}</script>')
|
||||||
|
block = "\n ".join(tags)
|
||||||
|
|
||||||
|
raw = _read_index()
|
||||||
|
if raw is None:
|
||||||
|
# بیلد فرانتاند سوار نشده — صفحه حداقلی با خودِ متا تا خزندهها دست خالی نمانند
|
||||||
|
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
|
||||||
|
return HTMLResponse(
|
||||||
|
f'<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">'
|
||||||
|
f"<title>{esc(title)}</title>{block}</head><body>{body}</body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
|
||||||
|
raw = raw.replace("</head>", f" {block}\n </head>", 1)
|
||||||
|
if noscript:
|
||||||
|
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 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" <url><loc>{html.escape(_absolute(u))}</loc></url>" for u in urls
|
||||||
|
)
|
||||||
|
xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||||
|
f"{items}\n</urlset>\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"<h1>{html.escape(article.title)}</h1><p>{html.escape(first_text)}</p>"
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# وابستگیهای توسعه و تست — pip install -r requirements-dev.txt
|
||||||
|
-r requirements.txt
|
||||||
|
pytest==8.3.4
|
||||||
|
httpx==0.27.2
|
||||||
+13
-11
@@ -1,11 +1,13 @@
|
|||||||
fastapi>=0.115.8
|
# نسخهها دقیق پین شدهاند تا ارتقای خودکار یک وابستگی، استقرار را نشکند.
|
||||||
uvicorn[standard]>=0.34.0
|
# ارتقا: نسخه را دستی بالا ببرید و روی محیط آمادهسازی تست کنید.
|
||||||
sqlalchemy>=2.0.36
|
fastapi==0.115.8
|
||||||
psycopg[binary]>=3.2.3
|
uvicorn[standard]==0.34.0
|
||||||
pydantic>=2.10.4
|
sqlalchemy==2.0.36
|
||||||
pydantic-settings>=2.6.1
|
psycopg[binary]==3.2.3
|
||||||
PyJWT>=2.10.1
|
pydantic==2.10.4
|
||||||
bcrypt>=4.2.1
|
pydantic-settings==2.6.1
|
||||||
email-validator>=2.2.0
|
PyJWT==2.10.1
|
||||||
boto3>=1.35.0
|
bcrypt==4.2.1
|
||||||
python-multipart>=0.0.18
|
email-validator==2.2.0
|
||||||
|
boto3==1.35.0
|
||||||
|
python-multipart==0.0.18
|
||||||
|
|||||||
@@ -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(
|
||||||
|
"<!doctype html><html><head><title>Shell</title></head>"
|
||||||
|
"<body><div id=\"root\"></div></body></html>",
|
||||||
|
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)
|
||||||
@@ -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("<?xml")
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_injection_for_article(client):
|
||||||
|
res = client.get("/blog/published-post")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "مقاله منتشرشده" in res.text
|
||||||
|
assert 'property="og:title"' in res.text
|
||||||
|
assert "application/ld+json" in res.text
|
||||||
|
assert "https://test.example/blog/published-post" in res.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_not_injected_for_draft(client):
|
||||||
|
res = client.get("/blog/draft-post")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "پیشنویس" not in res.text # عنوان پیشنویس افشا نمیشود
|
||||||
|
assert "application/ld+json" not in res.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_replaces_title_once(client):
|
||||||
|
res = client.get("/blog/published-post")
|
||||||
|
assert len(re.findall(r"<title>", res.text)) == 1
|
||||||
+30
-5
@@ -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`
|
```bash
|
||||||
2. Run `npm run dev`
|
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` — انواع داده مشترک
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>گروه ساختمانی آرامش</title>
|
<title>گروه ساختمانی آرامش</title>
|
||||||
|
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژههای ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایهگذاری ساختوساز." />
|
||||||
|
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:locale" content="fa_IR" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{"@context":"https://schema.org","@type":"Organization","name":"گروه ساختمانی آرامش","inLanguage":"fa"}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { ArticleDetail } from './pages/ArticleDetail';
|
|||||||
import { FaqPage } from './pages/FaqPage';
|
import { FaqPage } from './pages/FaqPage';
|
||||||
import { ContactPage } from './pages/ContactPage';
|
import { ContactPage } from './pages/ContactPage';
|
||||||
import { SiteProvider, useSiteData } from './api';
|
import { SiteProvider, useSiteData } from './api';
|
||||||
import { seoKeyForPath, setMetaDescription } from './seo';
|
import { applySeo, seoKeyForPath } from './seo';
|
||||||
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
||||||
import { AdminBar } from './admin/AdminBar';
|
import { AdminBar } from './admin/AdminBar';
|
||||||
import { AdminLoginPage } from './admin/AdminLoginPage';
|
import { AdminLoginPage } from './admin/AdminLoginPage';
|
||||||
@@ -76,8 +76,11 @@ function AppChrome() {
|
|||||||
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
||||||
const brand = data.brand.name;
|
const brand = data.brand.name;
|
||||||
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
|
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
|
||||||
document.title = meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand);
|
applySeo({
|
||||||
setMetaDescription(meta?.description ?? '');
|
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
||||||
|
description: meta?.description ?? '',
|
||||||
|
path: pathname,
|
||||||
|
});
|
||||||
}, [pathname, data, isAdminPage]);
|
}, [pathname, data, isAdminPage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+170
-44
@@ -1,4 +1,8 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import Swal from 'sweetalert2'
|
||||||
|
import { KeyRoundIcon, XIcon } from 'lucide-react'
|
||||||
import { useAdmin } from './AdminContext'
|
import { useAdmin } from './AdminContext'
|
||||||
|
import { adminApi } from './adminClient'
|
||||||
|
|
||||||
const STATUS_TEXT: Record<string, string> = {
|
const STATUS_TEXT: Record<string, string> = {
|
||||||
idle: 'همگامسازی شد',
|
idle: 'همگامسازی شد',
|
||||||
@@ -12,53 +16,175 @@ const STATUS_COLOR: Record<string, string> = {
|
|||||||
error: 'bg-red-400',
|
error: 'bg-red-400',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
const SWAL_BASE = {
|
||||||
export function AdminBar() {
|
background: '#1e1a16',
|
||||||
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
|
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 (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-[95] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="تغییر رمز عبور">
|
||||||
dir="rtl"
|
<div dir="rtl" className="w-full max-w-sm border border-ivory/15 bg-ink p-8 shadow-2xl">
|
||||||
className="fixed bottom-4 right-1/2 z-[90] flex translate-x-1/2 items-center gap-5 border border-ivory/15 bg-ink/95 py-2.5 pl-6 pr-5 shadow-2xl backdrop-blur-md"
|
<div className="flex items-center justify-between">
|
||||||
>
|
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||||
<span className="flex items-center gap-2 text-xs font-bold text-copper">
|
<KeyRoundIcon className="h-4 w-4" />
|
||||||
<span className="flex h-2 w-2 rounded-full bg-copper" />
|
تغییر رمز عبور
|
||||||
حالت مدیریت
|
</h2>
|
||||||
</span>
|
<button
|
||||||
|
type="button"
|
||||||
<button
|
onClick={onClose}
|
||||||
type="button"
|
aria-label="بستن"
|
||||||
role="switch"
|
className="text-ivory/50 transition-colors hover:text-ivory"
|
||||||
aria-checked={isEditMode}
|
>
|
||||||
onClick={toggleEditMode}
|
<XIcon className="h-4 w-4" />
|
||||||
className="group flex items-center gap-3 text-sm font-light text-ivory/80"
|
</button>
|
||||||
>
|
</div>
|
||||||
<span
|
<div className="mt-6 grid gap-4">
|
||||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${
|
<input
|
||||||
isEditMode ? 'border-copper bg-copper/30' : 'border-ivory/25 bg-char'
|
type="password"
|
||||||
}`}
|
value={current}
|
||||||
>
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
<span
|
placeholder="رمز عبور فعلی"
|
||||||
className={`absolute h-4 w-4 rounded-full transition-all duration-200 ${
|
aria-label="رمز عبور فعلی"
|
||||||
isEditMode ? 'right-1 bg-copper' : 'right-6 bg-ivory/50'
|
autoComplete="current-password"
|
||||||
}`}
|
className={field}
|
||||||
/>
|
/>
|
||||||
</span>
|
<input
|
||||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
type="password"
|
||||||
</button>
|
value={next}
|
||||||
|
onChange={(e) => setNext(e.target.value)}
|
||||||
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
|
placeholder="رمز جدید (دستکم ۸ نویسه)"
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
|
aria-label="رمز جدید"
|
||||||
{STATUS_TEXT[saveStatus]}
|
autoComplete="new-password"
|
||||||
</span>
|
className={field}
|
||||||
|
/>
|
||||||
<button
|
<input
|
||||||
type="button"
|
type="password"
|
||||||
onClick={logout}
|
value={confirm}
|
||||||
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
>
|
placeholder="تکرار رمز جدید"
|
||||||
خروج
|
aria-label="تکرار رمز جدید"
|
||||||
</button>
|
autoComplete="new-password"
|
||||||
|
className={field}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void submit()}
|
||||||
|
disabled={busy || !current || !next}
|
||||||
|
className="mt-6 w-full bg-copper py-2.5 text-sm font-bold text-char transition-opacity disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? 'در حال ذخیره…' : 'ذخیره رمز جدید'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||||
|
export function AdminBar() {
|
||||||
|
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
|
||||||
|
const [pwOpen, setPwOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="fixed bottom-4 right-1/2 z-[90] flex translate-x-1/2 items-center gap-5 border border-ivory/15 bg-ink/95 py-2.5 pl-6 pr-5 shadow-2xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-xs font-bold text-copper">
|
||||||
|
<span className="flex h-2 w-2 rounded-full bg-copper" />
|
||||||
|
حالت مدیریت
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isEditMode}
|
||||||
|
onClick={toggleEditMode}
|
||||||
|
className="group flex items-center gap-3 text-sm font-light text-ivory/80"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${
|
||||||
|
isEditMode ? 'border-copper bg-copper/30' : 'border-ivory/25 bg-char'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute h-4 w-4 rounded-full transition-all duration-200 ${
|
||||||
|
isEditMode ? 'right-1 bg-copper' : 'right-6 bg-ivory/50'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
|
||||||
|
{STATUS_TEXT[saveStatus]}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPwOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||||
|
>
|
||||||
|
<KeyRoundIcon className="h-3.5 w-3.5" />
|
||||||
|
رمز
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
|
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||||
|
>
|
||||||
|
خروج
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pwOpen && <ChangePasswordModal onClose={() => setPwOpen(false)} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,14 +67,18 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
|||||||
const login = useCallback(async (username: string, password: string) => {
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
await adminApi.login(username, password)
|
await adminApi.login(username, password)
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
}, [])
|
// داده سایت دوباره بارگذاری میشود تا پیشنویس مقالهها هم ادغام شوند
|
||||||
|
reload()
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
setIsEditMode(false)
|
setIsEditMode(false)
|
||||||
sessionStorage.removeItem('aramesh_edit_mode')
|
sessionStorage.removeItem('aramesh_edit_mode')
|
||||||
}, [])
|
// داده سایت دوباره بارگذاری میشود تا پیشنویسها از درخت داده حذف شوند
|
||||||
|
reload()
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
const runSave = useCallback(
|
const runSave = useCallback(
|
||||||
async (action: () => Promise<void>): Promise<boolean> => {
|
async (action: () => Promise<void>): Promise<boolean> => {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type Draft = {
|
|||||||
read: string
|
read: string
|
||||||
excerpt: string
|
excerpt: string
|
||||||
body: ArticleBodyDoc
|
body: ArticleBodyDoc
|
||||||
|
status: 'draft' | 'published'
|
||||||
|
seo_keyphrase: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_TEXT: Record<string, string> = {
|
const STATUS_TEXT: Record<string, string> = {
|
||||||
@@ -41,34 +43,24 @@ export function ArticleWritePage() {
|
|||||||
const { data, updateSite } = useSiteData()
|
const { data, updateSite } = useSiteData()
|
||||||
|
|
||||||
const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined
|
const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined
|
||||||
|
const [fetched, setFetched] = useState<Draft | null>(null)
|
||||||
|
|
||||||
const [draft, setDraft] = useState<Draft>(() =>
|
// مقاله تازه همیشه پیشنویس شروع میشود؛ با دکمه «انتشار در سایت» عمومی میشود
|
||||||
existing
|
const [draft, setDraft] = useState<Draft>({
|
||||||
? {
|
id: 0,
|
||||||
...existing,
|
slug: `article-${Date.now()}`,
|
||||||
body: (Array.isArray(existing.body) ? EMPTY_DOC : existing.body) as ArticleBodyDoc,
|
title: '',
|
||||||
}
|
date: new Date().toLocaleDateString('fa-IR'),
|
||||||
: {
|
category: 'یادداشت',
|
||||||
id: 0,
|
image: '',
|
||||||
slug: `article-${Date.now()}`,
|
read: '۵ دقیقه',
|
||||||
title: '',
|
excerpt: '',
|
||||||
date: new Date().toLocaleDateString('fa-IR'),
|
body: EMPTY_DOC,
|
||||||
category: 'یادداشت',
|
status: 'draft',
|
||||||
image: '',
|
seo_keyphrase: '',
|
||||||
read: '۵ دقیقه',
|
})
|
||||||
excerpt: '',
|
const [createdId, setCreatedId] = useState<number | null>(null)
|
||||||
body: EMPTY_DOC,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const [createdId, setCreatedId] = useState<number | null>(existing?.id ?? null)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
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)
|
const draftRef = useRef(draft)
|
||||||
draftRef.current = draft
|
draftRef.current = draft
|
||||||
const createdRef = useRef(createdId)
|
const createdRef = useRef(createdId)
|
||||||
@@ -76,7 +68,35 @@ export function ArticleWritePage() {
|
|||||||
const pendingRef = useRef<Record<string, unknown> | null>(null)
|
const pendingRef = useRef<Record<string, unknown> | null>(null)
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
|
// اگر مقاله در داده سایت نبود (مثلاً پیش از ادغام پیشنویسها) مستقیم از انتهای مدیریتی خوانده میشود
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || existing) return
|
||||||
|
let cancelled = false
|
||||||
|
adminApi
|
||||||
|
.get<Draft>('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<string, unknown>) => {
|
const doSave = async (changes: Record<string, unknown>) => {
|
||||||
if (createdRef.current) {
|
if (createdRef.current) {
|
||||||
@@ -86,7 +106,7 @@ export function ArticleWritePage() {
|
|||||||
if (a) Object.assign(a, changes)
|
if (a) Object.assign(a, changes)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// اولین ذخیره = انتشار: مقاله با همه فیلدهای فعلی ساخته میشود
|
// اولین ذخیره = ساخت پیشنویس: مقاله ساخته میشود اما تا «انتشار» در سایت عمومی دیده نمیشود
|
||||||
const payload = { ...draftRef.current, ...changes }
|
const payload = { ...draftRef.current, ...changes }
|
||||||
if (!payload.title) payload.title = 'بدون عنوان'
|
if (!payload.title) payload.title = 'بدون عنوان'
|
||||||
const created = await adminApi.create<Draft & { id: number }>('articles', payload)
|
const created = await adminApi.create<Draft & { id: number }>('articles', payload)
|
||||||
@@ -133,6 +153,9 @@ export function ArticleWritePage() {
|
|||||||
|
|
||||||
const forceSave = () => void runSave(flush)
|
const forceSave = () => void runSave(flush)
|
||||||
|
|
||||||
|
// محافظ مسیر — پس از همه هوکها تا ترتیب فراخوانی هوکها همیشه یکسان بماند
|
||||||
|
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!createdId) return
|
if (!createdId) return
|
||||||
const result = await Swal.fire({
|
const result = await Swal.fire({
|
||||||
@@ -193,8 +216,28 @@ export function ArticleWritePage() {
|
|||||||
>
|
>
|
||||||
ذخیره
|
ذخیره
|
||||||
</button>
|
</button>
|
||||||
|
<span
|
||||||
|
className={`border px-3 py-2 text-xs font-light ${
|
||||||
|
draft.status === 'draft'
|
||||||
|
? 'border-amber-400/50 text-amber-300'
|
||||||
|
: 'border-emerald-400/50 text-emerald-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{draft.status === 'draft' ? 'پیشنویس' : 'منتشر شده'}
|
||||||
|
</span>
|
||||||
{createdId && (
|
{createdId && (
|
||||||
<>
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
|
||||||
|
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
||||||
|
draft.status === 'draft'
|
||||||
|
? 'bg-copper text-char hover:scale-105'
|
||||||
|
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||||
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to={`/blog/${draft.slug}`}
|
to={`/blog/${draft.slug}`}
|
||||||
data-admin-link
|
data-admin-link
|
||||||
@@ -220,7 +263,11 @@ export function ArticleWritePage() {
|
|||||||
|
|
||||||
<main className="mx-auto max-w-4xl px-6 pb-32 pt-10">
|
<main className="mx-auto max-w-4xl px-6 pb-32 pt-10">
|
||||||
<p className="text-xs font-light text-copper">
|
<p className="text-xs font-light text-copper">
|
||||||
{createdId ? 'ویرایش مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود' : 'نوشتن مقاله جدید — با اولین ذخیره منتشر میشود'}
|
{!createdId
|
||||||
|
? 'نوشتن مقاله جدید — بهصورت پیشنویس ذخیره میشود؛ با دکمه «انتشار در سایت» عمومی میشود'
|
||||||
|
: draft.status === 'draft'
|
||||||
|
? 'پیشنویس — تا انتشار در سایت عمومی دیده نمیشود؛ تغییرها ذخیره میشوند'
|
||||||
|
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* عنوان */}
|
{/* عنوان */}
|
||||||
@@ -260,7 +307,7 @@ export function ArticleWritePage() {
|
|||||||
slug: draft.slug,
|
slug: draft.slug,
|
||||||
excerpt: draft.excerpt,
|
excerpt: draft.excerpt,
|
||||||
body: draft.body,
|
body: draft.body,
|
||||||
keyphrase,
|
keyphrase: draft.seo_keyphrase,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -272,8 +319,8 @@ export function ArticleWritePage() {
|
|||||||
<input
|
<input
|
||||||
id="seo-keyphrase"
|
id="seo-keyphrase"
|
||||||
type="text"
|
type="text"
|
||||||
value={keyphrase}
|
value={draft.seo_keyphrase}
|
||||||
onChange={(e) => setKeyphrase(e.target.value)}
|
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"
|
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -223,38 +223,68 @@ export function AddItemButton({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش */
|
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش.
|
||||||
|
* اگر رکورد هنوز در دیتابیس ساخته نشده باشد و createDefaults داده شود، دکمه + آن را میسازد. */
|
||||||
export function SingletonPill({
|
export function SingletonPill({
|
||||||
resource,
|
resource,
|
||||||
label,
|
label,
|
||||||
className = '',
|
className = '',
|
||||||
select,
|
select,
|
||||||
|
createDefaults,
|
||||||
}: {
|
}: {
|
||||||
resource: string
|
resource: string
|
||||||
label: string
|
label: string
|
||||||
className?: string
|
className?: string
|
||||||
/** انتخاب رکورد از درخت داده — برای مجموعههایی که یک رکورد خاص اینجاست (مثل سئو) */
|
/** انتخاب رکورد از درخت داده — برای مجموعههایی که یک رکورد خاص اینجاست (مثل سئو) */
|
||||||
select?: (data: NonNullable<ReturnType<typeof useSiteData>['data']>) => ItemWithId | undefined
|
select?: (data: NonNullable<ReturnType<typeof useSiteData>['data']>) => ItemWithId | undefined
|
||||||
|
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
|
||||||
|
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||||
}) {
|
}) {
|
||||||
const { isAuthenticated, isEditMode } = useAdmin()
|
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||||
const config = ENTITIES[resource]
|
const config = ENTITIES[resource]
|
||||||
const { data } = useSiteData()
|
const { data, updateSite } = useSiteData()
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
const [created, setCreated] = useState<ItemWithId | null>(null)
|
||||||
|
|
||||||
if (!config || !isAuthenticated || !isEditMode) return null
|
if (!config || !isAuthenticated || !isEditMode) return null
|
||||||
const item = select
|
const item =
|
||||||
? select(data!)
|
created ??
|
||||||
: (data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined)
|
(select
|
||||||
if (!item) return null
|
? select(data!)
|
||||||
|
: (data?.[config.dataKey as keyof NonNullable<typeof data>] 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<ItemWithId>(config.resource, defaults)
|
||||||
|
// درج خوشبینانه در درخت داده — دفعه بعد از داده انتخاب میشود
|
||||||
|
updateSite((d) => {
|
||||||
|
const list = d[config.dataKey as keyof NonNullable<typeof d>] as unknown as ItemWithId[]
|
||||||
|
if (Array.isArray(list)) list.push(rec)
|
||||||
|
})
|
||||||
|
setCreated(rec)
|
||||||
|
setDrawerOpen(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className={className}>
|
<span className={className}>
|
||||||
<PillButton onClick={() => setDrawerOpen(true)} label={label}>
|
{item ? (
|
||||||
<PencilIcon className="h-3.5 w-3.5" />
|
<PillButton onClick={() => setDrawerOpen(true)} label={label}>
|
||||||
</PillButton>
|
<PencilIcon className="h-3.5 w-3.5" />
|
||||||
|
</PillButton>
|
||||||
|
) : (
|
||||||
|
<PillButton onClick={create} label={`ساخت — ${label}`}>
|
||||||
|
<PlusIcon className="h-3.5 w-3.5" />
|
||||||
|
</PillButton>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{drawerOpen && <EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />}
|
{drawerOpen && item && (
|
||||||
|
<EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ export const adminApi = {
|
|||||||
patch: <T = unknown>(resource: string, id: number, partial: Record<string, unknown>) =>
|
patch: <T = unknown>(resource: string, id: number, partial: Record<string, unknown>) =>
|
||||||
authFetch<T>(`/api/admin/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(partial) }),
|
authFetch<T>(`/api/admin/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(partial) }),
|
||||||
|
|
||||||
|
list: <T = unknown>(resource: string) => authFetch<T>(`/api/admin/${resource}`),
|
||||||
|
|
||||||
|
get: <T = unknown>(resource: string, id: number) =>
|
||||||
|
authFetch<T>(`/api/admin/${resource}/${id}`),
|
||||||
|
|
||||||
create: <T = unknown>(resource: string, data: Record<string, unknown>) =>
|
create: <T = unknown>(resource: string, data: Record<string, unknown>) =>
|
||||||
authFetch<T>(`/api/admin/${resource}`, { method: 'POST', body: JSON.stringify(data) }),
|
authFetch<T>(`/api/admin/${resource}`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
@@ -83,4 +88,10 @@ export const adminApi = {
|
|||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
|
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 }),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-1
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { Article, Project, Service } from './data/site'
|
import type { Article, Project, Service } from './data/site'
|
||||||
import { API_URL } from './config'
|
import { API_URL } from './config'
|
||||||
|
import { getToken } from './admin/adminClient'
|
||||||
|
|
||||||
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
||||||
export type BrandInfo = {
|
export type BrandInfo = {
|
||||||
@@ -183,7 +184,25 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
return res.json() as Promise<SiteData>
|
return res.json() as Promise<SiteData>
|
||||||
})
|
})
|
||||||
.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 (!cancelled) {
|
||||||
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
||||||
else setError(true)
|
else setError(true)
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
|||||||
resource="seo-meta"
|
resource="seo-meta"
|
||||||
label="عنوان و توضیح سئوی این صفحه"
|
label="عنوان و توضیح سئوی این صفحه"
|
||||||
select={(d) => d.seo_meta.find((s) => s.page_key === pageKey)}
|
select={(d) => d.seo_meta.find((s) => s.page_key === pageKey)}
|
||||||
|
// اگر ردیف سئوی این صفحه هنوز ساخته نشده باشد، همینجا ساخته میشود
|
||||||
|
createDefaults={() => ({ page_key: pageKey, title: '', description: '' })}
|
||||||
/>
|
/>
|
||||||
<ImageField label="تصویر پسزمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
|
<ImageField label="تصویر پسزمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export function Articles() {
|
|||||||
const heading = useSectionHeading('articles');
|
const heading = useSectionHeading('articles');
|
||||||
const save = useFieldSaver();
|
const save = useFieldSaver();
|
||||||
|
|
||||||
|
// پیشنویسها فقط در صفحه وبلاگ (برای ادمین) دیده میشوند، نه در خانه
|
||||||
|
const published = articles.filter((a) => a.status !== 'draft');
|
||||||
|
|
||||||
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
|
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
|
||||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||||
const h = d.section_headings.find((s) => s.id === heading.id);
|
const h = d.section_headings.find((s) => s.id === heading.id);
|
||||||
@@ -36,7 +39,7 @@ export function Articles() {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
||||||
{articles.slice(0, 3).map((article, i) =>
|
{published.slice(0, 3).map((article, i) =>
|
||||||
<Reveal key={article.id} delay={0.06 * i} className="h-full">
|
<Reveal key={article.id} delay={0.06 * i} className="h-full">
|
||||||
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
||||||
<ArticleCard article={article} />
|
<ArticleCard article={article} />
|
||||||
|
|||||||
@@ -29,9 +29,15 @@ export type Project = {
|
|||||||
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
|
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
|
||||||
export type ArticleBodyDoc = { type: 'doc'; content: unknown[] };
|
export type ArticleBodyDoc = { type: 'doc'; content: unknown[] };
|
||||||
|
|
||||||
|
export type ArticleStatus = 'draft' | 'published';
|
||||||
|
|
||||||
export type Article = {
|
export type Article = {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string;
|
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string;
|
||||||
/** سند TipTap — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
/** سند TipTap — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
||||||
body: ArticleBodyDoc | string[];
|
body: ArticleBodyDoc | string[];
|
||||||
|
/** پیشنویس فقط برای ادمین (از انتهای مدیریتی) میآید؛ بازدیدکننده نمیبیند */
|
||||||
|
status: ArticleStatus;
|
||||||
|
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
||||||
|
seo_keyphrase: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import { ButtonLink } from '../components/Button';
|
|||||||
import { ArticleCard } from '../components/ArticleCard';
|
import { ArticleCard } from '../components/ArticleCard';
|
||||||
import { Newsletter } from '../components/home/Newsletter';
|
import { Newsletter } from '../components/home/Newsletter';
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
import { setMetaDescription } from '../seo';
|
import { applySeo } from '../seo';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||||
import type { Article } from '../data/site';
|
import type { Article } from '../data/site';
|
||||||
@@ -34,11 +34,27 @@ export function ArticleDetail() {
|
|||||||
const { articles, brand } = useSite();
|
const { articles, brand } = useSite();
|
||||||
const article = articles.find((a) => a.slug === slug);
|
const article = articles.find((a) => a.slug === slug);
|
||||||
|
|
||||||
// عنوان تب و توضیح متا از خود مقاله
|
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!article) return;
|
if (!article) return;
|
||||||
document.title = `${article.title} | ${brand}`;
|
applySeo({
|
||||||
setMetaDescription(article.excerpt);
|
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]);
|
}, [article?.id]);
|
||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
@@ -55,7 +71,7 @@ export function ArticleDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { html, legacy } = renderBody(article);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -9,9 +9,46 @@ import { useSite } from '../api'
|
|||||||
import { EditableItem } from '../admin/Editable'
|
import { EditableItem } from '../admin/Editable'
|
||||||
import { PlusIcon } from 'lucide-react'
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
function DraftBadge({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`}
|
||||||
|
>
|
||||||
|
پیشنویس
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Blog() {
|
export function Blog() {
|
||||||
const { articles } = useSite()
|
const { articles } = useSite()
|
||||||
const [featured, ...rest] = articles
|
|
||||||
|
// حالت خالی — وبلاگ بدون مقاله نباید کرش کند
|
||||||
|
if (articles.length === 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero pageKey="blog" />
|
||||||
|
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
|
||||||
|
<div className="mx-auto max-w-site px-6 text-center lg:px-10">
|
||||||
|
<h2 className="text-3xl font-bold text-ivory">هنوز مقالهای منتشر نشده است</h2>
|
||||||
|
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||||
|
بهزودی نخستین نوشتههای ما اینجا منتشر میشود.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/admin/write"
|
||||||
|
data-admin-link
|
||||||
|
className="mt-8 inline-flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
نوشتن مقاله جدید
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<Newsletter />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [featured, ...rest] = articles
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -44,7 +81,12 @@ export function Blog() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center p-8 lg:p-12">
|
<div className="flex flex-col justify-center p-8 lg:p-12">
|
||||||
<p className="text-xs font-light text-copper">مقاله ویژه — {featured.category}</p>
|
<p className="text-xs font-light text-copper">
|
||||||
|
مقاله ویژه — {featured.category}
|
||||||
|
{featured.status === 'draft' && (
|
||||||
|
<DraftBadge className="mr-3 inline-block align-middle" />
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl">
|
<h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl">
|
||||||
{featured.title}
|
{featured.title}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -71,7 +113,12 @@ export function Blog() {
|
|||||||
{rest.map((article, i) => (
|
{rest.map((article, i) => (
|
||||||
<Reveal key={article.id} delay={0.05 * i} className="h-full">
|
<Reveal key={article.id} delay={0.05 * i} className="h-full">
|
||||||
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
||||||
<ArticleCard article={article} />
|
<div className="relative h-full">
|
||||||
|
{article.status === 'draft' && (
|
||||||
|
<DraftBadge className="absolute left-3 top-3 z-10" />
|
||||||
|
)}
|
||||||
|
<ArticleCard article={article} />
|
||||||
|
</div>
|
||||||
</EditableItem>
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
@@ -82,4 +129,4 @@ export function Blog() {
|
|||||||
<Newsletter />
|
<Newsletter />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ContactSection } from '../components/ContactSection'
|
|||||||
import { Lightbox } from '../components/Lightbox'
|
import { Lightbox } from '../components/Lightbox'
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSite, useStatusLabel } from '../api';
|
import { useSite, useStatusLabel } from '../api';
|
||||||
import { setMetaDescription } from '../seo';
|
import { applySeo } from '../seo';
|
||||||
|
|
||||||
export function ProjectDetail() {
|
export function ProjectDetail() {
|
||||||
const { slug } = useParams<{ slug: string }>()
|
const { slug } = useParams<{ slug: string }>()
|
||||||
@@ -17,11 +17,15 @@ export function ProjectDetail() {
|
|||||||
const statusLabel = useStatusLabel(project?.status ?? '')
|
const statusLabel = useStatusLabel(project?.status ?? '')
|
||||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||||
|
|
||||||
// عنوان تب و توضیح متا از خود پروژه
|
// عنوان تب، توضیح متا و OG از خود پروژه
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
document.title = `${project.title} | ${brand}`;
|
applySeo({
|
||||||
setMetaDescription(project.summary);
|
title: `${project.title} | ${brand}`,
|
||||||
|
description: project.summary,
|
||||||
|
path: `/projects/${project.slug}`,
|
||||||
|
image: project.image,
|
||||||
|
});
|
||||||
}, [project?.id]);
|
}, [project?.id]);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
|
|||||||
+75
-5
@@ -1,11 +1,11 @@
|
|||||||
// تنظیم عنوان صفحه و توضیح متا — محتوای سئو از دیتابیس میآید
|
// تگهای سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical و JSON-LD.
|
||||||
|
// محتوا از دیتابیس میآید؛ برای خزندههای بدون جاوااسکریپت، بکاند همان تگها را
|
||||||
|
// سمت سرور داخل index.html تزریق میکند (app/seo_pages.py).
|
||||||
|
|
||||||
export function setMetaDescription(content: string): void {
|
export function setMetaDescription(content: string): void {
|
||||||
|
// بدون توضیح از دیتابیس، پیشفرض استاتیک index.html دستنخورده میماند
|
||||||
|
if (!content.trim()) return
|
||||||
let el = document.querySelector<HTMLMetaElement>('meta[name="description"]')
|
let el = document.querySelector<HTMLMetaElement>('meta[name="description"]')
|
||||||
if (!content.trim()) {
|
|
||||||
el?.remove()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!el) {
|
if (!el) {
|
||||||
el = document.createElement('meta')
|
el = document.createElement('meta')
|
||||||
el.setAttribute('name', 'description')
|
el.setAttribute('name', 'description')
|
||||||
@@ -22,3 +22,73 @@ export function seoKeyForPath(pathname: string): string | null {
|
|||||||
// مسیرهای جزئی مثل /blog/{slug} شامل نمیشوند تا اثر خود صفحه جزئیات برسانند
|
// مسیرهای جزئی مثل /blog/{slug} شامل نمیشوند تا اثر خود صفحه جزئیات برسانند
|
||||||
return known.includes(seg) && pathname === `/${seg}` ? seg : null
|
return known.includes(seg) && pathname === `/${seg}` ? seg : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertMeta(attr: 'name' | 'property', key: string, content: string): void {
|
||||||
|
let el = document.querySelector<HTMLMetaElement>(`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<HTMLLinkElement>('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<string, unknown> | 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<string, unknown> | 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<HTMLMetaElement>('meta[property="og:image"]')?.remove()
|
||||||
|
}
|
||||||
|
setJsonLd(input.jsonLd ?? null)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user