لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا): - ورود ادمین از مسیر پنهان /admin/login با توکن JWT (localStorage) و AdminContext شامل isEditMode و وضعیت ذخیره (همگام/در حال ذخیره/خطا) - AdminBar شناور پایین صفحه (فقط برای کاربر واردشده): کلید حالت ویرایش، وضعیت ذخیره، خروج — حالت ویرایش در sessionStorage میماند - InlineEdit: در حالت عادی دقیقاً همان HTML قبلی (بدون تغییر چیدمان)؛ در حالت ویرایش کادرخطچین + contentEditable با ذخیره خودکار در blur (Enter ذخیره، Esc لغو) — وایرایششده روی همه متنهای سایت: هیرو، تیتر بخشها، هیرو صفحات، داستان، فرم تماس، خبرنامه، فوتر، منو، برچسبهای وضعیت پروژه و... - کشوی عمومی EntityDrawer با پیکربندی متمرکز برای ۱۴ مجموعه (خدمات، پروژهها، مقالات، نظرات، سوالات، تیم، تایملاین، آمار، چرا آرامش، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، منو): ویرایش چندفیلدی، افزودن، حذف و جابهجایی ترتیب (▲▼) با ذخیره خودکار هر فیلد - دکمههای شناور ویرایش/حذف/ترتیب فقط در حالت ویرایش روی آیتمها - کشوهای یگانه: برند و لوگو، تنظیمات هیرو، فرم تماس، خبرنامه، فوتر، قبل/بعد و نقلقول داستان — آپلود تصویر به MinIO از همانجا - بهروزرسانی خوشبینانه SiteContext (updateSite) پس از هر PATCH؛ مسیرها در حالت ویرایش کلیک نمیشوند؛ breadcrumb محافظ beforeunload
122 lines
4.7 KiB
TypeScript
122 lines
4.7 KiB
TypeScript
import React from 'react'
|
|
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;
|
|
showLabel?: boolean;
|
|
showQuote?: boolean;
|
|
alignTop?: boolean;
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
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'}`}>
|
|
<div>
|
|
{showLabel && (
|
|
<Reveal>
|
|
<h2 id="story-title" className="text-section font-bold text-ivory">
|
|
<InlineEdit value={story.title} onSave={(v) => saveStory('title', v)} />
|
|
</h2>
|
|
<div className="mt-6 h-px w-24 bg-copper/70" />
|
|
</Reveal>
|
|
)}
|
|
|
|
<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'}`}>
|
|
<InlineEdit multiline value={paragraph} onSave={(v) => saveParagraph(i, v)} />
|
|
</p>
|
|
))}
|
|
</Reveal>
|
|
|
|
<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">
|
|
<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">
|
|
<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>
|
|
|
|
{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">
|
|
<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>
|
|
)}
|
|
|
|
{showLink && (
|
|
<Reveal delay={0.22}>
|
|
<ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light">
|
|
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
|
<ArrowLeftIcon className="h-4 w-4" />
|
|
</ButtonLink>
|
|
</Reveal>
|
|
)}
|
|
|
|
{children && (
|
|
<Reveal delay={0.24}>
|
|
{children}
|
|
</Reveal>
|
|
)}
|
|
</div>
|
|
|
|
<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">
|
|
<img
|
|
src={item.src}
|
|
alt={item.alt}
|
|
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux hover:scale-105"
|
|
/>
|
|
</div>
|
|
</Reveal>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|