لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا): - ورود ادمین از مسیر پنهان /admin/login با توکن JWT (localStorage) و AdminContext شامل isEditMode و وضعیت ذخیره (همگام/در حال ذخیره/خطا) - AdminBar شناور پایین صفحه (فقط برای کاربر واردشده): کلید حالت ویرایش، وضعیت ذخیره، خروج — حالت ویرایش در sessionStorage میماند - InlineEdit: در حالت عادی دقیقاً همان HTML قبلی (بدون تغییر چیدمان)؛ در حالت ویرایش کادرخطچین + contentEditable با ذخیره خودکار در blur (Enter ذخیره، Esc لغو) — وایرایششده روی همه متنهای سایت: هیرو، تیتر بخشها، هیرو صفحات، داستان، فرم تماس، خبرنامه، فوتر، منو، برچسبهای وضعیت پروژه و... - کشوی عمومی EntityDrawer با پیکربندی متمرکز برای ۱۴ مجموعه (خدمات، پروژهها، مقالات، نظرات، سوالات، تیم، تایملاین، آمار، چرا آرامش، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، منو): ویرایش چندفیلدی، افزودن، حذف و جابهجایی ترتیب (▲▼) با ذخیره خودکار هر فیلد - دکمههای شناور ویرایش/حذف/ترتیب فقط در حالت ویرایش روی آیتمها - کشوهای یگانه: برند و لوگو، تنظیمات هیرو، فرم تماس، خبرنامه، فوتر، قبل/بعد و نقلقول داستان — آپلود تصویر به MinIO از همانجا - بهروزرسانی خوشبینانه SiteContext (updateSite) پس از هر PATCH؛ مسیرها در حالت ویرایش کلیک نمیشوند؛ breadcrumb محافظ beforeunload
124 lines
4.9 KiB
TypeScript
124 lines
4.9 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react'
|
|
import { AnimatePresence, motion } from 'framer-motion'
|
|
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
|
import { Reveal } from '../Reveal'
|
|
import { SectionHeading } from '../SectionHeading'
|
|
import { useSite } from '../../api'
|
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
|
|
|
export function Gallery() {
|
|
const { gallery } = useSite()
|
|
const [active, setActive] = useState<number | null>(null)
|
|
|
|
const close = useCallback(() => setActive(null), [])
|
|
const step = useCallback((dir: number) => {
|
|
setActive((prev) => (prev === null ? prev : (prev + dir + gallery.length) % gallery.length))
|
|
}, [gallery.length])
|
|
|
|
useEffect(() => {
|
|
if (active === null) return
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') close()
|
|
if (e.key === 'ArrowRight') step(-1)
|
|
if (e.key === 'ArrowLeft') step(1)
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [active, close, step])
|
|
|
|
return (
|
|
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title">
|
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
|
<SectionHeading
|
|
title="گالری تصاویر"
|
|
lead="قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."
|
|
headingKey="gallery"
|
|
align="center"
|
|
/>
|
|
|
|
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
|
|
{gallery.map((image, i) => (
|
|
<Reveal key={image.id} delay={0.04 * i}>
|
|
<EditableItem resource="gallery" item={image}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActive(i)}
|
|
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
|
aria-label={`بزرگنمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
|
>
|
|
<img
|
|
src={image.src}
|
|
alt={image.alt}
|
|
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
|
/>
|
|
</button>
|
|
</EditableItem>
|
|
</Reveal>
|
|
))}
|
|
<AddItemButton resource="gallery" />
|
|
</div>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{active !== null && gallery[active] && (
|
|
<motion.div
|
|
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="نمایش تصویر"
|
|
onClick={close}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={close}
|
|
className="absolute left-8 top-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
|
aria-label="بستن"
|
|
>
|
|
<XIcon className="h-5 w-5" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
step(-1)
|
|
}}
|
|
className="absolute right-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:right-10"
|
|
aria-label="تصویر قبلی"
|
|
>
|
|
<ChevronRightIcon className="h-6 w-6" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
step(1)
|
|
}}
|
|
className="absolute left-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10"
|
|
aria-label="تصویر بعدی"
|
|
>
|
|
<ChevronLeftIcon className="h-6 w-6" />
|
|
</button>
|
|
|
|
<motion.img
|
|
key={active}
|
|
src={gallery[active].src}
|
|
alt=""
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
|
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</section>
|
|
)
|
|
}
|