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
+7 -4
View File
@@ -322,10 +322,13 @@ STORY = {
], ],
"quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی می‌سازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»", "quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی می‌سازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»",
"quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش", "quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
"stat1_value": "۹۴٪", # آمارهای دموی «۹۴٪ تحویل به‌موقع» و «۵ سال پشتیبانی» خالی شدند — ادعای
"stat1_label": "تحویل به‌موقع پروژه‌ها", # بدون پشتوانه بودند و در سایت واقعی هم پاک شده‌اند؛ این‌جا هم خالی می‌مانند
"stat2_value": "۵ سال", # تا اگر روزی جدول story خالی شود، seed آن‌ها را برنگرداند
"stat2_label": "پشتیبانی پس از تحویل", "stat1_value": "",
"stat1_label": "",
"stat2_value": "",
"stat2_label": "",
"link_label": "داستان کامل ما", "link_label": "داستان کامل ما",
"link_href": "/about", "link_href": "/about",
"bento": [ "bento": [
+11 -8
View File
@@ -396,14 +396,17 @@ STORY = {
"quote_en": "\u201cA building, for us, is not just brick and concrete; it is a commitment to the quality of life of its residents. We build spaces that keep their value and identity as time passes.\u201d", "quote_en": "\u201cA building, for us, is not just brick and concrete; it is a commitment to the quality of life of its residents. We build spaces that keep their value and identity as time passes.\u201d",
"quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش", "quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
"quote_author_en": "Kamran Aramesh — CEO, Aramesh Construction Group", "quote_author_en": "Kamran Aramesh — CEO, Aramesh Construction Group",
"stat1_value": "۹۴٪", # Demo stats «94% on-time» / «5 years support» emptied — unsupported claims,
"stat1_value_en": "94%", # removed from the live site too; kept empty here so a future reseed of an
"stat1_label": "تحویل به‌موقع پروژه‌ها", # empty story table cannot bring them back
"stat1_label_en": "On-time project delivery", "stat1_value": "",
"stat2_value": "۵ سال", "stat1_value_en": "",
"stat2_value_en": "5 years", "stat1_label": "",
"stat2_label": "پشتیبانی پس از تحویل", "stat1_label_en": "",
"stat2_label_en": "Post-handover support", "stat2_value": "",
"stat2_value_en": "",
"stat2_label": "",
"stat2_label_en": "",
"link_label": "داستان کامل ما", "link_label": "داستان کامل ما",
"link_label_en": "Our full story", "link_label_en": "Our full story",
"bento": [], "bento": [],
@@ -0,0 +1,39 @@
"""زمان آخرین به‌روزرسانی مقاله‌ها و پروژه‌ها — ستون updated_at برای lastmod نقشه سایت.
نقشه سایت lastmod معتبر لازم دارد که آخرین تغییر معنادار محتوا را نشان دهد؛
تاریخ نمایشی مقاله‌ها (شمسی دست‌نویس) برای این کار مناسب نیست. ردیف‌های قدیمی
NULL می‌مانند و lastmod فقط برای ردیف‌هایی صادر می‌شود که بعداً در پنل ذخیره شده‌اند —
تاریخ جعلی همگن (همه = امروز) به‌عنوان سیگنال منفی شناخته می‌شود.
Revision ID: 0004
Revises: 0003
Create Date: 2026-10-11
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0004"
down_revision: Union[str, None] = "0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_TABLES = ("articles", "projects")
def upgrade() -> None:
for table in _TABLES:
cols = {c["name"] for c in sa.inspect(op.get_bind()).get_columns(table)}
if "updated_at" not in cols:
op.add_column(
table,
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
for table in _TABLES:
cols = {c["name"] for c in sa.inspect(op.get_bind()).get_columns(table)}
if "updated_at" in cols:
op.drop_column(table, "updated_at")
+8 -2
View File
@@ -10,8 +10,14 @@ COPY requirements.txt /srv/chatbot/requirements.txt
# (ایمیج ~۹.۷GB روی سرور بدون GPU). نسخهٔ CPU حدود ۲۰۰MB است — همین اول نصب # (ایمیج ~۹.۷GB روی سرور بدون GPU). نسخهٔ CPU حدود ۲۰۰MB است — همین اول نصب
# می‌شود تا pip در گام بعد آن را برآورده‌شده ببیند. # می‌شود تا pip در گام بعد آن را برآورده‌شده ببیند.
# روی ماشینی با GPU می‌توان این خط را حذف کرد. # روی ماشینی با GPU می‌توان این خط را حذف کرد.
RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu "torch>=2.2" # دو مرحله‌ای (PyPI از این سرور فیلتر است): (۱) خودِ torch فقط از ایندکس CPU
RUN pip install --no-cache-dir -r /srv/chatbot/requirements.txt # پی‌تورچ با --no-deps — چون وابستگی‌های torch در آن ایندکس به
# files.pythonhosted.org لینک می‌خورند؛ (۲) وابستگی‌های torch از میرور در
# دسترس — torch از قبل نصب است و pip فقط وابستگی‌های missing را می‌گیرد.
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu --no-deps "torch>=2.2" \
&& pip install --no-cache-dir --index-url "${PIP_INDEX_URL}" "torch>=2.2"
RUN pip install --no-cache-dir --index-url "${PIP_INDEX_URL}" -r /srv/chatbot/requirements.txt
COPY . /srv/chatbot/ COPY . /srv/chatbot/
+4 -4
View File
@@ -53,7 +53,7 @@ services:
- traefik.http.routers.media.entrypoints=websecure - traefik.http.routers.media.entrypoints=websecure
- traefik.http.routers.media.tls.certresolver=le - traefik.http.routers.media.tls.certresolver=le
- traefik.http.routers.media.priority=100 - traefik.http.routers.media.priority=100
- traefik.http.routers.media.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}media-rewrite,sec-headers - traefik.http.routers.media.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}media-rewrite,sec-headers@file
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*) - traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1 - traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
- traefik.http.services.media.loadbalancer.server.port=9000 - traefik.http.services.media.loadbalancer.server.port=9000
@@ -70,13 +70,13 @@ services:
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`) - traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=websecure - traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=le - traefik.http.routers.api.tls.certresolver=le
- traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers - traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood@file,sec-headers@file
# سئو: همهٔ صفحات عمومی از بک‌اند سرو می‌شوند تا متا در HTML اولیه تزریق شود — # سئو: همهٔ صفحات عمومی از بک‌اند سرو می‌شوند تا متا در HTML اولیه تزریق شود —
# مسیرهای استاتیک دقیق‌اند (Path) تا assets/ فونت‌ها از Caddy سرو شوند # مسیرهای استاتیک دقیق‌اند (Path) تا assets/ فونت‌ها از Caddy سرو شوند
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (Path(`/`) || Path(`/about`) || Path(`/services`) || Path(`/investment`) || Path(`/faq`) || Path(`/contact`) || PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/llms.txt`) || Path(`/.well-known/indexnow-key.txt`) || Path(`/daa4488a8e2905312b5efd02ad5fc614.txt`) || PathRegexp(`^/[a-f0-9]{32}\.txt$`) || Path(`/projects-images.json`) || PathRegexp(`^/projects/.+/images\.zip$`)) - traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (Path(`/`) || Path(`/about`) || Path(`/services`) || Path(`/investment`) || Path(`/faq`) || Path(`/contact`) || PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/llms.txt`) || Path(`/.well-known/indexnow-key.txt`) || Path(`/daa4488a8e2905312b5efd02ad5fc614.txt`) || PathRegexp(`^/[a-f0-9]{32}\.txt$`) || Path(`/projects-images.json`) || PathRegexp(`^/projects/.+/images\.zip$`))
- traefik.http.routers.seo.entrypoints=websecure - traefik.http.routers.seo.entrypoints=websecure
- traefik.http.routers.seo.tls.certresolver=le - traefik.http.routers.seo.tls.certresolver=le
- traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers - traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers@file
frontend: frontend:
labels: labels:
@@ -114,7 +114,7 @@ services:
- traefik.http.routers.chat.entrypoints=websecure - traefik.http.routers.chat.entrypoints=websecure
- traefik.http.routers.chat.tls.certresolver=le - traefik.http.routers.chat.tls.certresolver=le
- traefik.http.routers.chat.priority=100 - traefik.http.routers.chat.priority=100
- traefik.http.routers.chat.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers - traefik.http.routers.chat.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood@file,sec-headers@file
umami: umami:
labels: labels:
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -24,12 +24,30 @@
file_server file_server
} }
# مسیریابی SPA — فقط مسیرهای بدون پسوند (مسیرهای اپ) به index.html برمی‌گردند # مسیرهای پنل ادمین فقط SPA هستند و فایلی پشتشان نیست؛ پوسته باید با
handle { # وضعیت ۲۰۰ سرو شود وگرنه قاعدهٔ ۴۰۴ مسیرهای ناشناخته پنل را هم می‌بندد
# (و لبهٔ Traefik به‌جای پوسته، صفحهٔ خطای خودش را نشان می‌دهد). فهرست
# مسیرهای ادمین در App.tsx است؛ مسیر تازهٔ ادمین یعنی افزودن الگوی آن‌جا و این‌جا.
# robots.txt از قبل /admin را برای خزنده‌ها می‌بندد؛ این مسیرها ایندکس نمی‌شوند.
@admin path /admin /admin/*
handle @admin {
try_files {path} /index.html try_files {path} /index.html
file_server file_server
} }
# مسیرهای واقعی اپ همگی سمت بک‌اند سرو می‌شوند (روتر seo ترافیک را می‌برد)؛
# پس به این‌جا فقط مسیر ناشناخته می‌رسد — باید ۴۰۴ بدهد، نه پوستهٔ ۲۰۰ (نرم‌۴۰۴)
handle {
try_files {path}
file_server
}
# خطاها با پوستهٔ برند دیده می‌شوند — وضعیت ۴۰۴ حفظ می‌شود تا خزنده ایندکس نکند
handle_errors {
rewrite * /index.html
file_server
}
# index.html و fallback اسپا: بدون کش مرورگر تا هر استقرار فوری دیده شود # index.html و fallback اسپا: بدون کش مرورگر تا هر استقرار فوری دیده شود
header Cache-Control "no-cache" header Cache-Control "no-cache"
# فایل‌های ثابت (فونت/تصویر): یک ساعت کش مرورگر # فایل‌های ثابت (فونت/تصویر): یک ساعت کش مرورگر
+16 -1
View File
@@ -19,7 +19,7 @@
<meta property="og:locale" content="fa_IR" /> <meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json"> <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> </script>
</head> </head>
<body> <body>
@@ -31,6 +31,21 @@
document.documentElement.setAttribute('lang', 'en'); document.documentElement.setAttribute('lang', 'en');
document.documentElement.setAttribute('dir', 'ltr'); 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>
<script type="module"> <script type="module">
// تحلیل رفتار (Umami) — در زمان بیلد تزریق می‌شود؛ VITE_UMAMI_SRC خالی یعنی خاموش // تحلیل رفتار (Umami) — در زمان بیلد تزریق می‌شود؛ VITE_UMAMI_SRC خالی یعنی خاموش
+2
View File
@@ -22,6 +22,7 @@ import { applySeo, seoKeyForPath } from './seo';
import { AdminProvider, useAdmin } from './admin/AdminContext'; import { AdminProvider, useAdmin } from './admin/AdminContext';
import { AdminBar } from './admin/AdminBar'; import { AdminBar } from './admin/AdminBar';
import { AdminLoginPage } from './admin/AdminLoginPage'; import { AdminLoginPage } from './admin/AdminLoginPage';
import { AdminArticlesPage } from './admin/AdminArticlesPage';
import { ArticleWritePage } from './admin/ArticleWritePage'; import { ArticleWritePage } from './admin/ArticleWritePage';
import { ProjectWritePage } from './admin/ProjectWritePage'; import { ProjectWritePage } from './admin/ProjectWritePage';
import { LangProvider, useLang } from './i18n/lang'; import { LangProvider, useLang } from './i18n/lang';
@@ -69,6 +70,7 @@ function AnimatedRoutes() {
<Routes location={location}> <Routes location={location}>
<Route path="/admin/login" element={<AdminLoginPage />} /> <Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/admin/articles" element={<AdminArticlesPage />} />
<Route path="/admin/write" element={<ArticleWritePage />} /> <Route path="/admin/write" element={<ArticleWritePage />} />
<Route path="/admin/write/:id" element={<ArticleWritePage />} /> <Route path="/admin/write/:id" element={<ArticleWritePage />} />
<Route path="/admin/projects/new" element={<ProjectWritePage />} /> <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 items-center gap-2 text-xs font-bold text-copper">
<span className="flex h-2 w-2 rounded-full bg-copper" /> <span className="flex h-2 w-2 rounded-full bg-copper" />
حالت مدیریت {role === 'creator' ? 'حالت نویسنده' : 'حالت مدیریت'}
</span> </span>
{canManageSite && ( {canManageSite && (
@@ -174,6 +174,10 @@ export function AdminBar() {
</button> </button>
)} )}
{/* کلید زبان ویرایش فقط برای ادمین — برای نویسنده هیچ ویرایش درجایی روی
سایت وجود ندارد و دکمه بی‌اثر به نظر می‌رسید؛ زبان ویرایش نویسنده
داخل خود ویرایشگر مقاله و به‌ازای هر مقاله انتخاب می‌شود */}
{canManageSite && (
<button <button
type="button" type="button"
onClick={toggleEditLang} onClick={toggleEditLang}
@@ -186,6 +190,7 @@ export function AdminBar() {
> >
زبان ویرایش: {editLang === 'fa' ? 'فارسی' : 'English'} زبان ویرایش: {editLang === 'fa' ? 'فارسی' : 'English'}
</button> </button>
)}
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60"> <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" /> <span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
@@ -201,8 +206,17 @@ export function AdminBar() {
رمز رمز
</button> </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 <Link
to="/admin/write" to="/admin/write"
data-admin-link data-admin-link
@@ -211,6 +225,8 @@ export function AdminBar() {
نوشتن مقاله نوشتن مقاله
</Link> </Link>
{canManageSite && (
<>
<button <button
type="button" type="button"
onClick={() => void adminApi.downloadCsv('messages').catch((e) => { 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"> <div className="border-t border-ivory/10 pt-8">
<ImageField <ImageField
label="تصویر شاخص" label="تصویر شاخص"
requireEditMode={false}
value={draft.image} value={draft.image}
onSave={persistImage} onSave={persistImage}
altFallback={`پروژه ${draft.title}`} altFallback={`پروژه ${draft.title}`}
+9
View File
@@ -6,6 +6,7 @@ import { useLang } from '../i18n/lang';
import { useFieldSaver } from '../admin/AdminContext'; import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from './admin/InlineEdit'; import { InlineEdit } from './admin/InlineEdit';
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable'; import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
import { LazyOfficeMap } from '../map/LazyMaps';
const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = { const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
instagram: InstagramIcon, instagram: InstagramIcon,
@@ -261,6 +262,14 @@ export function Footer() {
</a> </a>
</li> </li>
</ul> </ul>
{/* نقشهٔ دفتر مرکزی — همان مختصاتی که در اسکیمای LocalBusiness
(geo/hasMap) به گوگل و دستیارهای AI می‌رسد؛ با رسیدن فوتر
به دید کاربر بارگیری می‌شود تا بارگذاری اولیه سبک بماند.
ارتفاع عمداً کم است (نصف کارت اطلاعات) — نقشه یک نشانگرِ
موقعیت است، نه تجربهٔ تمام‌صفحه؛ آن از لینک گوگل‌مپ می‌آید */}
<div className="mt-6 h-36">
<LazyOfficeMap className="h-full" />
</div>
</div> </div>
</div> </div>
+8 -1
View File
@@ -22,6 +22,12 @@ type ImageFieldProps = {
altFallback?: string altFallback?: string
/** پیشنهاد عنوان تصویر — پیش‌فرض «Photo 1» */ /** پیشنهاد عنوان تصویر — پیش‌فرض «Photo 1» */
titleFallback?: string titleFallback?: string
/**
* در صفحه‌های نوشتن (مقاله/پروژه) همیشه نمایش داده شود — آن‌جا حالت ویرایش
* ادمین معنا ندارد و creator هم باید به تصویر شاخص دسترسی داشته باشد.
* پیش‌فرض true: ویرایش درجای سایت فقط در حالت ویرایش ادمین.
*/
requireEditMode?: boolean
} }
const META_FIELD_CLASS = const META_FIELD_CLASS =
@@ -43,6 +49,7 @@ export function ImageField({
onSaveTitle, onSaveTitle,
altFallback = '', altFallback = '',
titleFallback = '', titleFallback = '',
requireEditMode = true,
}: ImageFieldProps) { }: ImageFieldProps) {
const { runSave, isAuthenticated, isEditMode } = useAdmin() const { runSave, isAuthenticated, isEditMode } = useAdmin()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
@@ -69,7 +76,7 @@ export function ImageField({
onCommit: onSaveAlt, onCommit: onSaveAlt,
}) })
if (!isAuthenticated || !isEditMode) return null if (!isAuthenticated || (requireEditMode && !isEditMode)) return null
const pick = async (file: File | undefined) => { const pick = async (file: File | undefined) => {
if (!file) return if (!file) return
+24 -19
View File
@@ -55,26 +55,31 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
))} ))}
</Reveal> </Reveal>
<Reveal delay={0.16}> {/* آمارها فقط وقتی نمایش داده می‌شوند که مقداری ثبت شده باشد —
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8"> با خالی‌بودن، کادر خالی با خط جداکننده بی‌معنا می‌ماند؛
<div> ویرایش/افزودن آمار از پنل ادمین (موجودیت story) انجام می‌شود */}
<p className="text-2xl font-bold text-copper"> {(story.stat1_value || story.stat2_value) && (
<InlineEdit value={story.stat1_value} onSave={(v) => saveStory('stat1_value', v)} /> <Reveal delay={0.16}>
</p> <div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
<p className="mt-2 text-sm font-light text-ivory/55"> <div>
<InlineEdit value={story.stat1_label} onSave={(v) => saveStory('stat1_label', v)} /> <p className="text-2xl font-bold text-copper">
</p> <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>
<div> </Reveal>
<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>
{showQuote && story.quote && ( {showQuote && story.quote && (
<Reveal delay={0.2}> <Reveal delay={0.2}>
+7
View File
@@ -25,6 +25,13 @@ export type MapProject = {
/** مرکز پیش‌فرض نقشه — اصفهان */ /** مرکز پیش‌فرض نقشه — اصفهان */
export const ISFAHAN_CENTER: [number, number] = [51.6675, 32.6539] 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 } 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: 'جدید', mapNewBadge: 'جدید',
mapProjectDescription: 'پروژه اجراشده در اصفهان', mapProjectDescription: 'پروژه اجراشده در اصفهان',
mapImagePlaceholder: 'تصویر پروژه', mapImagePlaceholder: 'تصویر پروژه',
mapOfficeAria: 'نقشه موقعیت دفتر مرکزی',
openInGoogleMaps: 'مشاهده در گوگل‌مپ',
} }
export type Dict = typeof fa export type Dict = typeof fa
@@ -260,6 +262,8 @@ export const en: Dict = {
mapNewBadge: 'New', mapNewBadge: 'New',
mapProjectDescription: 'Project delivered in Isfahan', mapProjectDescription: 'Project delivered in Isfahan',
mapImagePlaceholder: 'Project image', mapImagePlaceholder: 'Project image',
mapOfficeAria: 'Head office location map',
openInGoogleMaps: 'Open in Google Maps',
} }
export const dict = { fa, en } 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 { ReactNode } from 'react'
import type { ProjectsMapAllProps } from './ProjectsMapAll' import type { ProjectsMapAllProps } from './ProjectsMapAll'
import type { ProjectsMapSingleProps } from './ProjectsMapSingle' import type { ProjectsMapSingleProps } from './ProjectsMapSingle'
@@ -12,6 +12,7 @@ const ProjectsMapAll = lazy(() => import('./ProjectsMapAll').then((module) => ({
const ProjectsMapSingle = lazy(() => const ProjectsMapSingle = lazy(() =>
import('./ProjectsMapSingle').then((module) => ({ default: module.ProjectsMapSingle })), import('./ProjectsMapSingle').then((module) => ({ default: module.ProjectsMapSingle })),
) )
const OfficeMap = lazy(() => import('./OfficeMap').then((module) => ({ default: module.OfficeMap })))
/** جای‌نگهدار هم‌اندازه‌ی نقشه تا چیدمان هنگام بارگیری نلرزد */ /** جای‌نگهدار هم‌اندازه‌ی نقشه تا چیدمان هنگام بارگیری نلرزد */
function MapSkeleton({ className = '' }: { className?: string }) { function MapSkeleton({ className = '' }: { className?: string }) {
@@ -70,3 +71,43 @@ export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) {
</MapErrorBoundary> </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 label: string
/** ارتفاع/فاصله‌ها از بیرون — مثل «h-[520px]» */ /** ارتفاع/فاصله‌ها از بیرون — مثل «h-[520px]» */
className?: string className?: string
/** کف ارتفاع ظرف — نقشه‌های کوچک (فوتر) کف کمتری می‌خواهند */
minHeightClass?: string
/**
* اجازهٔ حالت تمام‌صفحه — برای نقشهٔ فوتر خاموش است: فوتر backdrop-filter دارد
* و چنین اجدادی position:fixed را در خودش حبس می‌کند؛ دکمهٔ تمام‌صفحه آن‌جا
* نقشه را داخل کادر فوتر گیر می‌اندازد. حذف دکمه، رفتاری بی‌نقص به کاربر می‌دهد.
*/
allowFullscreen?: boolean
/** محتوای شناور روی نقشه (پورتال پاپ‌آپ) */ /** محتوای شناور روی نقشه (پورتال پاپ‌آپ) */
children?: ReactNode children?: ReactNode
} }
@@ -37,6 +45,8 @@ export function MapViewport({
onRetry, onRetry,
label, label,
className = '', className = '',
minHeightClass = 'min-h-[280px]',
allowFullscreen = true,
children, children,
}: MapViewportProps) { }: MapViewportProps) {
const { t } = useLang() const { t } = useLang()
@@ -66,7 +76,7 @@ export function MapViewport({
const shellClass = isFullscreen const shellClass = isFullscreen
? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0' ? '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 ( return (
<div <div
@@ -78,29 +88,30 @@ export function MapViewport({
است تا هم بستر موقعیت‌یابی فرزندان مپ‌لیبر باشد و هم هشدار است تا هم بستر موقعیت‌یابی فرزندان مپ‌لیبر باشد و هم هشدار
«container has a non-static position» و محاسبه‌ی اشتباه آفست اسکرول «container has a non-static position» و محاسبه‌ی اشتباه آفست اسکرول
رخ ندهد — ترتیب بار شدن CSS هیچ‌وقت تعیین‌کننده نباشد */} رخ ندهد — ترتیب بار شدن CSS هیچ‌وقت تعیین‌کننده نباشد */}
<div ref={containerRef} className="relative h-full min-h-[280px] w-full" /> <div ref={containerRef} className={`relative h-full ${minHeightClass} w-full`} />
{isFullscreen ? ( {allowFullscreen &&
<button (isFullscreen ? (
type="button" <button
onClick={() => setIsFullscreen(false)} type="button"
aria-label={t('mapExitFullscreen')} onClick={() => setIsFullscreen(false)}
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" 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')} <MdClose className="h-4 w-4" aria-hidden="true" />
</button> {t('mapExitFullscreen')}
) : ( </button>
<button ) : (
type="button" <button
onClick={() => setIsFullscreen(true)} type="button"
aria-label={t('mapFullscreen')} onClick={() => setIsFullscreen(true)}
title={t('mapFullscreen')} aria-label={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" 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> <MdFullscreen className="h-5 w-5" aria-hidden="true" />
)} </button>
))}
{status !== 'ready' && ( {status !== 'ready' && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-char/70 backdrop-blur-sm"> <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" /> <Stats tone="gray" />
<Timeline tone="black" /> <Timeline tone="black" />
<WhyUs tone="gray" /> <WhyUs tone="gray" />
<Team tone="black" /> {/* بخش «تیم ما» موقتاً غیرفعال است — به‌زودی برمی‌گردد؛ کامپوننت و داده‌ها سر جایشان‌اند */}
{/* <Team tone="black" /> */}
<ContactSection /> <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() { export function Blog() {
const { articles } = useSite() const { articles } = useSite()
const { t, href, lang } = useLang() const { t, href, lang } = useLang()
const { isEditMode } = useAdmin() const { isEditMode, isAuthenticated } = useAdmin()
// دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده می‌شود // دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده می‌شود
const writeButton = isEditMode ? ( const writeButton = isEditMode ? (
@@ -71,8 +91,9 @@ export function Blog() {
<div className="mb-10 flex justify-start">{writeButton}</div> <div className="mb-10 flex justify-start">{writeButton}</div>
<Reveal> <Reveal>
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}> <EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
<div className="relative">
<Link <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" 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}`} aria-label={`${t('readArticlePrefix')} ${featured.title}`}
> >
@@ -109,6 +130,10 @@ export function Blog() {
</div> </div>
</div> </div>
</Link> </Link>
{isAuthenticated && featured.status === 'draft' && (
<DraftEditLink id={featured.id} className="absolute top-4 z-10 ltr:right-4 rtl:left-4" />
)}
</div>
</EditableItem> </EditableItem>
</Reveal> </Reveal>
@@ -120,6 +145,9 @@ export function Blog() {
{article.status === 'draft' && ( {article.status === 'draft' && (
<DraftBadge className="absolute top-3 ltr:left-3 rtl:right-3 z-10" /> <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} /> <ArticleCard article={article} />
</div> </div>
</EditableItem> </EditableItem>
+2 -1
View File
@@ -24,7 +24,8 @@ export function Home() {
<FeaturedProjects /> <FeaturedProjects />
<Timeline /> <Timeline />
<WhyUs /> <WhyUs />
<Team /> {/* بخش «تیم ما» موقتاً غیرفعال است — به‌زودی برمی‌گردد؛ کامپوننت و داده‌ها سر جایشان‌اند */}
{/* <Team /> */}
<Articles /> <Articles />
<Faq /> <Faq />
<Newsletter /> <Newsletter />