لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا): - ورود ادمین از مسیر پنهان /admin/login با توکن JWT (localStorage) و AdminContext شامل isEditMode و وضعیت ذخیره (همگام/در حال ذخیره/خطا) - AdminBar شناور پایین صفحه (فقط برای کاربر واردشده): کلید حالت ویرایش، وضعیت ذخیره، خروج — حالت ویرایش در sessionStorage میماند - InlineEdit: در حالت عادی دقیقاً همان HTML قبلی (بدون تغییر چیدمان)؛ در حالت ویرایش کادرخطچین + contentEditable با ذخیره خودکار در blur (Enter ذخیره، Esc لغو) — وایرایششده روی همه متنهای سایت: هیرو، تیتر بخشها، هیرو صفحات، داستان، فرم تماس، خبرنامه، فوتر، منو، برچسبهای وضعیت پروژه و... - کشوی عمومی EntityDrawer با پیکربندی متمرکز برای ۱۴ مجموعه (خدمات، پروژهها، مقالات، نظرات، سوالات، تیم، تایملاین، آمار، چرا آرامش، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، منو): ویرایش چندفیلدی، افزودن، حذف و جابهجایی ترتیب (▲▼) با ذخیره خودکار هر فیلد - دکمههای شناور ویرایش/حذف/ترتیب فقط در حالت ویرایش روی آیتمها - کشوهای یگانه: برند و لوگو، تنظیمات هیرو، فرم تماس، خبرنامه، فوتر، قبل/بعد و نقلقول داستان — آپلود تصویر به MinIO از همانجا - بهروزرسانی خوشبینانه SiteContext (updateSite) پس از هر PATCH؛ مسیرها در حالت ویرایش کلیک نمیشوند؛ breadcrumb محافظ beforeunload
71 lines
2.3 KiB
TypeScript
71 lines
2.3 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { useInView } from 'framer-motion';
|
|
import { useSite } from '../../api';
|
|
import { useFieldSaver } from '../../admin/AdminContext';
|
|
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
|
import { InlineEdit } from '../admin/InlineEdit';
|
|
|
|
function toFa(n: number) {
|
|
return n.toLocaleString('fa-IR');
|
|
}
|
|
|
|
function Counter({ value }: {value: number;}) {
|
|
const ref = useRef<HTMLSpanElement>(null);
|
|
const inView = useInView(ref, { once: true, margin: '-60px' });
|
|
const [display, setDisplay] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!inView) return;
|
|
let frame = 0;
|
|
const total = 60;
|
|
const id = window.setInterval(() => {
|
|
frame += 1;
|
|
const progress = 1 - Math.pow(1 - frame / total, 3);
|
|
setDisplay(Math.round(value * progress));
|
|
if (frame >= total) window.clearInterval(id);
|
|
}, 20);
|
|
return () => window.clearInterval(id);
|
|
}, [inView, value]);
|
|
|
|
return <span ref={ref}>{toFa(display)}</span>;
|
|
}
|
|
|
|
export function Stats() {
|
|
const { stats } = useSite();
|
|
const save = useFieldSaver();
|
|
|
|
return (
|
|
<section className="bg-char py-16" aria-label="آمار و ارقام">
|
|
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10">
|
|
{stats.map((stat) =>
|
|
<EditableItem key={stat.id} resource="stats" item={stat}>
|
|
<div className="text-center">
|
|
<p className="text-4xl font-bold text-copper md:text-5xl">
|
|
<Counter value={stat.value} />
|
|
<InlineEdit
|
|
value={stat.suffix}
|
|
onSave={(v) => save('stats', stat.id, { suffix: v }, (d) => {
|
|
const s = d.stats.find((x) => x.id === stat.id);
|
|
if (s) s.suffix = v;
|
|
})}
|
|
className="mr-1 text-2xl"
|
|
/>
|
|
</p>
|
|
<p className="mt-3 text-sm font-light text-ivory/55">
|
|
<InlineEdit
|
|
value={stat.label}
|
|
onSave={(v) => save('stats', stat.id, { label: v }, (d) => {
|
|
const s = d.stats.find((x) => x.id === stat.id);
|
|
if (s) s.label = v;
|
|
})}
|
|
/>
|
|
</p>
|
|
</div>
|
|
</EditableItem>
|
|
)}
|
|
<AddItemButton resource="stats" />
|
|
</div>
|
|
</section>);
|
|
|
|
}
|