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')],
|
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: {},
|
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: {
|
story: {
|
||||||
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||||
titleKey: 'title',
|
titleKey: 'title',
|
||||||
|
|||||||
@@ -74,8 +74,15 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
|||||||
</motion.p>
|
</motion.p>
|
||||||
<div className="mt-8 h-px w-28 bg-copper" />
|
<div className="mt-8 h-px w-28 bg-copper" />
|
||||||
|
|
||||||
{/* ویرایش تصویر پسزمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
|
{/* ویرایش هیروی صفحه (عنوان/سرآغاز/تصویر)، تصویر پسزمینه و سئو — فقط در حالت ویرایش ادمین */}
|
||||||
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
|
<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
|
<SingletonPill
|
||||||
resource="seo-meta"
|
resource="seo-meta"
|
||||||
label="عنوان و توضیح سئوی این صفحه"
|
label="عنوان و توضیح سئوی این صفحه"
|
||||||
|
|||||||
@@ -12,13 +12,27 @@ import { useSite, useStatusLabel } from '../api';
|
|||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
import { applySeo } from '../seo';
|
import { applySeo } from '../seo';
|
||||||
import { projectsData } from '../data/projectsData';
|
import { projectsData } from '../data/projectsData';
|
||||||
|
import { SingletonPill } from '../admin/Editable';
|
||||||
|
|
||||||
export function ProjectDetail() {
|
export function ProjectDetail() {
|
||||||
const { slug } = useParams<{ slug: string }>()
|
const { slug } = useParams<{ slug: string }>()
|
||||||
const { projects, brand } = useSite()
|
const { projects, brand } = useSite()
|
||||||
const { t, href, lang } = useLang()
|
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)
|
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>
|
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
|
// Dynamic bilingual text from projectsData
|
||||||
const displayTitle = currentProject.name[lang as 'en' | 'fa'] || currentProject.name.en;
|
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]);
|
}, [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 = [
|
const facts = [
|
||||||
{ label: t('specLocation'), value: displayLocation },
|
{ label: t('specLocation'), value: displayLocation },
|
||||||
@@ -104,8 +121,15 @@ export function ProjectDetail() {
|
|||||||
<span className="text-copper">{displayTitle}</span>
|
<span className="text-copper">{displayTitle}</span>
|
||||||
</nav>
|
</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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Gallery Desktop: Grid. Mobile: Grid */}
|
{/* Gallery Desktop: Grid. Mobile: Grid */}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import { useSite, useStatusLabel } from '../api';
|
|||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
import { useFieldSaver } from '../admin/AdminContext';
|
import { useFieldSaver } from '../admin/AdminContext';
|
||||||
import { InlineEdit } from '../components/admin/InlineEdit';
|
import { InlineEdit } from '../components/admin/InlineEdit';
|
||||||
|
import { AddItemButton, EditableItem } from '../admin/Editable';
|
||||||
import { projectsData } from '../data/projectsData';
|
import { projectsData } from '../data/projectsData';
|
||||||
import { LazyProjectsMapAll } from '../map/LazyMaps';
|
import { LazyProjectsMapAll } from '../map/LazyMaps';
|
||||||
import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
|
import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
|
||||||
|
|
||||||
export function Projects() {
|
export function Projects() {
|
||||||
const { status_labels } = useSite();
|
const { status_labels, projects: dbProjects } = useSite();
|
||||||
const { t } = useLang();
|
const { t } = useLang();
|
||||||
const save = useFieldSaver();
|
const save = useFieldSaver();
|
||||||
|
|
||||||
@@ -35,6 +36,14 @@ export function Projects() {
|
|||||||
];
|
];
|
||||||
const [status, setStatus] = useState<'done' | 'progress'>('done');
|
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
|
// Filtering and Pagination State
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -42,8 +51,8 @@ export function Projects() {
|
|||||||
|
|
||||||
// Filter projects by status
|
// Filter projects by status
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
return projectsData.filter((p) => p.status === status);
|
return cardProjects.filter((p) => p.status === status);
|
||||||
}, [status]);
|
}, [cardProjects, status]);
|
||||||
|
|
||||||
// Currently displayed projects
|
// Currently displayed projects
|
||||||
const displayedProjects = useMemo(() => {
|
const displayedProjects = useMemo(() => {
|
||||||
@@ -68,10 +77,15 @@ export function Projects() {
|
|||||||
|
|
||||||
const BASE_IMG_URL = "/seed-media/2026/09/";
|
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 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(
|
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;
|
return match ? `/projects/${match.slug}` : undefined;
|
||||||
};
|
};
|
||||||
@@ -131,19 +145,31 @@ export function Projects() {
|
|||||||
|
|
||||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||||
{displayedProjects.map((project, index) => {
|
{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);
|
||||||
return (
|
const imageUrl = hasOwnImage ? undefined : `${BASE_IMG_URL}${(index % 6) + 1}.png`;
|
||||||
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
|
const card = (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
project={project}
|
project={project}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
detailHref={`/projects/${project.slug}`}
|
detailHref={`/projects/${project.slug}`}
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
|
||||||
|
{fromDb ? (
|
||||||
|
<EditableItem resource="projects" item={project} className="h-full">
|
||||||
|
{card}
|
||||||
|
</EditableItem>
|
||||||
|
) : (
|
||||||
|
card
|
||||||
|
)}
|
||||||
</Reveal>
|
</Reveal>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{fromDb && <AddItemButton resource="projects" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Load More Button */}
|
{/* Load More Button */}
|
||||||
|
|||||||
Reference in New Issue
Block a user