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

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

- ورود ادمین از مسیر پنهان /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
+187
View File
@@ -0,0 +1,187 @@
import type { SiteData } from '../api'
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list'
export type FieldConfig = {
name: string
label: string
type: FieldType
hint?: string
}
export type EntityConfig = {
/** مسیر منبع در پنل مدیریت، مثل services یا why-us */
resource: string
/** کلید همان منبع در پاسخ GET /api/site */
dataKey: string
title: string
/** مجموعه یا رکورد یگانه */
kind: 'collection' | 'singleton'
/** فیلدی که عنوان آیتم را نشان می‌دهد */
titleKey: string
fields: FieldConfig[]
/** مقادیر پیش‌فرض برای «افزودن» */
defaults: Record<string, unknown>
}
const F = (name: string, label: string, type: FieldType = 'text', hint?: string): FieldConfig => ({
name,
label,
type,
hint,
})
/** پیکربندی همه منابع قابل ویرایش — هم مجموعه‌ها و هم رکوردهای یگانه */
export const ENTITIES: Record<string, EntityConfig> = {
services: {
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape')],
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
},
projects: {
resource: 'projects', dataKey: 'projects', title: 'پروژه‌ها', kind: 'collection',
titleKey: 'title',
fields: [
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('location', 'موقعیت'),
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'),
F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'),
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
],
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
},
articles: {
resource: 'articles', dataKey: 'articles', title: 'مقالات', kind: 'collection',
titleKey: 'title',
fields: [
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('date', 'تاریخ'), F('category', 'دسته'),
F('image', 'تصویر شاخص', 'image'), F('read', 'زمان مطالعه'), F('excerpt', 'چکیده', 'textarea'),
F('body', 'متن مقاله (هر پاراگراف یک خط)', 'list'),
],
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: [] },
},
testimonials: {
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
titleKey: 'name',
fields: [F('name', 'نام'), F('role', 'سمت'), F('quote', 'متن نظر', 'textarea')],
defaults: { name: 'نظر جدید', role: '', quote: '' },
},
faqs: {
resource: 'faqs', dataKey: 'faqs', title: 'سوالات متداول', kind: 'collection',
titleKey: 'question',
fields: [F('question', 'سوال'), F('answer', 'پاسخ', 'textarea')],
defaults: { question: 'سوال جدید', answer: '' },
},
team: {
resource: 'team', dataKey: 'team', title: 'تیم', kind: 'collection',
titleKey: 'name',
fields: [F('name', 'نام'), F('role', 'سمت'), F('bio', 'معرفی', 'textarea')],
defaults: { name: 'عضو جدید', role: '', bio: '' },
},
timeline: {
resource: 'timeline', dataKey: 'timeline', title: 'تایم‌لاین', kind: 'collection',
titleKey: 'title',
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image')],
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
},
stats: {
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
titleKey: 'label',
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number'), F('suffix', 'پسوند (مثل + یا هزار)')],
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
},
why_us: {
resource: 'why-us', dataKey: 'why_us', title: 'چرا آرامش', kind: 'collection',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
defaults: { title: 'مورد جدید', desc: '' },
},
investment_models: {
resource: 'investment-models', dataKey: 'investment_models', title: 'مدل‌های سرمایه‌گذاری', kind: 'collection',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('points', 'نکات (هر خط یک مورد)', 'list')],
defaults: { title: 'مدل جدید', desc: '', points: [] },
},
process_steps: {
resource: 'process-steps', dataKey: 'process_steps', title: 'مراحل فرآیند کار', kind: 'collection',
titleKey: 'title',
fields: [F('step', 'شماره مرحله'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
defaults: { step: '۰۱', title: 'مرحله جدید', desc: '' },
},
offices: {
resource: 'offices', dataKey: 'offices', title: 'دفاتر', kind: 'collection',
titleKey: 'city',
fields: [F('city', 'شهر'), F('address', 'نشانی', 'textarea'), F('hours', 'ساعات کاری')],
defaults: { city: 'شهر جدید', address: '', hours: '' },
},
gallery: {
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
titleKey: 'alt',
fields: [F('src', 'تصویر', 'image'), F('alt', 'متن جایگزین')],
defaults: { src: '', alt: 'تصویر گالری' },
},
nav: {
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
titleKey: 'label',
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
defaults: { label: 'آیتم جدید', href: '/' },
},
// ---------- رکوردهای یگانه ----------
brand: {
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
titleKey: 'name',
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), F('hero', 'تصویر هیرو', 'image'), F('contact_bg', 'پس‌زمینه بخش تماس', 'image'), F('phone', 'تلفن نمایشی'), F('phone_href', 'لینک تلفن'), F('email', 'ایمیل'), F('address', 'نشانی', 'textarea')],
defaults: {},
},
hero: {
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
titleKey: 'title',
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی'), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم'), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
defaults: {},
},
story: {
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
titleKey: 'title',
fields: [F('quote', 'نقل‌قول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقل‌قول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه')],
defaults: {},
},
before_after: {
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
defaults: {},
},
contact_section: {
resource: 'contact-section', dataKey: 'contact_section', title: 'فرم تماس', kind: 'singleton',
titleKey: 'title',
fields: [
F('label', 'برچسب بالا'), F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'),
F('address_label', 'برچسب نشانی'), F('phone_label', 'برچسب تلفن'), F('email_label', 'برچسب ایمیل'),
F('name_label', 'برچسب نام'), F('name_placeholder', 'جای‌نگهدار نام'),
F('phone_field_label', 'برچسب تلفن (فرم)'), F('phone_placeholder', 'جای‌نگهدار تلفن'),
F('note_label', 'برچسب توضیحات'), F('note_optional_label', 'برچسب اختیاری'), F('note_placeholder', 'جای‌نگهدار توضیحات'),
F('submit_label', 'متن دکمه ارسال'), F('sending_label', 'متن در حال ارسال'),
F('success_title', 'عنوان پیام موفقیت'), F('success_text', 'متن پیام موفقیت', 'textarea'), F('success_button_label', 'دکمه پیام موفقیت'),
],
defaults: {},
},
newsletter: {
resource: 'newsletter', dataKey: 'newsletter', title: 'خبرنامه', kind: 'singleton',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('placeholder', 'جای‌نگهدار ایمیل'), F('button_label', 'متن دکمه'), F('sending_label', 'متن در حال ارسال'), F('success_text', 'پیام موفقیت')],
defaults: {},
},
footer: {
resource: 'footer', dataKey: 'footer', title: 'فوتر', kind: 'singleton',
titleKey: 'pages_heading',
fields: [F('about_text', 'متن معرفی', 'textarea'), F('pages_heading', 'تیتر ستون صفحات'), F('services_heading', 'تیتر ستون خدمات'), F('contact_heading', 'تیتر ستون تماس'), F('copyright', 'متن کپی‌رایت'), F('tagline', 'شعار پایین')],
defaults: {},
},
}
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
return data[config.dataKey as keyof SiteData] as never
}