وبلاگ و سئو: پیش‌نویس/انتشار مقاله + کلمه کلیدی سئو در دیتابیس + 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
+30 -5
View File
@@ -1,8 +1,33 @@
# Magic Patterns - Vite Template
# فرانت‌اند گروه ساختمانی آرامش
This code was generated by [Magic Patterns](https://magicpatterns.com) for this design: [Source Design](https://www.magicpatterns.com/c/ka4i8ikygtakmdobhws6nu)
سایت React + Vite + Tailwind — تمام متن‌ها و تصاویر از بک‌اند (`GET /api/site`) خوانده می‌شود.
## Getting Started
## اجرا
1. Run `npm install`
2. Run `npm run dev`
```bash
npm install
npm run dev
```
در حالت توسعه، Vite درخواست‌های `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی می‌کند —
پس استک Docker باید در حال اجرا باشد. آدرس بک‌اند در `src/config.ts` از `VITE_API_URL`
خوانده می‌شود؛ پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ.
## ساخت
بیلد روی هاست انجام می‌شود (کانتینر فقط با Caddy فایل‌های `dist/` را سرو می‌کند):
```bash
npm run build
```
> نکته: بک‌اند برای تزریق متای سئو در صفحات `/blog` و `/projects` همین پوشه `dist/` را
> (در compose به `/srv/frontend` سوار است) می‌خواند — بعد از هر تغییر فرانت، `npm run build`
> و سپس ری‌استارت بک‌اند کافی است.
## بخش‌های کلیدی
- `src/api.tsx` — بارگذاری داده سایت و ادغام پیش‌نویس مقاله‌ها برای ادمین
- `src/admin/` — لایه ویرایش زنده (دوقلوی دیجیتال): نوار مدیریت، کشوی موجودیت‌ها، صفحه نوشتن مقاله
- `src/seo.ts` — تگ‌های title / description / OG / canonical / JSON-LD هر صفحه
- `src/data/site.ts` — انواع داده مشترک
+8
View File
@@ -5,6 +5,14 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>گروه ساختمانی آرامش</title>
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژه‌های ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایه‌گذاری ساخت‌وساز." />
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Organization","name":"گروه ساختمانی آرامش","inLanguage":"fa"}
</script>
</head>
<body>
<div id="root"></div>
+6 -3
View File
@@ -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
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 }),
}),
}
+20 -1
View File
@@ -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)
+2
View File
@@ -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>
+4 -1
View File
@@ -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} />
+6
View File
@@ -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;
};
-14
View File
@@ -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"
}
}
+21 -5
View File
@@ -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 (
<>
+51 -4
View File
@@ -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 -4
View File
@@ -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
View File
@@ -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)
}