chore: همگامسازی وضعیت زندهٔ سرور با مخزن
شامل کارهای چند نشست موازی که تا اینجا فقط روی سرور بود: - نقشهٔ دفتر مرکزی در فوتر (LazyOfficeMap) + MapViewport/LazyMaps/mapProjects - صفحهٔ مدیریت مقالات (AdminArticlesPage) + میانبر ویرایش پیشنویس در وبلاگ - مهاجرت ۰۰۰۴: updated_at مقالات/پروژهها - chatbot/Dockerfile: نصب دومرحلهای torch (کاربردی برای PyPI فیلترشده) - seed: خالیکردن آمارهای دموی story - docker-compose: اصلاح پسوند @file میانافزارهای Traefik - حذف فایل موقت frontend/.tmp-style.json و اصلاحات جزئی UI با این کامیت، آنچه روی سرور اجرا میشود Exactly همان چیزی است که در مخزن ثبت شده.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+20
-2
@@ -24,12 +24,30 @@
|
||||
file_server
|
||||
}
|
||||
|
||||
# مسیریابی SPA — فقط مسیرهای بدون پسوند (مسیرهای اپ) به index.html برمیگردند
|
||||
handle {
|
||||
# مسیرهای پنل ادمین فقط SPA هستند و فایلی پشتشان نیست؛ پوسته باید با
|
||||
# وضعیت ۲۰۰ سرو شود وگرنه قاعدهٔ ۴۰۴ مسیرهای ناشناخته پنل را هم میبندد
|
||||
# (و لبهٔ Traefik بهجای پوسته، صفحهٔ خطای خودش را نشان میدهد). فهرست
|
||||
# مسیرهای ادمین در App.tsx است؛ مسیر تازهٔ ادمین یعنی افزودن الگوی آنجا و اینجا.
|
||||
# robots.txt از قبل /admin را برای خزندهها میبندد؛ این مسیرها ایندکس نمیشوند.
|
||||
@admin path /admin /admin/*
|
||||
handle @admin {
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
# مسیرهای واقعی اپ همگی سمت بکاند سرو میشوند (روتر seo ترافیک را میبرد)؛
|
||||
# پس به اینجا فقط مسیر ناشناخته میرسد — باید ۴۰۴ بدهد، نه پوستهٔ ۲۰۰ (نرم۴۰۴)
|
||||
handle {
|
||||
try_files {path}
|
||||
file_server
|
||||
}
|
||||
|
||||
# خطاها با پوستهٔ برند دیده میشوند — وضعیت ۴۰۴ حفظ میشود تا خزنده ایندکس نکند
|
||||
handle_errors {
|
||||
rewrite * /index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
# index.html و fallback اسپا: بدون کش مرورگر تا هر استقرار فوری دیده شود
|
||||
header Cache-Control "no-cache"
|
||||
# فایلهای ثابت (فونت/تصویر): یک ساعت کش مرورگر
|
||||
|
||||
+16
-1
@@ -19,7 +19,7 @@
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"Organization","name":"گروه ساختمانی آرامش","inLanguage":"fa"}
|
||||
{"@context":"https://schema.org","@type":"Organization","name":"گروه ساختمانی آرامش","url":"/","logo":"/images/logo.png"}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -31,6 +31,21 @@
|
||||
document.documentElement.setAttribute('lang', 'en');
|
||||
document.documentElement.setAttribute('dir', 'ltr');
|
||||
}
|
||||
// نشانی مطلق اسکیمای Organization پوستهٔ استاتیک — این پوسته فقط برای پوستهٔ
|
||||
// خطا سرو میشود؛ صفحات واقعی این اسکریپت را با نسخهٔ غنی دیتابیس جایگزین
|
||||
// میکنند و آنجا این بلوک بدون اثر میماند (اسکیمای تزریقی id دارد)
|
||||
try {
|
||||
var staticOrg = document.querySelector('script[type="application/ld+json"]:not([id])');
|
||||
if (staticOrg) {
|
||||
var org = JSON.parse(staticOrg.textContent);
|
||||
if (org['@type'] === 'Organization') {
|
||||
['url', 'logo'].forEach(function (k) {
|
||||
if (org[k] && org[k].charAt(0) === '/') org[k] = location.origin + org[k];
|
||||
});
|
||||
staticOrg.textContent = JSON.stringify(org);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<script type="module">
|
||||
// تحلیل رفتار (Umami) — در زمان بیلد تزریق میشود؛ VITE_UMAMI_SRC خالی یعنی خاموش
|
||||
|
||||
@@ -22,6 +22,7 @@ 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';
|
||||
@@ -69,6 +70,7 @@ function AnimatedRoutes() {
|
||||
|
||||
<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 />} />
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { adminApi } from './adminClient'
|
||||
import { useAdmin } from './AdminContext'
|
||||
|
||||
type ArticleRow = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
status: string
|
||||
author?: string
|
||||
date?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* فهرست مقالهها — همهٔ اعضای تیم (ادمین و نویسنده) همهٔ مقالهها را میبینند.
|
||||
* پیشنویسها برای هر دو نقش قابل ویرایشاند؛ مقالهٔ منتشرشده فقط برای ادمین.
|
||||
* نویسنده پیشتر هیچ راهی برای دیدن پیشنویسهای همتیمی نداشت.
|
||||
*/
|
||||
export function AdminArticlesPage() {
|
||||
const { isAuthenticated, role } = useAdmin()
|
||||
const [rows, setRows] = useState<ArticleRow[] | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return
|
||||
adminApi
|
||||
.list<ArticleRow[]>('articles')
|
||||
.then((items) =>
|
||||
// پیشنویسها اول (کارِ در جریان)، بعد منتشرشدهها؛ در هر گروه تازهترها بالا
|
||||
setRows(
|
||||
[...items].sort((a, b) =>
|
||||
a.status === b.status ? b.id - a.id : a.status === 'draft' ? -1 : 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : 'خطا در دریافت مقالهها'))
|
||||
}, [isAuthenticated])
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
|
||||
|
||||
const drafts = (rows ?? []).filter((row) => row.status === 'draft')
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-ink px-4 py-10 font-pinar text-ivory md:px-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-light tracking-wide text-copper">
|
||||
حالت {role === 'creator' ? 'نویسنده' : 'مدیریت'}
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-bold">مقالهها</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||
>
|
||||
بازگشت به سایت
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/write"
|
||||
className="border border-copper/50 px-3 py-1.5 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||
>
|
||||
نوشتن مقاله جدید
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-6 border border-red-400/40 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</p>
|
||||
)}
|
||||
{rows === null && !error && (
|
||||
<p className="mt-8 text-sm font-light text-ivory/50">در حال بارگذاری…</p>
|
||||
)}
|
||||
{rows !== null && rows.length === 0 && (
|
||||
<p className="mt-8 text-sm font-light text-ivory/50">هنوز مقالهای ساخته نشده است.</p>
|
||||
)}
|
||||
|
||||
<ul className="mt-8 divide-y divide-ivory/10 border-y border-ivory/10">
|
||||
{(rows ?? []).map((article) => {
|
||||
// مقالهٔ منتشرشده برای نویسنده فقطخواندنی است — ویرایشش یعنی
|
||||
// انتشار غیرمستقیم محتوا؛ همان قاعدهای که گارد API اعمال میکند
|
||||
const editable = article.status === 'draft' || role === 'admin'
|
||||
const inner = (
|
||||
<div className="flex flex-wrap items-center gap-3 px-2 py-4">
|
||||
<span
|
||||
className={`shrink-0 border px-2 py-0.5 text-[11px] font-bold ${
|
||||
article.status === 'draft'
|
||||
? 'border-copper/50 text-copper'
|
||||
: 'border-emerald-400/40 text-emerald-300'
|
||||
}`}
|
||||
>
|
||||
{article.status === 'draft' ? 'پیشنویس' : 'منتشرشده'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-ivory">{article.title || '—'}</p>
|
||||
{article.title_en ? (
|
||||
<p dir="ltr" className="truncate text-left text-xs font-light text-ivory/40">
|
||||
{article.title_en}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] font-light text-ivory/45">
|
||||
{[article.author, article.date].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 text-[11px] ${editable ? 'text-copper' : 'text-ivory/35'}`}
|
||||
>
|
||||
{editable ? 'ویرایش' : 'فقطخواندنی'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<li key={article.id}>
|
||||
{editable ? (
|
||||
<Link to={`/admin/write/${article.id}`} className="block transition-colors duration-200 hover:bg-copper/5">
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
inner
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{rows !== null && rows.length > 0 && (
|
||||
<p className="mt-4 text-[11px] font-light text-ivory/40">
|
||||
{drafts.length} پیشنویس از {rows.length} مقاله
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -148,7 +148,7 @@ export function AdminBar() {
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-bold text-copper">
|
||||
<span className="flex h-2 w-2 rounded-full bg-copper" />
|
||||
حالت مدیریت
|
||||
{role === 'creator' ? 'حالت نویسنده' : 'حالت مدیریت'}
|
||||
</span>
|
||||
|
||||
{canManageSite && (
|
||||
@@ -174,6 +174,10 @@ export function AdminBar() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* کلید زبان ویرایش فقط برای ادمین — برای نویسنده هیچ ویرایش درجایی روی
|
||||
سایت وجود ندارد و دکمه بیاثر به نظر میرسید؛ زبان ویرایش نویسنده
|
||||
داخل خود ویرایشگر مقاله و بهازای هر مقاله انتخاب میشود */}
|
||||
{canManageSite && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleEditLang}
|
||||
@@ -186,6 +190,7 @@ export function AdminBar() {
|
||||
>
|
||||
زبان ویرایش: {editLang === 'fa' ? 'فارسی' : 'English'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
|
||||
@@ -201,8 +206,17 @@ export function AdminBar() {
|
||||
رمز
|
||||
</button>
|
||||
|
||||
{canManageSite && (
|
||||
<>
|
||||
{/* «مقالهها» و «نوشتن مقاله» برای هر دو نقش — creator تیم پیشنویس
|
||||
است و فهرست همهٔ مقالهها/پیشنویسها تنها مسیر کاریاش؛ CSV و
|
||||
حالت ویرایش سایت همچنان فقط ادمین است */}
|
||||
<Link
|
||||
to="/admin/articles"
|
||||
data-admin-link
|
||||
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||
>
|
||||
مقالهها
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/admin/write"
|
||||
data-admin-link
|
||||
@@ -211,6 +225,8 @@ export function AdminBar() {
|
||||
نوشتن مقاله
|
||||
</Link>
|
||||
|
||||
{canManageSite && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
|
||||
|
||||
@@ -333,6 +333,7 @@ export function ProjectWritePage() {
|
||||
<div className="border-t border-ivory/10 pt-8">
|
||||
<ImageField
|
||||
label="تصویر شاخص"
|
||||
requireEditMode={false}
|
||||
value={draft.image}
|
||||
onSave={persistImage}
|
||||
altFallback={`پروژه ${draft.title}`}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLang } from '../i18n/lang';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from './admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
|
||||
import { LazyOfficeMap } from '../map/LazyMaps';
|
||||
|
||||
const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
instagram: InstagramIcon,
|
||||
@@ -261,6 +262,14 @@ export function Footer() {
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{/* نقشهٔ دفتر مرکزی — همان مختصاتی که در اسکیمای LocalBusiness
|
||||
(geo/hasMap) به گوگل و دستیارهای AI میرسد؛ با رسیدن فوتر
|
||||
به دید کاربر بارگیری میشود تا بارگذاری اولیه سبک بماند.
|
||||
ارتفاع عمداً کم است (نصف کارت اطلاعات) — نقشه یک نشانگرِ
|
||||
موقعیت است، نه تجربهٔ تمامصفحه؛ آن از لینک گوگلمپ میآید */}
|
||||
<div className="mt-6 h-36">
|
||||
<LazyOfficeMap className="h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ type ImageFieldProps = {
|
||||
altFallback?: string
|
||||
/** پیشنهاد عنوان تصویر — پیشفرض «Photo 1» */
|
||||
titleFallback?: string
|
||||
/**
|
||||
* در صفحههای نوشتن (مقاله/پروژه) همیشه نمایش داده شود — آنجا حالت ویرایش
|
||||
* ادمین معنا ندارد و creator هم باید به تصویر شاخص دسترسی داشته باشد.
|
||||
* پیشفرض true: ویرایش درجای سایت فقط در حالت ویرایش ادمین.
|
||||
*/
|
||||
requireEditMode?: boolean
|
||||
}
|
||||
|
||||
const META_FIELD_CLASS =
|
||||
@@ -43,6 +49,7 @@ export function ImageField({
|
||||
onSaveTitle,
|
||||
altFallback = '',
|
||||
titleFallback = '',
|
||||
requireEditMode = true,
|
||||
}: ImageFieldProps) {
|
||||
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -69,7 +76,7 @@ export function ImageField({
|
||||
onCommit: onSaveAlt,
|
||||
})
|
||||
|
||||
if (!isAuthenticated || !isEditMode) return null
|
||||
if (!isAuthenticated || (requireEditMode && !isEditMode)) return null
|
||||
|
||||
const pick = async (file: File | undefined) => {
|
||||
if (!file) return
|
||||
|
||||
@@ -55,26 +55,31 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
))}
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.16}>
|
||||
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-copper">
|
||||
<InlineEdit value={story.stat1_value} onSave={(v) => saveStory('stat1_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">
|
||||
<InlineEdit value={story.stat1_label} onSave={(v) => saveStory('stat1_label', v)} />
|
||||
</p>
|
||||
{/* آمارها فقط وقتی نمایش داده میشوند که مقداری ثبت شده باشد —
|
||||
با خالیبودن، کادر خالی با خط جداکننده بیمعنا میماند؛
|
||||
ویرایش/افزودن آمار از پنل ادمین (موجودیت story) انجام میشود */}
|
||||
{(story.stat1_value || story.stat2_value) && (
|
||||
<Reveal delay={0.16}>
|
||||
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-copper">
|
||||
<InlineEdit value={story.stat1_value} onSave={(v) => saveStory('stat1_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">
|
||||
<InlineEdit value={story.stat1_label} onSave={(v) => saveStory('stat1_label', v)} />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-copper">
|
||||
<InlineEdit value={story.stat2_value} onSave={(v) => saveStory('stat2_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">
|
||||
<InlineEdit value={story.stat2_label} onSave={(v) => saveStory('stat2_label', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-copper">
|
||||
<InlineEdit value={story.stat2_value} onSave={(v) => saveStory('stat2_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light text-ivory/55">
|
||||
<InlineEdit value={story.stat2_label} onSave={(v) => saveStory('stat2_label', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{showQuote && story.quote && (
|
||||
<Reveal delay={0.2}>
|
||||
|
||||
@@ -25,6 +25,13 @@ export type MapProject = {
|
||||
/** مرکز پیشفرض نقشه — اصفهان */
|
||||
export const ISFAHAN_CENTER: [number, number] = [51.6675, 32.6539]
|
||||
|
||||
/**
|
||||
* مختصات دفتر مرکزی (اصفهان، خیابان ملاصدرا شمالی) — همان نقطهای که سمت
|
||||
* بکاند در اسکیمای LocalBusiness بهعنوان geo/hasMap میآید (seo_pages.py:
|
||||
* OFFICE_GEO). دو مقدار باید همیشه یکی بمانند؛ تغییر یعنی ویرایش هر دو جا.
|
||||
*/
|
||||
export const OFFICE_LOCATION = { lat: 32.61764688216492, lng: 51.666306342927214 }
|
||||
|
||||
type CsvRow = { name: string; year: string; isNew: boolean; lat: number; lng: number; description: string }
|
||||
|
||||
/**
|
||||
|
||||
@@ -133,6 +133,8 @@ export const fa = {
|
||||
mapNewBadge: 'جدید',
|
||||
mapProjectDescription: 'پروژه اجراشده در اصفهان',
|
||||
mapImagePlaceholder: 'تصویر پروژه',
|
||||
mapOfficeAria: 'نقشه موقعیت دفتر مرکزی',
|
||||
openInGoogleMaps: 'مشاهده در گوگلمپ',
|
||||
}
|
||||
|
||||
export type Dict = typeof fa
|
||||
@@ -260,6 +262,8 @@ export const en: Dict = {
|
||||
mapNewBadge: 'New',
|
||||
mapProjectDescription: 'Project delivered in Isfahan',
|
||||
mapImagePlaceholder: 'Project image',
|
||||
mapOfficeAria: 'Head office location map',
|
||||
openInGoogleMaps: 'Open in Google Maps',
|
||||
}
|
||||
|
||||
export const dict = { fa, en }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, lazy, Suspense } from 'react'
|
||||
import { Component, lazy, Suspense, useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ProjectsMapAllProps } from './ProjectsMapAll'
|
||||
import type { ProjectsMapSingleProps } from './ProjectsMapSingle'
|
||||
@@ -12,6 +12,7 @@ const ProjectsMapAll = lazy(() => import('./ProjectsMapAll').then((module) => ({
|
||||
const ProjectsMapSingle = lazy(() =>
|
||||
import('./ProjectsMapSingle').then((module) => ({ default: module.ProjectsMapSingle })),
|
||||
)
|
||||
const OfficeMap = lazy(() => import('./OfficeMap').then((module) => ({ default: module.OfficeMap })))
|
||||
|
||||
/** جاینگهدار هماندازهی نقشه تا چیدمان هنگام بارگیری نلرزد */
|
||||
function MapSkeleton({ className = '' }: { className?: string }) {
|
||||
@@ -70,3 +71,43 @@ export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) {
|
||||
</MapErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* نقشهٔ دفتر مرکزی با بارگیری هنگام رسیدن به دید کاربر — بخش تماس پایین
|
||||
* صفحههای خانه/درباره است؛ بدون این دروازه، مپلیبر در همان بارگذاری اول
|
||||
* هر صفحه هم دانلود میشد و حجم بیمصرف به LCP اضافه میکرد.
|
||||
*/
|
||||
export function LazyOfficeMap({ className = '' }: { className?: string }) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = hostRef.current
|
||||
if (!el || inView) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setInView(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' },
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [inView])
|
||||
|
||||
return (
|
||||
<div ref={hostRef} className={className}>
|
||||
{inView ? (
|
||||
<MapErrorBoundary className="h-full">
|
||||
<Suspense fallback={<MapSkeleton className="h-full" />}>
|
||||
<OfficeMap className="h-full" />
|
||||
</Suspense>
|
||||
</MapErrorBoundary>
|
||||
) : (
|
||||
<MapSkeleton className="h-full" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ type MapViewportProps = {
|
||||
label: string
|
||||
/** ارتفاع/فاصلهها از بیرون — مثل «h-[520px]» */
|
||||
className?: string
|
||||
/** کف ارتفاع ظرف — نقشههای کوچک (فوتر) کف کمتری میخواهند */
|
||||
minHeightClass?: string
|
||||
/**
|
||||
* اجازهٔ حالت تمامصفحه — برای نقشهٔ فوتر خاموش است: فوتر backdrop-filter دارد
|
||||
* و چنین اجدادی position:fixed را در خودش حبس میکند؛ دکمهٔ تمامصفحه آنجا
|
||||
* نقشه را داخل کادر فوتر گیر میاندازد. حذف دکمه، رفتاری بینقص به کاربر میدهد.
|
||||
*/
|
||||
allowFullscreen?: boolean
|
||||
/** محتوای شناور روی نقشه (پورتال پاپآپ) */
|
||||
children?: ReactNode
|
||||
}
|
||||
@@ -37,6 +45,8 @@ export function MapViewport({
|
||||
onRetry,
|
||||
label,
|
||||
className = '',
|
||||
minHeightClass = 'min-h-[280px]',
|
||||
allowFullscreen = true,
|
||||
children,
|
||||
}: MapViewportProps) {
|
||||
const { t } = useLang()
|
||||
@@ -66,7 +76,7 @@ export function MapViewport({
|
||||
|
||||
const shellClass = isFullscreen
|
||||
? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0'
|
||||
: `relative h-full min-h-[280px] w-full rounded-3xl border border-ivory/10 shadow-lift ${className}`
|
||||
: `relative h-full ${minHeightClass} w-full rounded-3xl border border-ivory/10 shadow-lift ${className}`
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -78,29 +88,30 @@ export function MapViewport({
|
||||
است تا هم بستر موقعیتیابی فرزندان مپلیبر باشد و هم هشدار
|
||||
«container has a non-static position» و محاسبهی اشتباه آفست اسکرول
|
||||
رخ ندهد — ترتیب بار شدن CSS هیچوقت تعیینکننده نباشد */}
|
||||
<div ref={containerRef} className="relative h-full min-h-[280px] w-full" />
|
||||
<div ref={containerRef} className={`relative h-full ${minHeightClass} w-full`} />
|
||||
|
||||
{isFullscreen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
aria-label={t('mapExitFullscreen')}
|
||||
className="fixed left-1/2 top-5 z-[80] flex h-11 -translate-x-1/2 items-center gap-2 rounded-full border border-copper/60 bg-ink/90 px-5 text-xs font-bold text-copper shadow-lift backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper hover:text-char"
|
||||
>
|
||||
<MdClose className="h-4 w-4" aria-hidden="true" />
|
||||
{t('mapExitFullscreen')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
aria-label={t('mapFullscreen')}
|
||||
title={t('mapFullscreen')}
|
||||
className="absolute left-4 top-4 z-20 flex h-10 w-10 items-center justify-center rounded-xl border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
>
|
||||
<MdFullscreen className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{allowFullscreen &&
|
||||
(isFullscreen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
aria-label={t('mapExitFullscreen')}
|
||||
className="fixed left-1/2 top-5 z-[80] flex h-11 -translate-x-1/2 items-center gap-2 rounded-full border border-copper/60 bg-ink/90 px-5 text-xs font-bold text-copper shadow-lift backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper hover:text-char"
|
||||
>
|
||||
<MdClose className="h-4 w-4" aria-hidden="true" />
|
||||
{t('mapExitFullscreen')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
aria-label={t('mapFullscreen')}
|
||||
title={t('mapFullscreen')}
|
||||
className="absolute left-4 top-4 z-20 flex h-10 w-10 items-center justify-center rounded-xl border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
>
|
||||
<MdFullscreen className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{status !== 'ready' && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-char/70 backdrop-blur-sm">
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { useEffect } from 'react'
|
||||
import { MdOpenInNew } from 'react-icons/md'
|
||||
import { buildGoogleMapsUrl, OFFICE_LOCATION } from '../data/mapProjects'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { MapViewport } from './MapViewport'
|
||||
import { useMapLibre } from './useMapLibre'
|
||||
|
||||
/**
|
||||
* نقشهٔ دفتر مرکزی — یک سنجاق روی مختصات ثابت دفتر، با پیوند «مشاهده در
|
||||
* گوگلمپ». همان دادهٔ مکانی سمت سرور (geo/hasMap در اسکیمای LocalBusiness و
|
||||
* لینک نقشه در noscript) اینجا هم بهصورت تعاملی به کاربر و رندرکنندهٔ گوگل
|
||||
* نشان داده میشود.
|
||||
*
|
||||
* زوم چرخ ماوس مطابق بقیهٔ نقشههای سایت فعال است — خاموشکردنِ آن کاربر را
|
||||
* گیج میکرد («زوم نمیشود» در حالی که اسکرول صفحه حرکت میکرد).
|
||||
*
|
||||
* تمامصفحه عمداً غیرفعال است — فوتر backdrop-filter دارد و چنین اجدادی
|
||||
* position:fixed را حبس میکند؛ دکمهٔ تمامصفحه اینجا فقط کادر گیرافتاده
|
||||
* میساخت. تجربهٔ تمامصفحه از طریق لینک گوگلمپ در دسترس است.
|
||||
*/
|
||||
export function OfficeMap({ className = '' }: { className?: string }) {
|
||||
const { t } = useLang()
|
||||
const { containerRef, map, status, retry } = useMapLibre({
|
||||
center: [OFFICE_LOCATION.lng, OFFICE_LOCATION.lat],
|
||||
zoom: 16,
|
||||
minZoom: 5,
|
||||
})
|
||||
|
||||
// سنجاق استاندارد مپلیبر با رنگ مسی برند — بدون پاپآپ، خود نشانگر مقصد است
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
const marker = new maplibregl.Marker({ color: '#c58f5c' })
|
||||
.setLngLat([OFFICE_LOCATION.lng, OFFICE_LOCATION.lat])
|
||||
.addTo(map)
|
||||
return () => {
|
||||
marker.remove()
|
||||
}
|
||||
}, [map])
|
||||
|
||||
return (
|
||||
<MapViewport
|
||||
containerRef={containerRef}
|
||||
map={map}
|
||||
status={status}
|
||||
onRetry={retry}
|
||||
label={t('mapOfficeAria')}
|
||||
className={className}
|
||||
minHeightClass="min-h-[140px]"
|
||||
allowFullscreen={false}
|
||||
>
|
||||
<a
|
||||
href={buildGoogleMapsUrl(OFFICE_LOCATION)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute bottom-4 left-4 z-20 flex items-center gap-2 rounded-xl border border-copper/50 bg-ink/85 px-4 py-2 text-xs font-bold text-copper backdrop-blur-md transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||
>
|
||||
{t('openInGoogleMaps')}
|
||||
<MdOpenInNew className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
</MapViewport>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,8 @@ export function About() {
|
||||
<Stats tone="gray" />
|
||||
<Timeline tone="black" />
|
||||
<WhyUs tone="gray" />
|
||||
<Team tone="black" />
|
||||
{/* بخش «تیم ما» موقتاً غیرفعال است — بهزودی برمیگردد؛ کامپوننت و دادهها سر جایشاناند */}
|
||||
{/* <Team tone="black" /> */}
|
||||
<ContactSection />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -24,10 +24,30 @@ function DraftBadge({ className = '' }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** میانبر ویرایش پیشنویس برای هر کارمند واردشده — creator هم بدون حالت ویرایش ادمین */
|
||||
function DraftEditLink({ id, className = '' }: { id: number; className?: string }) {
|
||||
const { lang } = useLang()
|
||||
const { syncEditLang } = useAdmin()
|
||||
return (
|
||||
<Link
|
||||
to={`/admin/write/${id}`}
|
||||
onClick={() => {
|
||||
// ویرایشگر با زبان همین صفحه باز شود — کلیک از وبلاگ انگلیسی نباید
|
||||
// مقاله را در حالت فارسی باز کند (و «دیدن در سایت» را به وبلاگ فارسی ببرد)
|
||||
syncEditLang(lang)
|
||||
localStorage.setItem(`aramesh_write_lang_${id}`, lang)
|
||||
}}
|
||||
className={`border border-copper/60 bg-ink/90 px-2.5 py-1 text-xs font-bold text-copper backdrop-blur transition-colors duration-200 hover:bg-copper hover:text-char ${className}`}
|
||||
>
|
||||
{lang === 'en' ? 'Edit draft' : 'ویرایش پیشنویس'}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Blog() {
|
||||
const { articles } = useSite()
|
||||
const { t, href, lang } = useLang()
|
||||
const { isEditMode } = useAdmin()
|
||||
const { isEditMode, isAuthenticated } = useAdmin()
|
||||
|
||||
// دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده میشود
|
||||
const writeButton = isEditMode ? (
|
||||
@@ -71,8 +91,9 @@ export function Blog() {
|
||||
<div className="mb-10 flex justify-start">{writeButton}</div>
|
||||
<Reveal>
|
||||
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
|
||||
<div className="relative">
|
||||
<Link
|
||||
to={href(`/blog/${featured.slug}`)}
|
||||
to={href(`/blog/${encodeURIComponent(lang === 'en' && featured.slug_en ? featured.slug_en : featured.slug)}`)}
|
||||
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
|
||||
aria-label={`${t('readArticlePrefix')} ${featured.title}`}
|
||||
>
|
||||
@@ -109,6 +130,10 @@ export function Blog() {
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{isAuthenticated && featured.status === 'draft' && (
|
||||
<DraftEditLink id={featured.id} className="absolute top-4 z-10 ltr:right-4 rtl:left-4" />
|
||||
)}
|
||||
</div>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
|
||||
@@ -120,6 +145,9 @@ export function Blog() {
|
||||
{article.status === 'draft' && (
|
||||
<DraftBadge className="absolute top-3 ltr:left-3 rtl:right-3 z-10" />
|
||||
)}
|
||||
{isAuthenticated && article.status === 'draft' && (
|
||||
<DraftEditLink id={article.id} className="absolute top-3 z-20 ltr:right-3 rtl:left-3" />
|
||||
)}
|
||||
<ArticleCard article={article} />
|
||||
</div>
|
||||
</EditableItem>
|
||||
|
||||
@@ -24,7 +24,8 @@ export function Home() {
|
||||
<FeaturedProjects />
|
||||
<Timeline />
|
||||
<WhyUs />
|
||||
<Team />
|
||||
{/* بخش «تیم ما» موقتاً غیرفعال است — بهزودی برمیگردد؛ کامپوننت و دادهها سر جایشاناند */}
|
||||
{/* <Team /> */}
|
||||
<Articles />
|
||||
<Faq />
|
||||
<Newsletter />
|
||||
|
||||
Reference in New Issue
Block a user