chore: همگام‌سازی وضعیت زندهٔ سرور با مخزن
CI / tests (push) Waiting to run
CI / images (push) Blocked by required conditions

شامل کارهای چند نشست موازی که تا این‌جا فقط روی سرور بود:
- نقشهٔ دفتر مرکزی در فوتر (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:
aramesh-site deploy
2026-10-26 12:28:28 +00:00
parent 884902e054
commit 14660586c4
23 changed files with 488 additions and 2137 deletions
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -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
View File
@@ -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 خالی یعنی خاموش
+2
View File
@@ -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 />} />
+136
View File
@@ -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>
)
}
+19 -3
View File
@@ -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) => {
+1
View File
@@ -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}`}
+9
View File
@@ -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>
+8 -1
View File
@@ -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
+24 -19
View File
@@ -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}>
+7
View File
@@ -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 }
/**
+4
View File
@@ -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 }
+42 -1
View File
@@ -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>
)
}
+34 -23
View File
@@ -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">
+63
View File
@@ -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>
)
}
+2 -1
View File
@@ -17,7 +17,8 @@ export function About() {
<Stats tone="gray" />
<Timeline tone="black" />
<WhyUs tone="gray" />
<Team tone="black" />
{/* بخش «تیم ما» موقتاً غیرفعال است — به‌زودی برمی‌گردد؛ کامپوننت و داده‌ها سر جایشان‌اند */}
{/* <Team tone="black" /> */}
<ContactSection />
</>
)
+30 -2
View File
@@ -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>
+2 -1
View File
@@ -24,7 +24,8 @@ export function Home() {
<FeaturedProjects />
<Timeline />
<WhyUs />
<Team />
{/* بخش «تیم ما» موقتاً غیرفعال است — به‌زودی برمی‌گردد؛ کامپوننت و داده‌ها سر جایشان‌اند */}
{/* <Team /> */}
<Articles />
<Faq />
<Newsletter />