- مقالهها ستون 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 سرگردان فرانت
191 lines
7.0 KiB
TypeScript
191 lines
7.0 KiB
TypeScript
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: 'همگامسازی شد',
|
|
saving: 'در حال ذخیره…',
|
|
error: 'خطا در ذخیره',
|
|
}
|
|
|
|
const STATUS_COLOR: Record<string, string> = {
|
|
idle: 'bg-emerald-400',
|
|
saving: 'bg-copper animate-pulse',
|
|
error: 'bg-red-400',
|
|
}
|
|
|
|
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 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}
|
|
/>
|
|
<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)} />}
|
|
</>
|
|
)
|
|
}
|