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:
@@ -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": [
|
||||||
|
|||||||
@@ -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
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 خالی یعنی خاموش
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 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) => {
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
))}
|
))}
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
|
{/* آمارها فقط وقتی نمایش داده میشوند که مقداری ثبت شده باشد —
|
||||||
|
با خالیبودن، کادر خالی با خط جداکننده بیمعنا میماند؛
|
||||||
|
ویرایش/افزودن آمار از پنل ادمین (موجودیت story) انجام میشود */}
|
||||||
|
{(story.stat1_value || story.stat2_value) && (
|
||||||
<Reveal delay={0.16}>
|
<Reveal delay={0.16}>
|
||||||
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
|
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -75,6 +79,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
)}
|
||||||
|
|
||||||
{showQuote && story.quote && (
|
{showQuote && story.quote && (
|
||||||
<Reveal delay={0.2}>
|
<Reveal delay={0.2}>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,9 +88,10 @@ 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 &&
|
||||||
|
(isFullscreen ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsFullscreen(false)}
|
onClick={() => setIsFullscreen(false)}
|
||||||
@@ -100,7 +111,7 @@ export function MapViewport({
|
|||||||
>
|
>
|
||||||
<MdFullscreen className="h-5 w-5" aria-hidden="true" />
|
<MdFullscreen className="h-5 w-5" aria-hidden="true" />
|
||||||
</button>
|
</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">
|
||||||
|
|||||||
@@ -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" />
|
<Stats tone="gray" />
|
||||||
<Timeline tone="black" />
|
<Timeline tone="black" />
|
||||||
<WhyUs tone="gray" />
|
<WhyUs tone="gray" />
|
||||||
<Team tone="black" />
|
{/* بخش «تیم ما» موقتاً غیرفعال است — بهزودی برمیگردد؛ کامپوننت و دادهها سر جایشاناند */}
|
||||||
|
{/* <Team tone="black" /> */}
|
||||||
<ContactSection />
|
<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() {
|
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>
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export function Home() {
|
|||||||
<FeaturedProjects />
|
<FeaturedProjects />
|
||||||
<Timeline />
|
<Timeline />
|
||||||
<WhyUs />
|
<WhyUs />
|
||||||
<Team />
|
{/* بخش «تیم ما» موقتاً غیرفعال است — بهزودی برمیگردد؛ کامپوننت و دادهها سر جایشاناند */}
|
||||||
|
{/* <Team /> */}
|
||||||
<Articles />
|
<Articles />
|
||||||
<Faq />
|
<Faq />
|
||||||
<Newsletter />
|
<Newsletter />
|
||||||
|
|||||||
Reference in New Issue
Block a user