فرانتاند: ویرایش زنده درجا — دوقلوی دیجیتال سایت برای ادمین
لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا): - ورود ادمین از مسیر پنهان /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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user