وبلاگ و سئو: پیشنویس/انتشار مقاله + کلمه کلیدی سئو در دیتابیس + 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:
@@ -7,7 +7,7 @@ import { ButtonLink } from '../components/Button';
|
||||
import { ArticleCard } from '../components/ArticleCard';
|
||||
import { Newsletter } from '../components/home/Newsletter';
|
||||
import { useSite } from '../api';
|
||||
import { setMetaDescription } from '../seo';
|
||||
import { applySeo } from '../seo';
|
||||
import { useEffect } from 'react';
|
||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||
import type { Article } from '../data/site';
|
||||
@@ -34,11 +34,27 @@ export function ArticleDetail() {
|
||||
const { articles, brand } = useSite();
|
||||
const article = articles.find((a) => a.slug === slug);
|
||||
|
||||
// عنوان تب و توضیح متا از خود مقاله
|
||||
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
||||
useEffect(() => {
|
||||
if (!article) return;
|
||||
document.title = `${article.title} | ${brand}`;
|
||||
setMetaDescription(article.excerpt);
|
||||
applySeo({
|
||||
title: `${article.title} | ${brand}`,
|
||||
description: article.excerpt,
|
||||
path: `/blog/${article.slug}`,
|
||||
image: article.image,
|
||||
type: 'article',
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article.title,
|
||||
description: article.excerpt,
|
||||
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
||||
inLanguage: 'fa',
|
||||
author: { '@type': 'Organization', name: brand },
|
||||
publisher: { '@type': 'Organization', name: brand },
|
||||
mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href,
|
||||
},
|
||||
});
|
||||
}, [article?.id]);
|
||||
|
||||
if (!article) {
|
||||
@@ -55,7 +71,7 @@ export function ArticleDetail() {
|
||||
}
|
||||
|
||||
const { html, legacy } = renderBody(article);
|
||||
const related = articles.filter((a) => a.slug !== article.slug).slice(0, 3);
|
||||
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -9,9 +9,46 @@ import { useSite } from '../api'
|
||||
import { EditableItem } from '../admin/Editable'
|
||||
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() {
|
||||
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 (
|
||||
<>
|
||||
@@ -44,7 +81,12 @@ export function Blog() {
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
{featured.title}
|
||||
</h2>
|
||||
@@ -71,7 +113,12 @@ export function Blog() {
|
||||
{rest.map((article, i) => (
|
||||
<Reveal key={article.id} delay={0.05 * i} className="h-full">
|
||||
<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>
|
||||
</Reveal>
|
||||
))}
|
||||
@@ -82,4 +129,4 @@ export function Blog() {
|
||||
<Newsletter />
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ContactSection } from '../components/ContactSection'
|
||||
import { Lightbox } from '../components/Lightbox'
|
||||
import { useEffect } from 'react';
|
||||
import { useSite, useStatusLabel } from '../api';
|
||||
import { setMetaDescription } from '../seo';
|
||||
import { applySeo } from '../seo';
|
||||
|
||||
export function ProjectDetail() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
@@ -17,11 +17,15 @@ export function ProjectDetail() {
|
||||
const statusLabel = useStatusLabel(project?.status ?? '')
|
||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||
|
||||
// عنوان تب و توضیح متا از خود پروژه
|
||||
// عنوان تب، توضیح متا و OG از خود پروژه
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
document.title = `${project.title} | ${brand}`;
|
||||
setMetaDescription(project.summary);
|
||||
applySeo({
|
||||
title: `${project.title} | ${brand}`,
|
||||
description: project.summary,
|
||||
path: `/projects/${project.slug}`,
|
||||
image: project.image,
|
||||
});
|
||||
}, [project?.id]);
|
||||
|
||||
if (!project) {
|
||||
|
||||
Reference in New Issue
Block a user