feat(admin): ویرایشپذیری هیروی صفحهها و پروژهها
- page-heroes به فهرست موجودیتهای پنل اضافه شد و PageHero یک دکمهٔ واضح «عنوان، سرآغاز و تصویر هیروی این صفحه» گرفت (پیشتر فقط ویرایش درجای نامرئی روی خود متن بود). - صفحهٔ پروژهها و صفحهٔ جزئیات پروژه حالا از دیتابیس خوانده میشوند (فایل استاتیک projectsData فقط جانشین دیتابیس خالی/اسلاگهای قدیمی است) — پیشتر ویرایش پنل روی این دو صفحه اصلاً نمایش داده نمیشد. - روی لیست پروژهها دکمهٔ ویرایش/جابهجایی/حذف و «افزودن پروژه» اضافه شد و روی صفحهٔ جزئیات هر پروژه دکمهٔ ویرایش همان پروژه.
This commit is contained in:
@@ -195,6 +195,15 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||
defaults: {},
|
||||
},
|
||||
'page-heroes': {
|
||||
resource: 'page-heroes', dataKey: 'page_heroes', title: 'هیروی صفحهها', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('key', 'کلید صفحه', 'text', 'about / services / projects / investment / blog / faq / contact', true),
|
||||
F('title', 'عنوان'), F('lead', 'سرآغاز', 'textarea'), F('image', 'تصویر', 'image', undefined, true),
|
||||
],
|
||||
defaults: { key: 'about', title: 'عنوان صفحه', lead: '', image: '' },
|
||||
},
|
||||
story: {
|
||||
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
|
||||
@@ -74,8 +74,15 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
||||
</motion.p>
|
||||
<div className="mt-8 h-px w-28 bg-copper" />
|
||||
|
||||
{/* ویرایش تصویر پسزمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
|
||||
{/* ویرایش هیروی صفحه (عنوان/سرآغاز/تصویر)، تصویر پسزمینه و سئو — فقط در حالت ویرایش ادمین */}
|
||||
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
|
||||
<SingletonPill
|
||||
resource="page-heroes"
|
||||
label="عنوان، سرآغاز و تصویر هیروی این صفحه"
|
||||
select={(d) => d.page_heroes.find((h) => h.key === pageKey)}
|
||||
// اگر ردیف هیروی این صفحه هنوز ساخته نشده باشد، همینجا ساخته میشود
|
||||
createDefaults={() => ({ key: pageKey, title: '', lead: '', image: '' })}
|
||||
/>
|
||||
<SingletonPill
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی این صفحه"
|
||||
|
||||
@@ -12,13 +12,27 @@ import { useSite, useStatusLabel } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { applySeo } from '../seo';
|
||||
import { projectsData } from '../data/projectsData';
|
||||
import { SingletonPill } from '../admin/Editable';
|
||||
|
||||
export function ProjectDetail() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const { projects, brand } = useSite()
|
||||
const { t, href, lang } = useLang()
|
||||
|
||||
const currentProject = projectsData.find((p) => p.slug === slug)
|
||||
// اولویت با رکورد دیتابیس است تا ویرایش پنل روی صفحهٔ جزئیات هم دیده شود؛
|
||||
// فایل استاتیک فقط برای اسلاگهای قدیمی که در دیتابیس نیستند به کار میرود.
|
||||
const dbProject = projects.find((p) => p.slug === slug)
|
||||
const staticProject = projectsData.find((p) => p.slug === slug)
|
||||
const currentProject = dbProject
|
||||
? {
|
||||
id: String(dbProject.id),
|
||||
slug: dbProject.slug,
|
||||
name: { en: dbProject.title_en || dbProject.title, fa: dbProject.title },
|
||||
location: { en: dbProject.location_en || dbProject.location, fa: dbProject.location },
|
||||
area: { en: dbProject.area_en || dbProject.area, fa: dbProject.area },
|
||||
status: (dbProject.status === 'progress' ? 'progress' : 'done') as 'done' | 'progress',
|
||||
}
|
||||
: staticProject
|
||||
|
||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||
|
||||
@@ -26,7 +40,7 @@ export function ProjectDetail() {
|
||||
return <div className="text-center py-20 text-ivory">Project not found</div>
|
||||
}
|
||||
|
||||
const backendProject = projects.find((p) => p.slug === slug)
|
||||
const backendProject = dbProject
|
||||
|
||||
// Dynamic bilingual text from projectsData
|
||||
const displayTitle = currentProject.name[lang as 'en' | 'fa'] || currentProject.name.en;
|
||||
@@ -76,7 +90,10 @@ export function ProjectDetail() {
|
||||
});
|
||||
}, [currentProject.slug, lang, href, displayTitle, summary, image, brand.name]);
|
||||
|
||||
const related = projectsData.filter((p) => p.slug !== currentProject.slug).slice(0, 3)
|
||||
// پروژههای مرتبط — از دیتابیس، و اگر خالی بود از فهرست استاتیک قدیمی
|
||||
const related = (projects.length > 0 ? projects : projectsData)
|
||||
.filter((p) => p.slug !== currentProject.slug)
|
||||
.slice(0, 3)
|
||||
|
||||
const facts = [
|
||||
{ label: t('specLocation'), value: displayLocation },
|
||||
@@ -104,8 +121,15 @@ export function ProjectDetail() {
|
||||
<span className="text-copper">{displayTitle}</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-8 flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-4xl lg:text-5xl font-bold text-ivory">{displayTitle}</h1>
|
||||
{dbProject && (
|
||||
<SingletonPill
|
||||
resource="projects"
|
||||
label="ویرایش این پروژه (عنوان، مشخصات، گالری، متن)"
|
||||
select={(d) => d.projects.find((p) => p.slug === slug)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gallery Desktop: Grid. Mobile: Grid */}
|
||||
|
||||
@@ -9,12 +9,13 @@ import { useSite, useStatusLabel } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from '../components/admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem } from '../admin/Editable';
|
||||
import { projectsData } from '../data/projectsData';
|
||||
import { LazyProjectsMapAll } from '../map/LazyMaps';
|
||||
import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
|
||||
|
||||
export function Projects() {
|
||||
const { status_labels } = useSite();
|
||||
const { status_labels, projects: dbProjects } = useSite();
|
||||
const { t } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
@@ -35,6 +36,14 @@ export function Projects() {
|
||||
];
|
||||
const [status, setStatus] = useState<'done' | 'progress'>('done');
|
||||
|
||||
// پروژهها از دیتابیس خوانده میشوند تا ویرایش پنل همانجا دیده شود؛
|
||||
// فایل استاتیک فقط وقتی دیتابیس خالی است جانشین میشود (محتوای قدیمی).
|
||||
const fromDb = dbProjects.length > 0;
|
||||
const cardProjects = useMemo(() => {
|
||||
if (fromDb) return dbProjects;
|
||||
return projectsData.map((p) => ({ ...p, id: Number(p.id) }));
|
||||
}, [fromDb, dbProjects]);
|
||||
|
||||
// Filtering and Pagination State
|
||||
const [page, setPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -42,8 +51,8 @@ export function Projects() {
|
||||
|
||||
// Filter projects by status
|
||||
const filteredProjects = useMemo(() => {
|
||||
return projectsData.filter((p) => p.status === status);
|
||||
}, [status]);
|
||||
return cardProjects.filter((p) => p.status === status);
|
||||
}, [cardProjects, status]);
|
||||
|
||||
// Currently displayed projects
|
||||
const displayedProjects = useMemo(() => {
|
||||
@@ -68,10 +77,15 @@ export function Projects() {
|
||||
|
||||
const BASE_IMG_URL = "/seed-media/2026/09/";
|
||||
|
||||
// Resolve a project href from a MapProject entry by matching against projectsData slugs
|
||||
// Resolve a project href from a MapProject entry by matching names — first in the
|
||||
// database (so pins follow panel edits), then in the static legacy list.
|
||||
const resolveProjectHref = (mapProject: MapProject): string | undefined => {
|
||||
const dbMatch = dbProjects.find(
|
||||
(p) => p.title === mapProject.name || p.title_en === mapProject.name,
|
||||
);
|
||||
if (dbMatch) return `/projects/${dbMatch.slug}`;
|
||||
const match = projectsData.find(
|
||||
(p) => p.name.fa === mapProject.name || p.name.en === mapProject.name
|
||||
(p) => p.name.fa === mapProject.name || p.name.en === mapProject.name,
|
||||
);
|
||||
return match ? `/projects/${match.slug}` : undefined;
|
||||
};
|
||||
@@ -131,19 +145,31 @@ export function Projects() {
|
||||
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||
{displayedProjects.map((project, index) => {
|
||||
const dummyImageName = `${(index % 6) + 1}.png`;
|
||||
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
|
||||
// تصویر رکورد دیتابیس اولویت دارد؛ فقط ردیفهای بدون تصویر از
|
||||
// تصاویر جایگزین تکراری استفاده میکنند.
|
||||
const hasOwnImage = 'image' in project && Boolean((project as { image?: string }).image);
|
||||
const imageUrl = hasOwnImage ? undefined : `${BASE_IMG_URL}${(index % 6) + 1}.png`;
|
||||
const card = (
|
||||
<ProjectCard
|
||||
project={project}
|
||||
imageUrl={imageUrl}
|
||||
detailHref={`/projects/${project.slug}`}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
|
||||
<ProjectCard
|
||||
project={project}
|
||||
imageUrl={imageUrl}
|
||||
detailHref={`/projects/${project.slug}`}
|
||||
/>
|
||||
{fromDb ? (
|
||||
<EditableItem resource="projects" item={project} className="h-full">
|
||||
{card}
|
||||
</EditableItem>
|
||||
) : (
|
||||
card
|
||||
)}
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
{fromDb && <AddItemButton resource="projects" />}
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
|
||||
Reference in New Issue
Block a user