شامل کارهای چند نشست موازی که تا اینجا فقط روی سرور بود: - نقشهٔ دفتر مرکزی در فوتر (LazyOfficeMap) + MapViewport/LazyMaps/mapProjects - صفحهٔ مدیریت مقالات (AdminArticlesPage) + میانبر ویرایش پیشنویس در وبلاگ - مهاجرت ۰۰۰۴: updated_at مقالات/پروژهها - chatbot/Dockerfile: نصب دومرحلهای torch (کاربردی برای PyPI فیلترشده) - seed: خالیکردن آمارهای دموی story - docker-compose: اصلاح پسوند @file میانافزارهای Traefik - حذف فایل موقت frontend/.tmp-style.json و اصلاحات جزئی UI با این کامیت، آنچه روی سرور اجرا میشود Exactly همان چیزی است که در مخزن ثبت شده.
179 lines
7.3 KiB
TypeScript
179 lines
7.3 KiB
TypeScript
import React, { useEffect } from 'react';
|
|
import { BrowserRouter, Route, Routes, useLocation } from 'react-router-dom';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import { Preloader } from './components/Preloader';
|
|
import { Header } from './components/Header';
|
|
import { Footer } from './components/Footer';
|
|
import { FloatingContact } from './components/FloatingContact';
|
|
// چتبات موقتاً خاموش است — برای روشنکردن، این ایمپورت و خط رندر پایین را برگردانید
|
|
// import { ChatWidget } from './components/ChatWidget';
|
|
import { Home } from './pages/Home';
|
|
import { About } from './pages/About';
|
|
import { ServicesPage } from './pages/ServicesPage';
|
|
import { Projects } from './pages/Projects';
|
|
import { ProjectDetail } from './pages/ProjectDetail';
|
|
import { Investment } from './pages/Investment';
|
|
import { Blog } from './pages/Blog';
|
|
import { ArticleDetail } from './pages/ArticleDetail';
|
|
import { FaqPage } from './pages/FaqPage';
|
|
import { ContactPage } from './pages/ContactPage';
|
|
import { SiteProvider, useSiteData, useSite } from './api';
|
|
import { applySeo, seoKeyForPath } from './seo';
|
|
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
|
import { AdminBar } from './admin/AdminBar';
|
|
import { AdminLoginPage } from './admin/AdminLoginPage';
|
|
import { AdminArticlesPage } from './admin/AdminArticlesPage';
|
|
import { ArticleWritePage } from './admin/ArticleWritePage';
|
|
import { ProjectWritePage } from './admin/ProjectWritePage';
|
|
import { LangProvider, useLang } from './i18n/lang';
|
|
|
|
// مسیرهای عمومی یک بار تعریف میشوند و دو بار سرو میشوند: ریشه فارسی و /en انگلیسی
|
|
const PUBLIC_ROUTES: { path: string; element: React.ReactElement }[] = [
|
|
{ path: '/', element: <Home /> },
|
|
{ path: '/about', element: <About /> },
|
|
{ path: '/services', element: <ServicesPage /> },
|
|
{ path: '/projects', element: <Projects /> },
|
|
{ path: '/projects/:slug', element: <ProjectDetail /> },
|
|
{ path: '/investment', element: <Investment /> },
|
|
{ path: '/blog', element: <Blog /> },
|
|
{ path: '/blog/:slug', element: <ArticleDetail /> },
|
|
{ path: '/faq', element: <FaqPage /> },
|
|
{ path: '/contact', element: <ContactPage /> },
|
|
];
|
|
|
|
// فرزند مستقیم <Routes> باید <Route> باشد — آرایههای Route بهجای کامپوننت واسط
|
|
const FA_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
|
|
<Route key={path} path={path} element={element} />
|
|
));
|
|
const EN_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
|
|
<Route key={`en-${path}`} path={path === '/' ? '/en' : `/en${path}`} element={element} />
|
|
));
|
|
|
|
function ScrollToTop() {
|
|
const { pathname } = useLocation();
|
|
useEffect(() => {
|
|
window.scrollTo({ top: 0, behavior: 'auto' });
|
|
}, [pathname]);
|
|
return null;
|
|
}
|
|
|
|
function AnimatedRoutes() {
|
|
const location = useLocation();
|
|
return (
|
|
<AnimatePresence mode="wait">
|
|
<motion.main
|
|
key={location.pathname}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
|
|
|
<Routes location={location}>
|
|
<Route path="/admin/login" element={<AdminLoginPage />} />
|
|
<Route path="/admin/articles" element={<AdminArticlesPage />} />
|
|
<Route path="/admin/write" element={<ArticleWritePage />} />
|
|
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
|
|
<Route path="/admin/projects/new" element={<ProjectWritePage />} />
|
|
<Route path="/admin/projects/:id" element={<ProjectWritePage />} />
|
|
{FA_ROUTES}
|
|
<Route path="*" element={<Home />} />
|
|
{EN_ROUTES}
|
|
</Routes>
|
|
</motion.main>
|
|
</AnimatePresence>);
|
|
|
|
}
|
|
|
|
function AppChrome() {
|
|
const { pathname } = useLocation();
|
|
const { isAuthenticated } = useAdmin();
|
|
const data = useSite();
|
|
const { lang } = useLang();
|
|
const isAdminPage = pathname.startsWith('/admin');
|
|
|
|
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحههای جزئیات که خودشان ست میکنند
|
|
useEffect(() => {
|
|
if (isAdminPage) return;
|
|
const key = seoKeyForPath(pathname);
|
|
if (!key) return;
|
|
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
|
const brand = data.brand.name;
|
|
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
|
|
// alternate انگلیسی فقط وقتی اعلام میشود که متای انگلیسی واقعاً ثبت شده باشد
|
|
const hasEn = !!(meta && ((meta.title_en ?? '').trim() || (meta.description_en ?? '').trim()));
|
|
applySeo({
|
|
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
|
description: meta?.description ?? '',
|
|
path: pathname,
|
|
lang,
|
|
hasEn,
|
|
});
|
|
}, [pathname, data, isAdminPage, lang]);
|
|
|
|
return (
|
|
<>
|
|
{!isAdminPage && <Header />}
|
|
<AnimatedRoutes />
|
|
{!isAdminPage && <Footer />}
|
|
{!isAdminPage && <FloatingContact />}
|
|
{/* چتبات موقتاً خاموش است — برای روشنکردن، این خط را از حالت کامنت خارج کنید */}
|
|
{/* {!isAdminPage && <ChatWidget />} */}
|
|
{isAuthenticated && !isAdminPage && <AdminBar />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AppShell() {
|
|
const { data, error, reload } = useSiteData();
|
|
const { t, dir, lang } = useLang();
|
|
|
|
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
|
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
|
return (
|
|
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
|
<Preloader />
|
|
{error ? (
|
|
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
|
<h1 className="text-2xl font-bold text-ivory">{t('errorTitle')}</h1>
|
|
<p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70">
|
|
{t('errorText')}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={reload}
|
|
className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105"
|
|
>
|
|
{t('retry')}
|
|
</button>
|
|
</div>
|
|
) : data ? (
|
|
<>
|
|
<ScrollToTop />
|
|
<AppChrome />
|
|
</>
|
|
) : (
|
|
// اسکلت اولیه تا رسیدن دادههای API — صفحه هرگز خالی رندر نمیشود.
|
|
// (خزندههای رندرکننده — مثل گوگل — صفحهٔ خالی را Soft 404 میبینند)
|
|
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
|
<h1 className="text-2xl font-bold text-ivory">{t('brandFallback')}</h1>
|
|
<p className="mt-4 text-sm font-light text-ivory/70">{t('loading')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function App() {
|
|
return (
|
|
<SiteProvider>
|
|
<AdminProvider>
|
|
<BrowserRouter>
|
|
<LangProvider>
|
|
<AppShell />
|
|
</LangProvider>
|
|
</BrowserRouter>
|
|
</AdminProvider>
|
|
</SiteProvider>
|
|
);
|
|
}
|