وبلاگ و سئو: پیش‌نویس/انتشار مقاله + کلمه کلیدی سئو در دیتابیس + 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:
MMD
2026-09-07 04:26:18 +03:30
parent a29deb8bb1
commit b31124e1e4
31 changed files with 1202 additions and 152 deletions
+170 -44
View File
@@ -1,4 +1,8 @@
import { useState } from 'react'
import Swal from 'sweetalert2'
import { KeyRoundIcon, XIcon } from 'lucide-react'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
const STATUS_TEXT: Record<string, string> = {
idle: 'همگام‌سازی شد',
@@ -12,53 +16,175 @@ const STATUS_COLOR: Record<string, string> = {
error: 'bg-red-400',
}
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */
export function AdminBar() {
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
const SWAL_BASE = {
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10 rounded-none',
title: 'font-pinar font-bold text-lg text-ivory',
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
},
}
function ChangePasswordModal({ onClose }: { onClose: () => void }) {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
if (next.length < 8) {
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'رمز جدید کوتاه است', text: 'رمز جدید باید دست‌کم ۸ نویسه باشد.', confirmButtonText: 'باشه' })
return
}
if (next !== confirm) {
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'تکرار رمز مطابق نیست', text: 'رمز جدید و تکرار آن یکسان نیستند.', confirmButtonText: 'باشه' })
return
}
setBusy(true)
try {
await adminApi.changePassword(current, next)
void Swal.fire({ ...SWAL_BASE, icon: 'success', title: 'رمز عبور عوض شد', text: 'از این به بعد با رمز جدید وارد شوید.', iconColor: '#34d399', confirmButtonText: 'باشه' })
onClose()
} catch (error) {
void Swal.fire({
...SWAL_BASE,
icon: 'error',
title: 'تغییر رمز ناموفق بود',
text: error instanceof Error ? error.message : 'دوباره تلاش کنید',
confirmButtonText: 'باشه',
})
} finally {
setBusy(false)
}
}
const field =
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none'
return (
<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'
}`}
<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="تغییر رمز عبور">
<div dir="rtl" className="w-full max-w-sm border border-ivory/15 bg-ink p-8 shadow-2xl">
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
<KeyRoundIcon className="h-4 w-4" />
تغییر رمز عبور
</h2>
<button
type="button"
onClick={onClose}
aria-label="بستن"
className="text-ivory/50 transition-colors hover:text-ivory"
>
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-6 grid gap-4">
<input
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
placeholder="رمز عبور فعلی"
aria-label="رمز عبور فعلی"
autoComplete="current-password"
className={field}
/>
</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={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>
<input
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
placeholder="رمز جدید (دست‌کم ۸ نویسه)"
aria-label="رمز جدید"
autoComplete="new-password"
className={field}
/>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="تکرار رمز جدید"
aria-label="تکرار رمز جدید"
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>
)
}
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */
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)} />}
</>
)
}
+6 -2
View File
@@ -67,14 +67,18 @@ export function AdminProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (username: string, password: string) => {
await adminApi.login(username, password)
setIsAuthenticated(true)
}, [])
// داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس مقاله‌ها هم ادغام شوند
reload()
}, [reload])
const logout = useCallback(() => {
setToken(null)
setIsAuthenticated(false)
setIsEditMode(false)
sessionStorage.removeItem('aramesh_edit_mode')
}, [])
// داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس‌ها از درخت داده حذف شوند
reload()
}, [reload])
const runSave = useCallback(
async (action: () => Promise<void>): Promise<boolean> => {
+79 -32
View File
@@ -22,6 +22,8 @@ type Draft = {
read: string
excerpt: string
body: ArticleBodyDoc
status: 'draft' | 'published'
seo_keyphrase: string
}
const STATUS_TEXT: Record<string, string> = {
@@ -41,34 +43,24 @@ export function ArticleWritePage() {
const { data, updateSite } = useSiteData()
const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined
const [fetched, setFetched] = useState<Draft | null>(null)
const [draft, setDraft] = useState<Draft>(() =>
existing
? {
...existing,
body: (Array.isArray(existing.body) ? EMPTY_DOC : existing.body) as ArticleBodyDoc,
}
: {
id: 0,
slug: `article-${Date.now()}`,
title: '',
date: new Date().toLocaleDateString('fa-IR'),
category: 'یادداشت',
image: '',
read: '۵ دقیقه',
excerpt: '',
body: EMPTY_DOC,
},
)
const [createdId, setCreatedId] = useState<number | null>(existing?.id ?? null)
// مقاله تازه همیشه پیش‌نویس شروع می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود
const [draft, setDraft] = useState<Draft>({
id: 0,
slug: `article-${Date.now()}`,
title: '',
date: new Date().toLocaleDateString('fa-IR'),
category: 'یادداشت',
image: '',
read: '۵ دقیقه',
excerpt: '',
body: EMPTY_DOC,
status: 'draft',
seo_keyphrase: '',
})
const [createdId, setCreatedId] = useState<number | null>(null)
const [deleting, setDeleting] = useState(false)
// کلمه کلیدی هدف فقط راهنمای سئو است — در localStorage می‌ماند، نه در دیتابیس
const [keyphrase, setKeyphrase] = useState(() => localStorage.getItem(`seo-keyphrase-${id ?? 'new'}`) ?? '')
useEffect(() => {
localStorage.setItem(`seo-keyphrase-${id ?? 'new'}`, keyphrase)
}, [keyphrase, id])
const draftRef = useRef(draft)
draftRef.current = draft
const createdRef = useRef(createdId)
@@ -76,7 +68,35 @@ export function ArticleWritePage() {
const pendingRef = useRef<Record<string, unknown> | 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>) => {
if (createdRef.current) {
@@ -86,7 +106,7 @@ export function ArticleWritePage() {
if (a) Object.assign(a, changes)
})
} else {
// اولین ذخیره = انتشار: مقاله با همه فیلدهای فعلی ساخته می‌شود
// اولین ذخیره = ساخت پیش‌نویس: مقاله ساخته می‌شود اما تا «انتشار» در سایت عمومی دیده نمی‌شود
const payload = { ...draftRef.current, ...changes }
if (!payload.title) payload.title = 'بدون عنوان'
const created = await adminApi.create<Draft & { id: number }>('articles', payload)
@@ -133,6 +153,9 @@ export function ArticleWritePage() {
const forceSave = () => void runSave(flush)
// محافظ مسیر — پس از همه هوک‌ها تا ترتیب فراخوانی هوک‌ها همیشه یکسان بماند
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
const remove = async () => {
if (!createdId) return
const result = await Swal.fire({
@@ -193,8 +216,28 @@ export function ArticleWritePage() {
>
ذخیره
</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 && (
<>
<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
to={`/blog/${draft.slug}`}
data-admin-link
@@ -220,7 +263,11 @@ export function ArticleWritePage() {
<main className="mx-auto max-w-4xl px-6 pb-32 pt-10">
<p className="text-xs font-light text-copper">
{createdId ? 'ویرایش مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود' : 'نوشتن مقاله جدید — با اولین ذخیره منتشر می‌شود'}
{!createdId
? 'نوشتن مقاله جدید — به‌صورت پیش‌نویس ذخیره می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود'
: draft.status === 'draft'
? 'پیش‌نویس — تا انتشار در سایت عمومی دیده نمی‌شود؛ تغییرها ذخیره می‌شوند'
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}
</p>
{/* عنوان */}
@@ -260,7 +307,7 @@ export function ArticleWritePage() {
slug: draft.slug,
excerpt: draft.excerpt,
body: draft.body,
keyphrase,
keyphrase: draft.seo_keyphrase,
}}
/>
@@ -272,8 +319,8 @@ export function ArticleWritePage() {
<input
id="seo-keyphrase"
type="text"
value={keyphrase}
onChange={(e) => setKeyphrase(e.target.value)}
value={draft.seo_keyphrase}
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
</div>
+41 -11
View File
@@ -223,38 +223,68 @@ export function AddItemButton({
)
}
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش */
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش.
* اگر رکورد هنوز در دیتابیس ساخته نشده باشد و createDefaults داده شود، دکمه + آن را می‌سازد. */
export function SingletonPill({
resource,
label,
className = '',
select,
createDefaults,
}: {
resource: string
label: string
className?: string
/** انتخاب رکورد از درخت داده — برای مجموعه‌هایی که یک رکورد خاص اینجاست (مثل سئو) */
select?: (data: NonNullable<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 { data } = useSiteData()
const { data, updateSite } = useSiteData()
const [drawerOpen, setDrawerOpen] = useState(false)
const [created, setCreated] = useState<ItemWithId | null>(null)
if (!config || !isAuthenticated || !isEditMode) return null
const item = select
? select(data!)
: (data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined)
if (!item) return null
const item =
created ??
(select
? 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 (
<>
<span className={className}>
<PillButton onClick={() => setDrawerOpen(true)} label={label}>
<PencilIcon className="h-3.5 w-3.5" />
</PillButton>
{item ? (
<PillButton onClick={() => setDrawerOpen(true)} label={label}>
<PencilIcon className="h-3.5 w-3.5" />
</PillButton>
) : (
<PillButton onClick={create} label={`ساخت — ${label}`}>
<PlusIcon className="h-3.5 w-3.5" />
</PillButton>
)}
</span>
{drawerOpen && <EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />}
{drawerOpen && item && (
<EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />
)}
</>
)
}
+11
View File
@@ -69,6 +69,11 @@ export const adminApi = {
patch: <T = unknown>(resource: string, id: number, partial: Record<string, unknown>) =>
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>) =>
authFetch<T>(`/api/admin/${resource}`, { method: 'POST', body: JSON.stringify(data) }),
@@ -83,4 +88,10 @@ export const adminApi = {
fd.append('file', file)
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
},
changePassword: (currentPassword: string, newPassword: string) =>
authFetch<{ ok: boolean }>('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}),
}