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

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

- ورود ادمین از مسیر پنهان /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
+2 -4
View File
@@ -6,13 +6,11 @@ import { Timeline } from '../components/home/Timeline'
import { WhyUs } from '../components/home/WhyUs'
import { Team } from '../components/home/Team'
import { ContactSection } from '../components/ContactSection'
import { usePageHero } from '../api'
export function About() {
const hero = usePageHero('about')
return (
return (
<>
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
<PageHero pageKey="about" />
<Story showLink={false} showLabel={false} showQuote={true} alignTop={true} />
+11 -6
View File
@@ -5,20 +5,21 @@ import { PageHero } from '../components/PageHero'
import { Reveal } from '../components/Reveal'
import { ArticleCard } from '../components/ArticleCard'
import { Newsletter } from '../components/home/Newsletter'
import { usePageHero, useSite } from '../api'
import { useSite } from '../api'
import { AddItemButton, EditableItem } from '../admin/Editable'
export function Blog() {
const { articles } = useSite()
const hero = usePageHero('blog')
const [featured, ...rest] = articles
const [featured, ...rest] = articles
return (
<>
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
<PageHero pageKey="blog" />
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
<div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal>
<EditableItem resource="articles" item={featured}>
<Link
to={`/blog/${featured.slug}`}
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
@@ -52,14 +53,18 @@ export function Blog() {
</div>
</div>
</Link>
</EditableItem>
</Reveal>
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{rest.map((article, i) => (
<Reveal key={article.slug} delay={0.05 * i} className="h-full">
<ArticleCard article={article} />
<Reveal key={article.id} delay={0.05 * i} className="h-full">
<EditableItem resource="articles" item={article} className="h-full">
<ArticleCard article={article} />
</EditableItem>
</Reveal>
))}
<AddItemButton resource="articles" />
</div>
</div>
</section>
+7 -4
View File
@@ -2,20 +2,21 @@ import React from 'react'
import { PageHero } from '../components/PageHero'
import { ContactSection } from '../components/ContactSection'
import { Reveal } from '../components/Reveal'
import { usePageHero, useSite } from '../api'
import { useSite } from '../api'
import { AddItemButton, EditableItem } from '../admin/Editable'
export function ContactPage() {
const { offices } = useSite()
const hero = usePageHero('contact')
return (
<>
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
<PageHero pageKey="contact" />
<section className="bg-ink py-24" aria-label="دفاتر ما">
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
{offices.map((office, i) => (
<Reveal key={office.id} delay={0.06 * i} className="h-full">
<EditableItem resource="offices" item={office} className="h-full">
<article className="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
@@ -25,8 +26,10 @@ export function ContactPage() {
</p>
</div>
</article>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="offices" />
</div>
</section>
+2 -4
View File
@@ -2,13 +2,11 @@ import React from 'react'
import { PageHero } from '../components/PageHero'
import { Faq } from '../components/home/Faq'
import { ContactSection } from '../components/ContactSection'
import { usePageHero } from '../api'
export function FaqPage() {
const hero = usePageHero('faq')
return (
return (
<>
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
<PageHero pageKey="faq" />
<Faq hideHeading={true} />
+7 -4
View File
@@ -4,16 +4,16 @@ import { Reveal } from '../components/Reveal'
import { SectionHeading } from '../components/SectionHeading'
import { Stats } from '../components/home/Stats'
import { ContactSection } from '../components/ContactSection'
import { usePageHero, useSectionHeading, useSite } from '../api'
import { useSectionHeading, useSite } from '../api'
import { AddItemButton, EditableItem } from '../admin/Editable'
export function Investment() {
const { investment_models } = useSite()
const hero = usePageHero('investment')
const heading = useSectionHeading('investment_models')
const heading = useSectionHeading('investment_models')
return (
<>
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
<PageHero pageKey="investment" />
<section className="bg-ink py-24 lg:py-32" aria-labelledby="models-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
@@ -21,6 +21,7 @@ export function Investment() {
<div className="mt-14 grid gap-6 lg:grid-cols-3">
{investment_models.map((model, i) => (
<Reveal key={model.id} delay={0.06 * i} className="h-full">
<EditableItem resource="investment_models" item={model} className="h-full">
<article className="group flex h-full flex-col border border-ivory/10 bg-char p-10 transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<p className="text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p>
@@ -41,8 +42,10 @@ export function Investment() {
</ul>
</div>
</article>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="investment_models" />
</div>
</div>
</section>
+26 -7
View File
@@ -5,11 +5,24 @@ import { ProjectCard } from '../components/ProjectCard';
import { Gallery } from '../components/home/Gallery';
import { ContactSection } from '../components/ContactSection';
import type { ProjectStatus } from '../data/site';
import { usePageHero, useSite, useStatusLabel } from '../api';
import { useSite, useStatusLabel } from '../api';
import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from '../components/admin/InlineEdit';
import { AddItemButton, EditableItem } from '../admin/Editable';
export function Projects() {
const { projects } = useSite();
const hero = usePageHero('projects');
const { projects, status_labels } = useSite();
const save = useFieldSaver();
const saveStatusLabel = (key: string, value: string) => {
const label = status_labels.find((s) => s.key === key);
if (!label) return;
void save('status-labels', label.id, { label: value }, (d) => {
const s = d.status_labels.find((x) => x.id === label.id);
if (s) s.label = value;
});
};
const doneLabel = useStatusLabel('done');
const progressLabel = useStatusLabel('progress');
const FILTERS: {key: ProjectStatus;label: string;}[] = [
@@ -20,7 +33,7 @@ export function Projects() {
return (
<>
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
<PageHero pageKey="projects" />
<section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژه‌ها">
@@ -46,7 +59,10 @@ export function Projects() {
}
<span className="relative z-10 transition-colors duration-300 ease-lux group-hover:text-char">
{filter.label}
<InlineEdit
value={filter.label}
onSave={(v) => saveStatusLabel(filter.key, v)}
/>
</span>
</button>
)}
@@ -54,10 +70,13 @@ export function Projects() {
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{list.map((project, i) =>
<Reveal key={project.slug} delay={0.05 * i} className="h-full">
<ProjectCard project={project} imageClass="h-80" />
<Reveal key={project.id} delay={0.05 * i} className="h-full">
<EditableItem resource="projects" item={project} className="h-full">
<ProjectCard project={project} imageClass="h-80" />
</EditableItem>
</Reveal>
)}
<AddItemButton resource="projects" />
</div>
</div>
</section>
+7 -4
View File
@@ -5,16 +5,16 @@ import { Faq } from '../components/home/Faq'
import { ContactSection } from '../components/ContactSection'
import { Reveal } from '../components/Reveal'
import { SectionHeading } from '../components/SectionHeading'
import { usePageHero, useSectionHeading, useSite } from '../api'
import { useSectionHeading, useSite } from '../api'
import { AddItemButton, EditableItem } from '../admin/Editable'
export function ServicesPage() {
const { process_steps } = useSite()
const hero = usePageHero('services')
const heading = useSectionHeading('process')
const heading = useSectionHeading('process')
return (
<>
<PageHero title={hero.title} lead={hero.lead} image={hero.image} />
<PageHero pageKey="services" />
<Services />
@@ -24,13 +24,16 @@ export function ServicesPage() {
<div className="mt-14 grid gap-px bg-ivory/10 md:grid-cols-2 lg:grid-cols-4">
{process_steps.map((item, i) => (
<Reveal key={item.id} delay={0.06 * i} className="h-full">
<EditableItem resource="process_steps" item={item} className="h-full">
<div className="flex h-full flex-col justify-center bg-char px-6 py-14 text-center">
<p className="text-4xl font-bold text-copper">{item.step}</p>
<h3 className="mt-6 text-xl font-bold text-ivory">{item.title}</h3>
<p className="mt-4 text-base font-light leading-9 text-ivory/60">{item.desc}</p>
</div>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="process_steps" />
</div>
</div>
</section>