diff --git a/README.md b/README.md index 4dcd4cf..55af1d0 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,15 @@ npm run dev (پاراگراف، تیتر، لیست، نقل‌قول، تصویر، لینک) تولید می‌شوند. مقالات قدیمی (لیست پاراگراف) در اولین اجرا به‌صورت خودکار به این فرمت تبدیل می‌شوند. +## سئو + +- **بررسی سئو در صفحه نوشتن**: فهرست سبز/نارنجی/قرمز به سبک یوست ولی سازگار با فارسی — + طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیترها)، طول چکیده، تعداد واژه، + تیتربندی H2، متن جایگزین تصاویر، پیوندها و نشانی (slug) +- **سئوی هر صفحه**: در حالت ویرایش، دکمه سئو روی هیرو هر صفحه (و کنار هیرو صفحه اصلی) — + عنوان تب و توضیح متای هر صفحه از دیتابیس خوانده و اعمال می‌شود +- صفحه مقاله و پروژه به‌طور خودکار عنوان و توضیح متا را از محتوای خودشان می‌گیرند + ## نکات امنیتی - فایل `.env` هرگز commit نمی‌شود (در `.gitignore` است) diff --git a/backend/README.md b/backend/README.md index d51a165..05538bc 100644 --- a/backend/README.md +++ b/backend/README.md @@ -138,6 +138,8 @@ Authorization: Bearer در startup/seed خودکار به سند تبدیل می‌شوند - seed هم بدنه‌های لیستی را قبل از درج تبدیل می‌کند +| POST/PATCH | `/api/admin/seo-meta` | عنوان و توضیح متای صفحات — `page_key`: home / about / services / projects / investment / blog / faq / contact | + منابعی که `reorder` دارند (ستون `position`): `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `investment-models`، `process-steps`، `offices`، `gallery`، `nav`. ستون با مهاجرت سبک idempotent هنگام startup/seed به دیتابیس‌های موجود اضافه می‌شود. ### تصاویر و MinIO diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 91e2a1c..ab539c3 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -63,6 +63,9 @@ from ..schemas import ( ProjectOut, ProjectPatch, ReorderIn, + SeoMetaIn, + SeoMetaOut, + SeoMetaPatch, SectionHeadingIn, SectionHeadingOut, SectionHeadingPatch, @@ -228,6 +231,7 @@ register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch) register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch) register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch) +register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch) # آپلود تصویر به Object Storage (MinIO) — URL نسبی برمی‌گرداند تا بک‌اند ذخیره‌سازی قابل تعویض باشد diff --git a/backend/app/api/public.py b/backend/app/api/public.py index fb45794..3ff7e3f 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -132,4 +132,5 @@ def get_site(db: Session = Depends(get_db)): "contact_section": db.query(models.ContactSectionContent).first(), "newsletter": db.query(models.NewsletterSectionContent).first(), "footer": db.query(models.FooterContent).first(), + "seo_meta": db.query(models.SeoMeta).order_by(models.SeoMeta.id).all(), } diff --git a/backend/app/models.py b/backend/app/models.py index ea25788..743816d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -340,6 +340,17 @@ class FooterContent(Base): socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}] +class SeoMeta(Base): + """عنوان و توضیح سئوی هر صفحه — page_key: home / about / services / ...""" + + __tablename__ = "seo_meta" + + id: Mapped[int] = mapped_column(primary_key=True) + page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) + title: Mapped[str] = mapped_column(String(256), default="") + description: Mapped[str] = mapped_column(Text, default="") + + class ContactMessage(Base): __tablename__ = "contact_messages" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b0891ba..97d09b3 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -243,6 +243,13 @@ class FooterOut(ORMModel): socials: list[dict] +class SeoMetaOut(ORMModel): + id: int + page_key: str + title: str + description: str + + class ContactMessageOut(ORMModel): id: int name: str @@ -504,6 +511,18 @@ class FooterIn(BaseModel): socials: list[dict] = [] +class SeoMetaIn(BaseModel): + page_key: str = Field(min_length=1, max_length=32) + title: str = "" + description: str = "" + + +class SeoMetaPatch(BaseModel): + page_key: Optional[str] = None + title: Optional[str] = None + description: Optional[str] = None + + class MessageReadIn(BaseModel): is_read: bool = True diff --git a/backend/app/seed.py b/backend/app/seed.py index 618ddfd..07b091e 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -43,6 +43,7 @@ CONTENT: list[tuple[Any, list[dict], str]] = [ (models.TimelineItem, seed_data.TIMELINE, "تایم‌لاین"), (models.WhyUsItem, seed_data.WHY, "چرا آرامش"), (models.BeforeAfter, [seed_data.BEFORE_AFTER], "قبل/بعد"), + (models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"), ] diff --git a/backend/app/seed_data.py b/backend/app/seed_data.py index 4e739cd..803bf8d 100644 --- a/backend/app/seed_data.py +++ b/backend/app/seed_data.py @@ -451,3 +451,8 @@ FOOTER = { {"label": "تلگرام", "href": "#", "icon": "telegram"}, ], } + +SEO_META = [ + {"page_key": key, "title": "", "description": ""} + for key in ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"] +] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8cb9a24..d800128 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +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 { AdminProvider, useAdmin } from './admin/AdminContext'; import { AdminBar } from './admin/AdminBar'; import { AdminLoginPage } from './admin/AdminLoginPage'; @@ -64,8 +65,21 @@ function AnimatedRoutes() { function AppChrome() { const { pathname } = useLocation(); const { isAuthenticated } = useAdmin(); + const { data } = useSiteData(); const isAdminPage = pathname.startsWith('/admin'); + // عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحه‌های جزئیات که خودشان ست می‌کنند + useEffect(() => { + if (isAdminPage || !data) return; + const key = seoKeyForPath(pathname); + if (!key) return; + const meta = data.seo_meta?.find((s) => s.page_key === key); + const brand = data.brand.name; + const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label; + document.title = meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand); + setMetaDescription(meta?.description ?? ''); + }, [pathname, data, isAdminPage]); + return ( <> {!isAdminPage &&
} @@ -80,10 +94,6 @@ function AppChrome() { function AppShell() { const { data, error, reload } = useSiteData(); - useEffect(() => { - if (data?.brand.name) document.title = data.brand.name; - }, [data?.brand.name]); - return (
diff --git a/frontend/src/admin/ArticleWritePage.tsx b/frontend/src/admin/ArticleWritePage.tsx index 522b860..f514bac 100644 --- a/frontend/src/admin/ArticleWritePage.tsx +++ b/frontend/src/admin/ArticleWritePage.tsx @@ -6,6 +6,7 @@ import { useSiteData } from '../api' import { useAdmin } from './AdminContext' import { adminApi } from './adminClient' import { ArticleEditor } from '../components/admin/ArticleEditor' +import { SeoChecklist } from '../components/admin/SeoChecklist' import { ImageField } from '../components/admin/ImageField' import type { ArticleBodyDoc } from '../data/site' @@ -61,6 +62,12 @@ export function ArticleWritePage() { ) const [createdId, setCreatedId] = useState(existing?.id ?? 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 @@ -247,9 +254,29 @@ export function ArticleWritePage() {
{/* چکیده و متادیتا */} + +

جزئیات انتشار

+
+ + setKeyphrase(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" + /> +