فرانت‌اند: ویرایش زنده درجا — دوقلوی دیجیتال سایت برای ادمین

لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا):

- ورود ادمین از مسیر پنهان /admin/login با توکن JWT (localStorage) و
  AdminContext شامل isEditMode و وضعیت ذخیره (همگام/در حال ذخیره/خطا)
- AdminBar شناور پایین صفحه (فقط برای کاربر واردشده): کلید حالت ویرایش،
  وضعیت ذخیره، خروج — حالت ویرایش در sessionStorage می‌ماند
- InlineEdit: در حالت عادی دقیقاً همان HTML قبلی (بدون تغییر چیدمان)؛
  در حالت ویرایش کادرخط‌چین + contentEditable با ذخیره خودکار در blur
  (Enter ذخیره، Esc لغو) — وایرایش‌شده روی همه متن‌های سایت: هیرو،
  تیتر بخش‌ها، هیرو صفحات، داستان، فرم تماس، خبرنامه، فوتر، منو،
  برچسب‌های وضعیت پروژه و...
- کشوی عمومی EntityDrawer با پیکربندی متمرکز برای ۱۴ مجموعه (خدمات،
  پروژه‌ها، مقالات، نظرات، سوالات، تیم، تایم‌لاین، آمار، چرا آرامش،
  مدل‌های سرمایه‌گذاری، مراحل کار، دفاتر، گالری، منو): ویرایش چندفیلدی،
  افزودن، حذف و جابه‌جایی ترتیب (▲▼) با ذخیره خودکار هر فیلد
- دکمه‌های شناور ویرایش/حذف/ترتیب فقط در حالت ویرایش روی آیتم‌ها
- کشوهای یگانه: برند و لوگو، تنظیمات هیرو، فرم تماس، خبرنامه، فوتر،
  قبل/بعد و نقل‌قول داستان — آپلود تصویر به MinIO از همان‌جا
- به‌روزرسانی خوش‌بینانه SiteContext (updateSite) پس از هر PATCH؛
  مسیرها در حالت ویرایش کلیک نمی‌شوند؛ breadcrumb محافظ beforeunload
This commit is contained in:
MMD
2026-09-06 21:57:46 +03:30
parent 6e422ec3dc
commit 5ed082631e
39 changed files with 1882 additions and 260 deletions
+39 -10
View File
@@ -3,6 +3,9 @@ import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable'
type StoryProps = {
showLink?: boolean;
@@ -14,6 +17,19 @@ type StoryProps = {
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
const { story } = useSite()
const save = useFieldSaver()
const saveStory = (field: string, value: unknown) =>
save('story', story.id, { [field]: value }, (d) => {
;(d.story as unknown as Record<string, unknown>)[field] = value
})
const saveParagraph = (index: number, value: string) => {
const paragraphs = [...story.paragraphs]
paragraphs[index] = value
void saveStory('paragraphs', paragraphs)
}
return (
<section id="story" className="bg-ink py-24 lg:py-32" aria-labelledby="story-title">
<div className={`mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-2 lg:px-10 ${alignTop ? 'items-start' : 'items-center'}`}>
@@ -21,7 +37,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showLabel && (
<Reveal>
<h2 id="story-title" className="text-section font-bold text-ivory">
{story.title}
<InlineEdit value={story.title} onSave={(v) => saveStory('title', v)} />
</h2>
<div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal>
@@ -30,7 +46,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
<Reveal delay={0.08}>
{story.paragraphs.map((paragraph, i) => (
<p key={i} className={`text-base font-light leading-9 text-ivory/65 ${i === 0 ? 'mt-8' : 'mt-5'}`}>
{paragraph}
<InlineEdit multiline value={paragraph} onSave={(v) => saveParagraph(i, v)} />
</p>
))}
</Reveal>
@@ -38,12 +54,20 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
<Reveal delay={0.16}>
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
<div>
<p className="text-2xl font-bold text-copper">{story.stat1_value}</p>
<p className="mt-2 text-sm font-light text-ivory/55">{story.stat1_label}</p>
<p className="text-2xl font-bold text-copper">
<InlineEdit value={story.stat1_value} onSave={(v) => saveStory('stat1_value', v)} />
</p>
<p className="mt-2 text-sm font-light text-ivory/55">
<InlineEdit value={story.stat1_label} onSave={(v) => saveStory('stat1_label', v)} />
</p>
</div>
<div>
<p className="text-2xl font-bold text-copper">{story.stat2_value}</p>
<p className="mt-2 text-sm font-light text-ivory/55">{story.stat2_label}</p>
<p className="text-2xl font-bold text-copper">
<InlineEdit value={story.stat2_value} onSave={(v) => saveStory('stat2_value', v)} />
</p>
<p className="mt-2 text-sm font-light text-ivory/55">
<InlineEdit value={story.stat2_label} onSave={(v) => saveStory('stat2_label', v)} />
</p>
</div>
</div>
</Reveal>
@@ -51,8 +75,12 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showQuote && story.quote && (
<Reveal delay={0.2}>
<div className="mt-12 border-r-2 border-copper bg-char/40 p-6">
<p className="text-sm font-light leading-8 text-ivory/80">{story.quote}</p>
<p className="mt-5 text-sm font-bold text-copper">{story.quote_author}</p>
<p className="text-sm font-light leading-8 text-ivory/80">
<InlineEdit multiline value={story.quote} onSave={(v) => saveStory('quote', v)} />
</p>
<p className="mt-5 text-sm font-bold text-copper">
<InlineEdit value={story.quote_author} onSave={(v) => saveStory('quote_author', v)} />
</p>
</div>
</Reveal>
)}
@@ -60,7 +88,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showLink && (
<Reveal delay={0.22}>
<ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light">
{story.link_label}
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</Reveal>
@@ -73,7 +101,8 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="relative grid grid-cols-2 gap-4">
<SingletonPill resource="story" label="تصاویر و نقل‌قول بخش درباره ما" className="absolute -top-10 right-0" />
{story.bento.map((item, i) => (
<Reveal key={item.src + i} delay={0.06 * i}>
<div className="overflow-hidden">