سئو: بررسی سئو در صفحه نوشتن + عنوان و توضیح متای هر صفحه

- پنل «بررسی سئو» در صفحه نوشتن مقاله — سبز/نارنجی/قرمز سازگار با فارسی:
  طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیتر H2)، طول چکیده،
  تعداد واژه، تیتربندی، متن جایگزین تصاویر، پیوندها و slug.
  کلمه کلیدی در localStorage نگه داشته می‌شود (راهنمای ویرایشگر است، نه محتوا)
- جدول seo_meta: عنوان و توضیح متای هر صفحه (page_key برای هر ۸ صفحه،
  seed خودکار، CRUD در /api/admin/seo-meta، خواندن در GET /api/site)
- تزریق عنوان/توضیح متا در فرانت‌اند: صفحه‌های فهرست از دیتابیس؛ صفحه
  مقاله و پروژه از محتوای خودشان (بدون تداخل با اثر سطح والد)
- دکمه سئو روی هیرو هر صفحه و کنار هیرو صفحه اصلی (فقط حالت ویرایش)
This commit is contained in:
MMD
2026-09-07 03:12:12 +03:30
parent 17345daa81
commit a29deb8bb1
19 changed files with 380 additions and 15 deletions
+27
View File
@@ -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<number | null>(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() {
</div>
{/* چکیده و متادیتا */}
<SeoChecklist
input={{
title: draft.title,
slug: draft.slug,
excerpt: draft.excerpt,
body: draft.body,
keyphrase,
}}
/>
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
<h2 className="text-sm font-bold text-copper">جزئیات انتشار</h2>
<div className="mt-5 grid gap-5">
<div>
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمی‌شود)</label>
<input
id="seo-keyphrase"
type="text"
value={keyphrase}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
<textarea
+6 -1
View File
@@ -228,10 +228,13 @@ export function SingletonPill({
resource,
label,
className = '',
select,
}: {
resource: string
label: string
className?: string
/** انتخاب رکورد از درخت داده — برای مجموعه‌هایی که یک رکورد خاص اینجاست (مثل سئو) */
select?: (data: NonNullable<ReturnType<typeof useSiteData>['data']>) => ItemWithId | undefined
}) {
const { isAuthenticated, isEditMode } = useAdmin()
const config = ENTITIES[resource]
@@ -239,7 +242,9 @@ export function SingletonPill({
const [drawerOpen, setDrawerOpen] = useState(false)
if (!config || !isAuthenticated || !isEditMode) return null
const item = data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined
const item = select
? select(data!)
: (data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined)
if (!item) return null
return (
+11
View File
@@ -133,6 +133,17 @@ export const ENTITIES: Record<string, EntityConfig> = {
defaults: { label: 'آیتم جدید', href: '/' },
},
seo_meta: {
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
titleKey: 'page_key',
fields: [
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact'),
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
],
defaults: { page_key: '', title: '', description: '' },
},
// ---------- رکوردهای یگانه ----------
brand: {
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',