وبلاگ و سئو: پیشنویس/انتشار مقاله + کلمه کلیدی سئو در دیتابیس + 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:
@@ -16,7 +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 { applySeo, seoKeyForPath } from './seo';
|
||||
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
||||
import { AdminBar } from './admin/AdminBar';
|
||||
import { AdminLoginPage } from './admin/AdminLoginPage';
|
||||
@@ -76,8 +76,11 @@ function AppChrome() {
|
||||
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 ?? '');
|
||||
applySeo({
|
||||
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
||||
description: meta?.description ?? '',
|
||||
path: pathname,
|
||||
});
|
||||
}, [pathname, data, isAdminPage]);
|
||||
|
||||
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 { 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)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
}
|
||||
|
||||
+20
-1
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Article, Project, Service } from './data/site'
|
||||
import { API_URL } from './config'
|
||||
import { getToken } from './admin/adminClient'
|
||||
|
||||
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
||||
export type BrandInfo = {
|
||||
@@ -183,7 +184,25 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
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 (json.brand && json.hero && json.footer && json.nav) setData(json)
|
||||
else setError(true)
|
||||
|
||||
@@ -78,6 +78,8 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی این صفحه"
|
||||
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)} />
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,9 @@ export function Articles() {
|
||||
const heading = useSectionHeading('articles');
|
||||
const save = useFieldSaver();
|
||||
|
||||
// پیشنویسها فقط در صفحه وبلاگ (برای ادمین) دیده میشوند، نه در خانه
|
||||
const published = articles.filter((a) => a.status !== 'draft');
|
||||
|
||||
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
|
||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id);
|
||||
@@ -36,7 +39,7 @@ export function Articles() {
|
||||
</Reveal>
|
||||
|
||||
<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">
|
||||
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
||||
<ArticleCard article={article} />
|
||||
|
||||
@@ -29,9 +29,15 @@ export type Project = {
|
||||
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
|
||||
export type ArticleBodyDoc = { type: 'doc'; content: unknown[] };
|
||||
|
||||
export type ArticleStatus = 'draft' | 'published';
|
||||
|
||||
export type Article = {
|
||||
id: number;
|
||||
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string;
|
||||
/** سند TipTap — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
||||
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 { 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) {
|
||||
|
||||
+75
-5
@@ -1,11 +1,11 @@
|
||||
// تنظیم عنوان صفحه و توضیح متا — محتوای سئو از دیتابیس میآید
|
||||
// تگهای سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical و JSON-LD.
|
||||
// محتوا از دیتابیس میآید؛ برای خزندههای بدون جاوااسکریپت، بکاند همان تگها را
|
||||
// سمت سرور داخل index.html تزریق میکند (app/seo_pages.py).
|
||||
|
||||
export function setMetaDescription(content: string): void {
|
||||
// بدون توضیح از دیتابیس، پیشفرض استاتیک index.html دستنخورده میماند
|
||||
if (!content.trim()) return
|
||||
let el = document.querySelector<HTMLMetaElement>('meta[name="description"]')
|
||||
if (!content.trim()) {
|
||||
el?.remove()
|
||||
return
|
||||
}
|
||||
if (!el) {
|
||||
el = document.createElement('meta')
|
||||
el.setAttribute('name', 'description')
|
||||
@@ -22,3 +22,73 @@ export function seoKeyForPath(pathname: string): string | null {
|
||||
// مسیرهای جزئی مثل /blog/{slug} شامل نمیشوند تا اثر خود صفحه جزئیات برسانند
|
||||
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