Compare commits
36
Commits
new-one2
...
68863eb44e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68863eb44e | ||
|
|
679195b590 | ||
|
|
b070125f51 | ||
|
|
d513512b87 | ||
|
|
30038d5103 | ||
|
|
d2ed1b1c76 | ||
|
|
5530c141a2 | ||
|
|
cb6fd6f5af | ||
|
|
1800909661 | ||
|
|
c0eaac1daa | ||
|
|
79ebe69221 | ||
|
|
e5fe31f6ba | ||
|
|
05945099a1 | ||
|
|
399a76da8e | ||
|
|
659b1a1943 | ||
|
|
fb1d53d97a | ||
|
|
79813ff52c | ||
|
|
1b571387df | ||
|
|
e1bf404455 | ||
|
|
18e226ad47 | ||
|
|
182a1bb674 | ||
|
|
0c87a287a3 | ||
|
|
edcbd0dc58 | ||
|
|
5cf7b51686 | ||
|
|
531e8d63c1 | ||
|
|
dc7a92f040 | ||
|
|
11552d6124 | ||
|
|
7b4783bb3e | ||
|
|
98c7977a19 | ||
|
|
c9e0720764 | ||
|
|
87b744c9c7 | ||
|
|
275c3d6bb3 | ||
|
|
ef619c635e | ||
|
|
d55c8cc1e5 | ||
|
|
9d0015808c | ||
|
|
8600d2d4a3 |
@@ -9,6 +9,12 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# FFmpeg — نرمالسازی رسانه به WebP برای ورودیهایی که Pillow نمیخواند
|
||||||
|
# (ویدیو → WebP متحرک، فرمتهای تصویری خاص). مسیر Pillow به آن وابسته نیست.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY alembic.ini ./
|
COPY alembic.ini ./
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
|
|||||||
+239
-79
@@ -3,8 +3,13 @@
|
|||||||
import csv
|
import csv
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -17,9 +22,10 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from .. import models, schemas
|
from .. import models, schemas
|
||||||
from ..auth import create_access_token, get_current_admin, hash_password, verify_password
|
from ..auth import create_access_token, get_current_admin, get_user_from_token, hash_password, oauth2_scheme, verify_password
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..rate_limit import clear as clear_rate_limit
|
from ..rate_limit import clear as clear_rate_limit
|
||||||
@@ -113,7 +119,46 @@ from ..schemas import (
|
|||||||
auth_router = APIRouter()
|
auth_router = APIRouter()
|
||||||
|
|
||||||
# روتر محافظتشده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند
|
# روتر محافظتشده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند
|
||||||
router = APIRouter(dependencies=[Depends(get_current_admin)])
|
async def admin_access_guard(
|
||||||
|
request: Request,
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> models.AdminUser:
|
||||||
|
"""نقشگارد — 'admin' همهکاره؛ 'creator' فقط مقالهنویسی: ساخت پیشنویس،
|
||||||
|
خواندن/ویرایش پیشنویس خودش، و آپلود تصویر. بقیهٔ مسیرهای مدیریتی ۴۰۳."""
|
||||||
|
user = get_user_from_token(token, db)
|
||||||
|
if user.role == "admin":
|
||||||
|
return user
|
||||||
|
if user.role != "creator":
|
||||||
|
raise HTTPException(status_code=403, detail="نقش ناشناخته")
|
||||||
|
|
||||||
|
path = request.url.path.rstrip("/")
|
||||||
|
method = request.method
|
||||||
|
|
||||||
|
# آپلود تصویر مقالهها — مجاز
|
||||||
|
if path in ("/api/admin/upload", "/api/admin/import-image") and method == "POST":
|
||||||
|
return user
|
||||||
|
# فهرست و ساخت مقاله — مجاز (ساخت همیشه پیشنویس است و نویسندهاش ثبت میشود)
|
||||||
|
if path == "/api/admin/articles" and method in ("GET", "POST"):
|
||||||
|
return user
|
||||||
|
# خواندن/ویرایش پیشنویس خودِ نویسنده — مالکیت در هندلر هم دوباره چک میشود
|
||||||
|
m = re.fullmatch(r"/api/admin/articles/(\d+)", path)
|
||||||
|
if m and method in ("GET", "PUT", "PATCH"):
|
||||||
|
article = db.get(models.Article, int(m.group(1)))
|
||||||
|
if article is not None and article.author_id == user.id:
|
||||||
|
return user
|
||||||
|
# ۴۰۴ نه ۴۰۳ — مقالههای بقیه برای نویسنده اصلاً وجود ندارند
|
||||||
|
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||||
|
raise HTTPException(status_code=403, detail="دسترسی نویسنده محدود است")
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
dependencies=[
|
||||||
|
Depends(get_current_admin),
|
||||||
|
Depends(admin_access_guard),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@auth_router.post("/auth/login", response_model=TokenOut)
|
@auth_router.post("/auth/login", response_model=TokenOut)
|
||||||
@@ -127,7 +172,7 @@ def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
|
|||||||
detail="نام کاربری یا رمز عبور اشتباه است",
|
detail="نام کاربری یا رمز عبور اشتباه است",
|
||||||
)
|
)
|
||||||
clear_rate_limit(request)
|
clear_rate_limit(request)
|
||||||
return TokenOut(access_token=create_access_token(user.username))
|
return TokenOut(access_token=create_access_token(user.username, user.role), role=user.role)
|
||||||
|
|
||||||
|
|
||||||
@auth_router.post("/auth/change-password")
|
@auth_router.post("/auth/change-password")
|
||||||
@@ -144,13 +189,21 @@ def change_password(
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
|
def register_crud(
|
||||||
|
path: str, model, out_schema, create_schema, patch_schema=None,
|
||||||
|
creator_scoped: bool = False,
|
||||||
|
) -> None:
|
||||||
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
||||||
حذف و ترتیببندی) روی روتر مدیریت."""
|
حذف و ترتیببندی) روی روتر مدیریت.
|
||||||
|
|
||||||
|
creator_scoped=True (فقط مقالهها): نقش creator فقط رکوردهای خودش را میبیند،
|
||||||
|
ساختش همیشه پیشنویس است و ویرایشش هرگز منتشر نمیشود."""
|
||||||
|
|
||||||
@router.get(f"/{path}", response_model=list[out_schema])
|
@router.get(f"/{path}", response_model=list[out_schema])
|
||||||
def list_items(db: Session = Depends(get_db)):
|
def list_items(db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||||
query = db.query(model)
|
query = db.query(model)
|
||||||
|
if creator_scoped and admin.role == "creator":
|
||||||
|
query = query.filter(model.author_id == admin.id)
|
||||||
if hasattr(model, "position"):
|
if hasattr(model, "position"):
|
||||||
query = query.order_by(model.position, model.id)
|
query = query.order_by(model.position, model.id)
|
||||||
else:
|
else:
|
||||||
@@ -158,8 +211,13 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
|||||||
return query.all()
|
return query.all()
|
||||||
|
|
||||||
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
||||||
def create_item(payload: create_schema, db: Session = Depends(get_db)):
|
def create_item(payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||||
item = model(**payload.model_dump())
|
item = model(**payload.model_dump())
|
||||||
|
if creator_scoped:
|
||||||
|
# نویسنده ثبت میشود؛ ساخت توسط creator همیشه پیشنویس است
|
||||||
|
item.author_id = admin.id
|
||||||
|
if admin.role == "creator":
|
||||||
|
item.status = "draft"
|
||||||
if hasattr(model, "position"):
|
if hasattr(model, "position"):
|
||||||
# آیتم جدید در انتهای لیست قرار میگیرد
|
# آیتم جدید در انتهای لیست قرار میگیرد
|
||||||
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
|
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
|
||||||
@@ -173,19 +231,26 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
@router.get(f"/{path}/{{item_id}}", response_model=out_schema)
|
@router.get(f"/{path}/{{item_id}}", response_model=out_schema)
|
||||||
def get_item(item_id: int, db: Session = Depends(get_db)):
|
def get_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||||
item = db.get(model, item_id)
|
item = db.get(model, item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||||
|
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
|
||||||
|
# مقالهٔ بقیه برای creator وجود ندارد
|
||||||
|
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||||
return item
|
return item
|
||||||
|
|
||||||
@router.put(f"/{path}/{{item_id}}", response_model=out_schema)
|
@router.put(f"/{path}/{{item_id}}", response_model=out_schema)
|
||||||
def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db)):
|
def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||||
item = db.get(model, item_id)
|
item = db.get(model, item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||||
|
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
|
||||||
|
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||||
for key, value in payload.model_dump().items():
|
for key, value in payload.model_dump().items():
|
||||||
setattr(item, key, value)
|
setattr(item, key, value)
|
||||||
|
if creator_scoped and admin.role == "creator":
|
||||||
|
item.status = "draft" # نویسنده حق انتشار ندارد
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
@@ -197,13 +262,17 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
|||||||
if patch_schema is not None:
|
if patch_schema is not None:
|
||||||
|
|
||||||
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
|
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
|
||||||
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)):
|
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||||
"""ویرایش جزئی — فقط فیلدهای ارسالشده بهروزرسانی میشوند (برای ویرایش زنده)."""
|
"""ویرایش جزئی — فقط فیلدهای ارسالشده بهروزرسانی میشوند (برای ویرایش زنده)."""
|
||||||
item = db.get(model, item_id)
|
item = db.get(model, item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||||
|
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
|
||||||
|
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||||
setattr(item, key, value)
|
setattr(item, key, value)
|
||||||
|
if creator_scoped and admin.role == "creator":
|
||||||
|
item.status = "draft" # نویسنده حق انتشار ندارد
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
@@ -213,7 +282,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
@router.post(f"/{path}/reorder", response_model=list[out_schema])
|
@router.post(f"/{path}/reorder", response_model=list[out_schema])
|
||||||
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)):
|
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||||
|
if creator_scoped and admin.role == "creator":
|
||||||
|
raise HTTPException(status_code=403, detail="ترتیب مقالهها فقط توسط مدیر تغییر میکند")
|
||||||
"""ذخیره ترتیب جدید آیتمها بر اساس لیست idهای ارسالی."""
|
"""ذخیره ترتیب جدید آیتمها بر اساس لیست idهای ارسالی."""
|
||||||
if not hasattr(model, "position"):
|
if not hasattr(model, "position"):
|
||||||
raise HTTPException(status_code=400, detail="این منبع از ترتیببندی پشتیبانی نمیکند")
|
raise HTTPException(status_code=400, detail="این منبع از ترتیببندی پشتیبانی نمیکند")
|
||||||
@@ -227,7 +298,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
|||||||
return db.query(model).order_by(model.position, model.id).all()
|
return db.query(model).order_by(model.position, model.id).all()
|
||||||
|
|
||||||
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
||||||
def delete_item(item_id: int, db: Session = Depends(get_db)):
|
def delete_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||||
|
if creator_scoped and admin.role == "creator":
|
||||||
|
raise HTTPException(status_code=403, detail="حذف مقاله فقط توسط مدیر انجام میشود")
|
||||||
item = db.get(model, item_id)
|
item = db.get(model, item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||||
@@ -244,7 +317,7 @@ register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOu
|
|||||||
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
|
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
|
||||||
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
|
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
|
||||||
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
|
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
|
||||||
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch)
|
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch, creator_scoped=True)
|
||||||
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
|
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
|
||||||
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
|
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
|
||||||
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
|
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
|
||||||
@@ -263,41 +336,153 @@ register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
|
|||||||
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
|
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
|
||||||
|
|
||||||
|
|
||||||
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمیگرداند تا بکاند ذخیرهسازی قابل تعویض باشد
|
# آپلود رسانه به Object Storage (MinIO) — هر ورودی قابلرمزگشایی به WebP نرمال میشود:
|
||||||
ALLOWED_IMAGE_TYPES = {
|
# تصویر ثابت → WebP تکفریمی | gif/apng/webp متحرک → WebP متحرک | ویدیو → WebP متحرک با سقف.
|
||||||
"image/jpeg": "jpg",
|
# URL نسبی برمیگردد تا بکاند ذخیرهسازی قابل تعویض باشد.
|
||||||
"image/png": "png",
|
MAX_DIMENSION = 2000 # سقف ضلع تصویر ثابت — ابعاد بزرگ اصلیترین عامل کندی سایت است
|
||||||
"image/webp": "webp",
|
MAX_ANIM_DIMENSION = 800 # انیمیشن: سقف ضلع فریم
|
||||||
"image/gif": "gif",
|
MAX_ANIM_FRAMES = 150 # انیمیشن: سقف فریم
|
||||||
"image/avif": "avif",
|
VIDEO_MAX_SECONDS = 8 # ویدیو: سقف مدت ورودی
|
||||||
}
|
VIDEO_FPS = 12 # ویدیو: نرخ فریم خروجی
|
||||||
|
VIDEO_WEBP_LIMIT = 5 * 1024 * 1024 # انیمیشن ویدیویی حجیمتر از این → فقط فریم پوستر
|
||||||
|
WEBP_QUALITY = 82
|
||||||
|
WEBP_CONTENT_TYPE = "image/webp"
|
||||||
|
|
||||||
# بهینهسازی در زمان آپلود — ابعاد بزرگ و فشردهسازی ضعیف، اصلیترین عامل کندی سایت است
|
SUPPORTED_MEDIA_DETAIL = "فرمت پشتیبانی نمیشود — فقط تصویر یا ویدیویی که بتوان به WebP تبدیل کرد مجاز است"
|
||||||
MAX_DIMENSION = 2000
|
|
||||||
REENCODE_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
|
||||||
|
|
||||||
|
|
||||||
def _optimize_image(data: bytes, content_type: str) -> tuple[bytes, str, int, int]:
|
def _to_webp_mode(img: Image.Image) -> Image.Image:
|
||||||
"""تصویر را به حداکثر ۲۰۰۰ پیکسل محدود و دوباره فشرده میکند؛
|
"""ذخیرهٔ WebP در Pillow فقط RGB/RGBA/L را میپذیرد — مدهای دیگر با حفظ شفافیت تبدیل میشوند."""
|
||||||
اگر خروجی بزرگتر از اصلی شد، همان اصلی برگردد. (gif/avif دستنخورده)"""
|
if img.mode in ("RGB", "RGBA", "L"):
|
||||||
img = Image.open(io.BytesIO(data))
|
return img
|
||||||
|
has_alpha = img.mode in ("LA", "PA") or (img.mode == "P" and "transparency" in img.info)
|
||||||
|
return img.convert("RGBA" if has_alpha else "RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def _webp_still(img: Image.Image) -> tuple[bytes, int, int]:
|
||||||
|
"""تکفریم → WebP؛ چرخش EXIF اعمال، متادیتا حذف و سقف ضلع ۲۰۰۰ پیکسل رعایت میشود."""
|
||||||
img = ImageOps.exif_transpose(img)
|
img = ImageOps.exif_transpose(img)
|
||||||
orig_w, orig_h = img.size
|
|
||||||
if max(img.size) > MAX_DIMENSION:
|
if max(img.size) > MAX_DIMENSION:
|
||||||
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
|
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
|
||||||
|
img = _to_webp_mode(img)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
if content_type == "image/jpeg":
|
img.save(buf, "WEBP", quality=WEBP_QUALITY, method=4)
|
||||||
if img.mode not in ("RGB", "L"):
|
return buf.getvalue(), *img.size
|
||||||
img = img.convert("RGB")
|
|
||||||
img.save(buf, "JPEG", quality=82, optimize=True, progressive=True)
|
|
||||||
elif content_type == "image/png":
|
def _webp_animated(img: Image.Image) -> tuple[bytes, int, int] | None:
|
||||||
img.save(buf, "PNG", optimize=True)
|
"""چندفریم (gif/apng/webp متحرک) → WebP متحرک؛ None یعنی ورودی تکفریمی است.
|
||||||
else:
|
فریمهای بیش از سقف رها میشوند تا حافظه و حجم خروجی مهار شود."""
|
||||||
img.save(buf, "WEBP", quality=82, method=4)
|
total = getattr(img, "n_frames", 1)
|
||||||
optimized = buf.getvalue()
|
if total <= 1:
|
||||||
if len(optimized) < len(data):
|
return None
|
||||||
return optimized, content_type, *img.size
|
count = min(total, MAX_ANIM_FRAMES)
|
||||||
return data, content_type, orig_w, orig_h
|
frames: list[Image.Image] = []
|
||||||
|
durations: list[int] = []
|
||||||
|
for i in range(count):
|
||||||
|
img.seek(i)
|
||||||
|
durations.append(int(img.info.get("duration") or 100))
|
||||||
|
frame = img.convert("RGBA")
|
||||||
|
if max(frame.size) > MAX_ANIM_DIMENSION:
|
||||||
|
frame.thumbnail((MAX_ANIM_DIMENSION, MAX_ANIM_DIMENSION), Image.LANCZOS)
|
||||||
|
frames.append(frame)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
frames[0].save(
|
||||||
|
buf, "WEBP", save_all=True, append_images=frames[1:],
|
||||||
|
duration=durations, loop=0, quality=WEBP_QUALITY, method=4,
|
||||||
|
)
|
||||||
|
return buf.getvalue(), *frames[0].size
|
||||||
|
|
||||||
|
|
||||||
|
def _ffprobe_video(path: str) -> tuple[dict | None, dict]:
|
||||||
|
"""استریم ویدیوی اول + مشخصات فرمت با ffprobe — استریم None یعنی ویدیویی در فایل نیست."""
|
||||||
|
ffprobe = shutil.which("ffprobe")
|
||||||
|
if ffprobe is None:
|
||||||
|
return None, {}
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[ffprobe, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", path],
|
||||||
|
capture_output=True, timeout=20,
|
||||||
|
)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return None, {}
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return None, {}
|
||||||
|
try:
|
||||||
|
info = json.loads(proc.stdout or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None, {}
|
||||||
|
video = next((s for s in info.get("streams") or [] if s.get("codec_type") == "video"), None)
|
||||||
|
return video, info.get("format") or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ffmpeg(args: list[str]) -> None:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(args, capture_output=True, timeout=60)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise HTTPException(status_code=415, detail="تبدیل رسانه بیش از حد طول کشید") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL) from exc
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
||||||
|
|
||||||
|
|
||||||
|
def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]:
|
||||||
|
"""مسیر FFmpeg — ورودیهایی که Pillow نمیخواند: ویدیو و فرمتهای تصویری خاص.
|
||||||
|
ویدیو تا ۸ ثانیه با ۱۲fps به WebP متحرک میرسد؛ خروجی حجیم → فقط فریم پوستر."""
|
||||||
|
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
|
||||||
|
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
src = os.path.join(tmp, "src")
|
||||||
|
dst = os.path.join(tmp, "out.webp")
|
||||||
|
with open(src, "wb") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
video, fmt = _ffprobe_video(src)
|
||||||
|
if video is None:
|
||||||
|
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
||||||
|
ff = shutil.which("ffmpeg") or "ffmpeg"
|
||||||
|
animated = float(fmt.get("duration") or 0) >= 0.5
|
||||||
|
video_vf = f"fps={VIDEO_FPS},scale='min({MAX_ANIM_DIMENSION},iw)':-2:flags=lanczos"
|
||||||
|
still_vf = f"scale='min({MAX_DIMENSION},iw)':-2:flags=lanczos"
|
||||||
|
|
||||||
|
def run_convert(extra: list[str], vf: str) -> None:
|
||||||
|
_run_ffmpeg([ff, "-y", "-v", "error", "-i", src, *extra, "-vf", vf,
|
||||||
|
"-c:v", "libwebp", "-q:v", str(WEBP_QUALITY), "-map_metadata", "-1", dst])
|
||||||
|
|
||||||
|
# ویدیو → متحرک با سقف فریمنرخ/ضلع/مدت؛ تصویر خاص → تکفریم
|
||||||
|
if animated:
|
||||||
|
run_convert(["-t", str(VIDEO_MAX_SECONDS)], video_vf)
|
||||||
|
else:
|
||||||
|
run_convert(["-frames:v", "1"], still_vf)
|
||||||
|
with open(dst, "rb") as fh:
|
||||||
|
out = fh.read()
|
||||||
|
# انیمیشن ویدیویی ناگذیر حجیم → فقط فریم پوستر
|
||||||
|
if animated and len(out) > VIDEO_WEBP_LIMIT:
|
||||||
|
run_convert(["-frames:v", "1"], still_vf)
|
||||||
|
with open(dst, "rb") as fh:
|
||||||
|
out = fh.read()
|
||||||
|
# ffprobe روی WebP عرض/ارتفاع صفر برمیگرداند — ابعاد از خود خروجی با Pillow
|
||||||
|
try:
|
||||||
|
width, height = Image.open(io.BytesIO(out)).size
|
||||||
|
except Exception:
|
||||||
|
width = height = None
|
||||||
|
return out, width, height
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_media(data: bytes) -> tuple[bytes, int | None, int | None]:
|
||||||
|
"""هر ورودی قابلرمزگشایی → WebP. اول Pillow (دقیقترین برای EXIF/شفافیت/انیمیشن)؛
|
||||||
|
اگر رمزگشایی نشد، FFmpeg (ویدیو و فرمتهای خاص). هیچکدام نشد → ۴۱۵."""
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(data))
|
||||||
|
animated = _webp_animated(img)
|
||||||
|
if animated is not None:
|
||||||
|
return animated
|
||||||
|
return _webp_still(img)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _ffmpeg_webp(data)
|
||||||
|
|
||||||
|
|
||||||
def _readable_key(name: str, ext: str) -> str:
|
def _readable_key(name: str, ext: str) -> str:
|
||||||
@@ -313,10 +498,9 @@ def _readable_key(name: str, ext: str) -> str:
|
|||||||
return f"{datetime.now(timezone.utc):%Y/%m}/{base}.{ext}"
|
return f"{datetime.now(timezone.utc):%Y/%m}/{base}.{ext}"
|
||||||
|
|
||||||
|
|
||||||
def _store_image(data: bytes, content_type: str, name: str) -> str:
|
def _store_image(data: bytes, name: str) -> str:
|
||||||
"""ذخیره تصویر در Object Storage با کلید خوانا — URL نسبی برمیگردد."""
|
"""ذخیرهٔ WebP در Object Storage با کلید خوانا — URL نسبی برمیگردد."""
|
||||||
ext = ALLOWED_IMAGE_TYPES[content_type]
|
key = _readable_key(name, "webp")
|
||||||
key = _readable_key(name, ext)
|
|
||||||
s3 = boto3.client(
|
s3 = boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
endpoint_url=settings.s3_endpoint,
|
endpoint_url=settings.s3_endpoint,
|
||||||
@@ -325,7 +509,7 @@ def _store_image(data: bytes, content_type: str, name: str) -> str:
|
|||||||
region_name="us-east-1",
|
region_name="us-east-1",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
|
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=WEBP_CONTENT_TYPE)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
||||||
return f"{settings.media_public_base}/{key}"
|
return f"{settings.media_public_base}/{key}"
|
||||||
@@ -337,14 +521,6 @@ async def upload_media(
|
|||||||
name: str = Form(""),
|
name: str = Form(""),
|
||||||
_admin: models.AdminUser = Depends(get_current_admin),
|
_admin: models.AdminUser = Depends(get_current_admin),
|
||||||
):
|
):
|
||||||
content_type = (file.content_type or "").lower()
|
|
||||||
ext = ALLOWED_IMAGE_TYPES.get(content_type)
|
|
||||||
if ext is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=415,
|
|
||||||
detail="فرمت فایل پشتیبانی نمیشود (مجاز: jpg، png، webp، gif، avif)",
|
|
||||||
)
|
|
||||||
|
|
||||||
max_bytes = settings.media_max_upload_mb * 1024 * 1024
|
max_bytes = settings.media_max_upload_mb * 1024 * 1024
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
if len(data) == 0:
|
if len(data) == 0:
|
||||||
@@ -355,23 +531,19 @@ async def upload_media(
|
|||||||
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
|
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||||
)
|
)
|
||||||
|
|
||||||
width = height = None
|
# هر ورودی قابلرمزگشایی (تصویر رایج/خاص، متحرک، ویدیو) → WebP نرمال — روی ترد،
|
||||||
if content_type in REENCODE_TYPES:
|
# تا رمزگذاری فایل حجیم رویدادلوپ و بقیهٔ درخواستها را قفل نکند
|
||||||
try:
|
data, width, height = await run_in_threadpool(_normalize_media, data)
|
||||||
data, content_type, width, height = _optimize_image(data, content_type)
|
|
||||||
except Exception:
|
|
||||||
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره میشود
|
|
||||||
pass
|
|
||||||
|
|
||||||
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
|
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
|
||||||
display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
|
display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
|
||||||
url = _store_image(data, content_type, display_name)
|
url = _store_image(data, display_name)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"url": url,
|
"url": url,
|
||||||
"key": url.removeprefix(f"{settings.media_public_base}/"),
|
"key": url.removeprefix(f"{settings.media_public_base}/"),
|
||||||
"size": len(data),
|
"size": len(data),
|
||||||
"content_type": content_type,
|
"content_type": WEBP_CONTENT_TYPE,
|
||||||
"width": width,
|
"width": width,
|
||||||
"height": height,
|
"height": height,
|
||||||
}
|
}
|
||||||
@@ -414,15 +586,7 @@ async def import_image(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=15) as response:
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
content_type = (response.headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
|
||||||
if content_type not in ALLOWED_IMAGE_TYPES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=415,
|
|
||||||
detail="فرمت تصویر پشتیبانی نمیشود (مجاز: jpg، png، webp، gif، avif)",
|
|
||||||
)
|
|
||||||
data = response.read(max_bytes + 1)
|
data = response.read(max_bytes + 1)
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=502, detail="دانلود تصویر از نشانی ناموفق بود")
|
raise HTTPException(status_code=502, detail="دانلود تصویر از نشانی ناموفق بود")
|
||||||
if len(data) == 0:
|
if len(data) == 0:
|
||||||
@@ -433,22 +597,18 @@ async def import_image(
|
|||||||
detail=f"حجم تصویر بیش از {settings.media_max_upload_mb} مگابایت است",
|
detail=f"حجم تصویر بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||||
)
|
)
|
||||||
|
|
||||||
width = height = None
|
# هدر Content-Type دروغ میگوید — خود بدنه رمزگشایی و به WebP نرمال میشود (روی ترد)
|
||||||
if content_type in REENCODE_TYPES:
|
data, width, height = await run_in_threadpool(_normalize_media, data)
|
||||||
try:
|
|
||||||
data, content_type, width, height = _optimize_image(data, content_type)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# نام خوانا از مسیر نشانی — بدون پارامترهای کوئری
|
# نام خوانا از مسیر نشانی — بدون پارامترهای کوئری
|
||||||
url_name = urlparse(payload.url).path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
url_name = urlparse(payload.url).path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||||
media_url = _store_image(data, content_type, url_name)
|
media_url = _store_image(data, url_name)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"url": media_url,
|
"url": media_url,
|
||||||
"key": media_url.removeprefix(f"{settings.media_public_base}/"),
|
"key": media_url.removeprefix(f"{settings.media_public_base}/"),
|
||||||
"size": len(data),
|
"size": len(data),
|
||||||
"content_type": content_type,
|
"content_type": WEBP_CONTENT_TYPE,
|
||||||
"width": width,
|
"width": width,
|
||||||
"height": height,
|
"height": height,
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-7
@@ -21,17 +21,14 @@ def verify_password(password: str, hashed: str) -> bool:
|
|||||||
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(username: str) -> str:
|
def create_access_token(username: str, role: str = "admin") -> str:
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
payload = {"sub": username, "exp": expire}
|
payload = {"sub": username, "role": role, "exp": expire}
|
||||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||||
|
|
||||||
|
|
||||||
def get_current_admin(
|
def get_user_from_token(token: str, db: Session) -> AdminUser:
|
||||||
token: str = Depends(oauth2_scheme),
|
"""decode توکن و برگرداندن کاربر — هم برای get_current_admin و هم نقشگارد"""
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> AdminUser:
|
|
||||||
"""وابستگی محافظتشده — فقط با توکن JWT معتبر دسترسی میدهد."""
|
|
||||||
credentials_error = HTTPException(
|
credentials_error = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="احراز هویت نامعتبر است",
|
detail="احراز هویت نامعتبر است",
|
||||||
@@ -50,3 +47,11 @@ def get_current_admin(
|
|||||||
if user is None:
|
if user is None:
|
||||||
raise credentials_error
|
raise credentials_error
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_admin(
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> AdminUser:
|
||||||
|
"""وابستگی محافظتشده — فقط با توکن JWT معتبر دسترسی میدهد."""
|
||||||
|
return get_user_from_token(token, db)
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class Settings(BaseSettings):
|
|||||||
s3_bucket: str = "aramesh-media"
|
s3_bucket: str = "aramesh-media"
|
||||||
# پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی میکند
|
# پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی میکند
|
||||||
media_public_base: str = "/media"
|
media_public_base: str = "/media"
|
||||||
media_max_upload_mb: int = 10
|
# رندرهای معماری تا ~۳۰ مگابایت — لبه (Traefik api-bodylimit = ۴۰MB) باید همیشه بزرگتر باشد
|
||||||
|
media_max_upload_mb: int = 35
|
||||||
|
|
||||||
# نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
|
# نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
|
||||||
site_url: str = ""
|
site_url: str = ""
|
||||||
|
|||||||
@@ -27,8 +27,16 @@ EN_COLUMNS: list[tuple[str, str, str]] = [
|
|||||||
("projects", "year_en", "VARCHAR(64)"),
|
("projects", "year_en", "VARCHAR(64)"),
|
||||||
("projects", "area_en", "VARCHAR(64)"),
|
("projects", "area_en", "VARCHAR(64)"),
|
||||||
("projects", "floors_en", "VARCHAR(64)"),
|
("projects", "floors_en", "VARCHAR(64)"),
|
||||||
("projects", "units_en", "VARCHAR(64)"),
|
("projects", "land_area", "VARCHAR(64)"),
|
||||||
|
("projects", "pilot_floors", "VARCHAR(32)"),
|
||||||
|
("projects", "units_residential", "VARCHAR(32)"),
|
||||||
|
("projects", "units_commercial", "VARCHAR(32)"),
|
||||||
|
("projects", "units_office", "VARCHAR(32)"),
|
||||||
("projects", "client_en", "VARCHAR(128)"),
|
("projects", "client_en", "VARCHAR(128)"),
|
||||||
|
("projects", "project_type", "VARCHAR(128)"),
|
||||||
|
("projects", "project_type_en", "VARCHAR(128)"),
|
||||||
|
("projects", "usage", "VARCHAR(128)"),
|
||||||
|
("projects", "usage_en", "VARCHAR(128)"),
|
||||||
("projects", "summary_en", "TEXT"),
|
("projects", "summary_en", "TEXT"),
|
||||||
("projects", "body_en", "JSON"),
|
("projects", "body_en", "JSON"),
|
||||||
("articles", "title_en", "VARCHAR(256)"),
|
("articles", "title_en", "VARCHAR(256)"),
|
||||||
@@ -145,6 +153,14 @@ def run_ddl_migrations() -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
||||||
)
|
)
|
||||||
|
# نقشها — 'admin' همهکاره، 'creator' فقط نویسندهٔ محتوا
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin'")
|
||||||
|
)
|
||||||
|
# نویسندهٔ مقاله — نقش creator فقط مقالههای خودش را میبیند/ویرایش میکند
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS author_id INTEGER")
|
||||||
|
)
|
||||||
# متادیتای تصویر شاخص مقاله + پرسشوپاسخهای پایان مقاله (سئو)
|
# متادیتای تصویر شاخص مقاله + پرسشوپاسخهای پایان مقاله (سئو)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
|
||||||
|
|||||||
+13
-2
@@ -21,6 +21,8 @@ class AdminUser(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
password_hash: Mapped[str] = mapped_column(String(255))
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
|
# 'admin' همهکاره — 'creator' فقط نویسندهٔ محتوا (پیشنویس مقالههای خودش)
|
||||||
|
role: Mapped[str] = mapped_column(String(16), default='admin')
|
||||||
|
|
||||||
|
|
||||||
class Brand(Base):
|
class Brand(Base):
|
||||||
@@ -72,10 +74,18 @@ class Project(Base):
|
|||||||
area_en: Mapped[str] = mapped_column(String(64), default="")
|
area_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
floors: Mapped[str] = mapped_column(String(64), default="")
|
floors: Mapped[str] = mapped_column(String(64), default="")
|
||||||
floors_en: Mapped[str] = mapped_column(String(64), default="")
|
floors_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
units: Mapped[str] = mapped_column(String(64), default="")
|
land_area: Mapped[str] = mapped_column(String(64), default="")
|
||||||
units_en: Mapped[str] = mapped_column(String(64), default="")
|
pilot_floors: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
units_residential: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
units_commercial: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
units_office: Mapped[str] = mapped_column(String(32), default="")
|
||||||
client: Mapped[str] = mapped_column(String(128), default="")
|
client: Mapped[str] = mapped_column(String(128), default="")
|
||||||
client_en: Mapped[str] = mapped_column(String(128), default="")
|
client_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
# مشخصات تکمیلی پروژه — به درخواست تیم: نوع پروژه و کاربری
|
||||||
|
project_type: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
project_type_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
usage: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
usage_en: Mapped[str] = mapped_column(String(128), default="")
|
||||||
summary: Mapped[str] = mapped_column(Text, default="")
|
summary: Mapped[str] = mapped_column(Text, default="")
|
||||||
summary_en: Mapped[str] = mapped_column(Text, default="")
|
summary_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
@@ -108,6 +118,7 @@ class Article(Base):
|
|||||||
# پرسش و پاسخهای پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
|
# پرسش و پاسخهای پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
|
||||||
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
||||||
|
author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقالههای ادمین NULL
|
||||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||||
|
|||||||
+20
-4
@@ -57,10 +57,17 @@ class ProjectOut(ORMModel):
|
|||||||
area_en: str = ""
|
area_en: str = ""
|
||||||
floors: str
|
floors: str
|
||||||
floors_en: str = ""
|
floors_en: str = ""
|
||||||
units: str
|
land_area: str = ""
|
||||||
units_en: str = ""
|
pilot_floors: str = ""
|
||||||
|
units_residential: str = ""
|
||||||
|
units_commercial: str = ""
|
||||||
|
units_office: str = ""
|
||||||
client: str
|
client: str
|
||||||
client_en: str = ""
|
client_en: str = ""
|
||||||
|
project_type: str = ""
|
||||||
|
project_type_en: str = ""
|
||||||
|
usage: str = ""
|
||||||
|
usage_en: str = ""
|
||||||
summary: str
|
summary: str
|
||||||
summary_en: str = ""
|
summary_en: str = ""
|
||||||
body: list[str]
|
body: list[str]
|
||||||
@@ -70,6 +77,7 @@ class ProjectOut(ORMModel):
|
|||||||
class ArticleOut(ORMModel):
|
class ArticleOut(ORMModel):
|
||||||
id: int
|
id: int
|
||||||
slug: str
|
slug: str
|
||||||
|
author_id: int | None = None
|
||||||
title: str
|
title: str
|
||||||
title_en: str = ""
|
title_en: str = ""
|
||||||
date: str
|
date: str
|
||||||
@@ -414,6 +422,7 @@ class LoginIn(BaseModel):
|
|||||||
class TokenOut(BaseModel):
|
class TokenOut(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
role: str = "admin"
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordIn(BaseModel):
|
class ChangePasswordIn(BaseModel):
|
||||||
@@ -451,14 +460,21 @@ class ProjectIn(BaseModel):
|
|||||||
year: str = ""
|
year: str = ""
|
||||||
year_en: str = ""
|
year_en: str = ""
|
||||||
status: Literal["done", "progress"] = "done"
|
status: Literal["done", "progress"] = "done"
|
||||||
|
land_area: str = ""
|
||||||
|
pilot_floors: str = ""
|
||||||
|
units_residential: str = ""
|
||||||
|
units_commercial: str = ""
|
||||||
|
units_office: str = ""
|
||||||
|
project_type: str = ""
|
||||||
|
project_type_en: str = ""
|
||||||
|
usage: str = ""
|
||||||
|
usage_en: str = ""
|
||||||
image: str = ""
|
image: str = ""
|
||||||
gallery: list[str] = []
|
gallery: list[str] = []
|
||||||
area: str = ""
|
area: str = ""
|
||||||
area_en: str = ""
|
area_en: str = ""
|
||||||
floors: str = ""
|
floors: str = ""
|
||||||
floors_en: str = ""
|
floors_en: str = ""
|
||||||
units: str = ""
|
|
||||||
units_en: str = ""
|
|
||||||
client: str = ""
|
client: str = ""
|
||||||
client_en: str = ""
|
client_en: str = ""
|
||||||
summary: str = ""
|
summary: str = ""
|
||||||
|
|||||||
@@ -404,12 +404,103 @@ def test_upload_with_readable_name(auth_client, monkeypatch):
|
|||||||
)
|
)
|
||||||
assert res.status_code == 200, res.text
|
assert res.status_code == 200, res.text
|
||||||
key = res.json()["key"]
|
key = res.json()["key"]
|
||||||
# نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + پسوند تصویر
|
# نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + نرمالشدن به WebP
|
||||||
assert "برج-الهیه-tower" in key
|
assert "برج-الهیه-tower" in key
|
||||||
assert key.endswith(".png")
|
assert key.endswith(".webp")
|
||||||
|
assert res.json()["content_type"] == "image/webp"
|
||||||
assert "!" not in key
|
assert "!" not in key
|
||||||
|
|
||||||
|
|
||||||
|
def _animated_gif_bytes() -> bytes:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
first = Image.new("RGB", (12, 8), (200, 120, 60))
|
||||||
|
second = Image.new("RGB", (12, 8), (60, 120, 200))
|
||||||
|
first.save(buf, "GIF", save_all=True, append_images=[second], duration=100, loop=0)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_jpeg_converted_to_webp(auth_client, monkeypatch):
|
||||||
|
_mock_storage(monkeypatch)
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (3000, 1500), (10, 10, 10)).save(buf, "JPEG", quality=90)
|
||||||
|
res = auth_client.post(
|
||||||
|
"/api/admin/upload",
|
||||||
|
files={"file": ("tower.jpg", buf.getvalue(), "image/jpeg")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200, res.text
|
||||||
|
body = res.json()
|
||||||
|
assert body["content_type"] == "image/webp"
|
||||||
|
assert body["key"].endswith(".webp")
|
||||||
|
# سقف ضلع ۲۰۰۰ پیکسل روی خروجی اعمال شده
|
||||||
|
assert body["width"] == 2000 and body["height"] == 1000
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_animated_gif_becomes_animated_webp(auth_client, monkeypatch):
|
||||||
|
fake = _mock_storage(monkeypatch)
|
||||||
|
res = auth_client.post(
|
||||||
|
"/api/admin/upload",
|
||||||
|
files={"file": ("anim.gif", _animated_gif_bytes(), "image/gif")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200, res.text
|
||||||
|
assert res.json()["content_type"] == "image/webp"
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
stored = next(iter(fake.objects.values()))
|
||||||
|
out = Image.open(io.BytesIO(stored))
|
||||||
|
assert out.format == "WEBP"
|
||||||
|
assert getattr(out, "n_frames", 1) == 2 # حرکت از بین نرفته
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_undecodable_file_rejected(auth_client, monkeypatch):
|
||||||
|
_mock_storage(monkeypatch)
|
||||||
|
res = auth_client.post(
|
||||||
|
"/api/admin/upload",
|
||||||
|
files={"file": ("notes.txt", b"this is not a media file at all", "text/plain")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_video_becomes_animated_webp(auth_client, monkeypatch):
|
||||||
|
# ویدیو تنها با FFmpeg قابل پذیرش است — بدون آن ۴۱۵، با آن WebP متحرک
|
||||||
|
import shutil as _shutil
|
||||||
|
import subprocess as _subprocess
|
||||||
|
import tempfile as _tempfile
|
||||||
|
import os as _os
|
||||||
|
|
||||||
|
_mock_storage(monkeypatch)
|
||||||
|
if _shutil.which("ffmpeg") is None:
|
||||||
|
res = auth_client.post(
|
||||||
|
"/api/admin/upload",
|
||||||
|
files={"file": ("clip.mp4", b"\x00\x00\x00\x18ftypmp42", "video/mp4")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 415
|
||||||
|
return
|
||||||
|
|
||||||
|
with _tempfile.TemporaryDirectory() as tmp:
|
||||||
|
clip = _os.path.join(tmp, "clip.mp4")
|
||||||
|
_subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-v", "error", "-f", "lavfi",
|
||||||
|
"-i", "testsrc=duration=0.5:size=128x96:rate=10", clip],
|
||||||
|
check=True, capture_output=True, timeout=30,
|
||||||
|
)
|
||||||
|
with open(clip, "rb") as fh:
|
||||||
|
clip_bytes = fh.read()
|
||||||
|
|
||||||
|
res = auth_client.post(
|
||||||
|
"/api/admin/upload",
|
||||||
|
files={"file": ("clip.mp4", clip_bytes, "video/mp4")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200, res.text
|
||||||
|
assert res.json()["content_type"] == "image/webp"
|
||||||
|
|
||||||
|
|
||||||
def test_upload_without_name_uses_filename(auth_client, monkeypatch):
|
def test_upload_without_name_uses_filename(auth_client, monkeypatch):
|
||||||
_mock_storage(monkeypatch)
|
_mock_storage(monkeypatch)
|
||||||
res = auth_client.post(
|
res = auth_client.post(
|
||||||
@@ -475,6 +566,7 @@ def test_import_image_happy_path(auth_client, monkeypatch):
|
|||||||
res = auth_client.post("/api/admin/import-image", json={"url": "https://wiki.example/Elahieh pic.png?token=1"})
|
res = auth_client.post("/api/admin/import-image", json={"url": "https://wiki.example/Elahieh pic.png?token=1"})
|
||||||
assert res.status_code == 200, res.text
|
assert res.status_code == 200, res.text
|
||||||
assert res.json()["url"].startswith("/media/")
|
assert res.json()["url"].startswith("/media/")
|
||||||
|
assert res.json()["key"].endswith(".webp")
|
||||||
assert "Elahieh-pic" in res.json()["key"]
|
assert "Elahieh-pic" in res.json()["key"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ WORKDIR /srv
|
|||||||
# همهٔ وابستگیهای سنگین (numpy/scikit-learn/torch/psycopg) ویل prebuilt دارند —
|
# همهٔ وابستگیهای سنگین (numpy/scikit-learn/torch/psycopg) ویل prebuilt دارند —
|
||||||
# بدون کامپایلر؛ دسترسی apt در شبکهٔ بیلد قابلاعتماد نیست
|
# بدون کامپایلر؛ دسترسی apt در شبکهٔ بیلد قابلاعتماد نیست
|
||||||
COPY requirements.txt /srv/chatbot/requirements.txt
|
COPY requirements.txt /srv/chatbot/requirements.txt
|
||||||
|
# torch روی PyPI پیشفرض لینوکس نسخهٔ CUDA است و کل استک nvidia را میآورد
|
||||||
|
# (ایمیج ~۹.۷GB روی سرور بدون GPU). نسخهٔ CPU حدود ۲۰۰MB است — همین اول نصب
|
||||||
|
# میشود تا pip در گام بعد آن را برآوردهشده ببیند.
|
||||||
|
# روی ماشینی با GPU میتوان این خط را حذف کرد.
|
||||||
|
RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu "torch>=2.2"
|
||||||
RUN pip install --no-cache-dir -r /srv/chatbot/requirements.txt
|
RUN pip install --no-cache-dir -r /srv/chatbot/requirements.txt
|
||||||
|
|
||||||
COPY . /srv/chatbot/
|
COPY . /srv/chatbot/
|
||||||
|
|||||||
+5
-5
@@ -115,7 +115,7 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
|||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
|
||||||
# --- خدمات ---
|
# --- خدمات ---
|
||||||
for row in _fetch_rows(dsn, "SELECT title, desc FROM services ORDER BY position"):
|
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM services ORDER BY position'):
|
||||||
d = _doc(
|
d = _doc(
|
||||||
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
|
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||||
"service", _clean(row["title"]), "/services",
|
"service", _clean(row["title"]), "/services",
|
||||||
@@ -166,7 +166,7 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
|||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
|
||||||
# --- مدلهای سرمایهگذاری ---
|
# --- مدلهای سرمایهگذاری ---
|
||||||
for row in _fetch_rows(dsn, "SELECT title, desc, points FROM investment_models ORDER BY position"):
|
for row in _fetch_rows(dsn, 'SELECT title, "desc", points FROM investment_models ORDER BY position'):
|
||||||
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
|
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
|
||||||
d = _doc(
|
d = _doc(
|
||||||
"\n".join(filter(None, [
|
"\n".join(filter(None, [
|
||||||
@@ -179,7 +179,7 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
|||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
|
||||||
# --- مراحل همکاری ---
|
# --- مراحل همکاری ---
|
||||||
for row in _fetch_rows(dsn, "SELECT step, title, desc FROM process_steps ORDER BY position"):
|
for row in _fetch_rows(dsn, 'SELECT step, title, "desc" FROM process_steps ORDER BY position'):
|
||||||
d = _doc(
|
d = _doc(
|
||||||
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||||
"process", _clean(row["title"]), "/investment",
|
"process", _clean(row["title"]), "/investment",
|
||||||
@@ -188,7 +188,7 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
|||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
|
||||||
# --- چرا ما / تیم / نظرسنجی / تایملاین / داستان ما ---
|
# --- چرا ما / تیم / نظرسنجی / تایملاین / داستان ما ---
|
||||||
for row in _fetch_rows(dsn, "SELECT title, desc FROM why_us ORDER BY position"):
|
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM why_us ORDER BY position'):
|
||||||
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
|
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
|
||||||
if d:
|
if d:
|
||||||
docs.append(d)
|
docs.append(d)
|
||||||
@@ -209,7 +209,7 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
|||||||
if d:
|
if d:
|
||||||
docs.append(d)
|
docs.append(d)
|
||||||
|
|
||||||
for row in _fetch_rows(dsn, "SELECT year, title, desc FROM timeline ORDER BY position"):
|
for row in _fetch_rows(dsn, 'SELECT year, title, "desc" FROM timeline ORDER BY position'):
|
||||||
d = _doc(
|
d = _doc(
|
||||||
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||||
"timeline", _clean(row["title"]), "/about",
|
"timeline", _clean(row["title"]), "/about",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
|||||||
خروجی: آمار عملیات برای لاگ.
|
خروجی: آمار عملیات برای لاگ.
|
||||||
"""
|
"""
|
||||||
import psycopg
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
from psycopg.types.json import Jsonb
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
from .embeddings import get_embeddings
|
from .embeddings import get_embeddings
|
||||||
@@ -84,7 +85,10 @@ def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
|||||||
model = settings.embedding_model
|
model = settings.embedding_model
|
||||||
wanted = {c["doc_id"]: c for c in chunks}
|
wanted = {c["doc_id"]: c for c in chunks}
|
||||||
|
|
||||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
# row_factory لازم است: پایینتر row["model"]/row["doc_id"] خوانده میشود و
|
||||||
|
# پیشفرض psycopg3 تاپل برمیگرداند (خطای tuple indices must be integers).
|
||||||
|
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
|
||||||
|
row_factory=dict_row) as conn:
|
||||||
existing: dict[str, str] = {}
|
existing: dict[str, str] = {}
|
||||||
if not force:
|
if not force:
|
||||||
for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
|
for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
|
||||||
@@ -136,8 +140,10 @@ def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
|||||||
def fetch_all_chunks() -> list[dict]:
|
def fetch_all_chunks() -> list[dict]:
|
||||||
"""همه قطعات ذخیرهشده (برای ساخت BM25 در حافظه)."""
|
"""همه قطعات ذخیرهشده (برای ساخت BM25 در حافظه)."""
|
||||||
import psycopg
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
|
||||||
|
row_factory=dict_row) as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
|
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|||||||
+14
-6
@@ -7,15 +7,23 @@
|
|||||||
root * /srv
|
root * /srv
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمیگردند
|
# فایلهای هشدار بیلد: کش بلندمدت بیرحم — ولی هرگز fallback به index.html نداشته باشند؛
|
||||||
try_files {path} /index.html
|
# پاسخ 200ٔ HTML برای یک مسیر /assets/* نبوده (مثلاً هنگام پنجرهٔ تعویض کانتینر در استقرار)
|
||||||
file_server
|
# توسط CDN (WCDN، سیاست SMART) با همین هدر immutable یک سال کش میشود و اسکریپت ماژول
|
||||||
|
# را برای همه بازدیدکنندهها میشکند. فایل ناموجود در assets باید 404 بدهد، نه HTML.
|
||||||
# فایلهای هشدار بیلد: کش بلندمدت بیرحم
|
|
||||||
@assets path /assets/*
|
@assets path /assets/*
|
||||||
header @assets Cache-Control "public, immutable, max-age=31536000"
|
handle @assets {
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# مسیریابی SPA — مسیرهای ناشناختهٔ غیرasset به index.html برمیگردند
|
||||||
|
handle {
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
# بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
|
# بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
|
||||||
@notassets not path /assets/*
|
@notassets not path /assets/*
|
||||||
header @notassets Cache-Control "no-cache"
|
header @notassets Cache-Control "no-cache"
|
||||||
|
header @assets Cache-Control "public, immutable, max-age=31536000"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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 { ArticleWritePage } from './admin/ArticleWritePage';
|
import { ArticleWritePage } from './admin/ArticleWritePage';
|
||||||
|
import { ProjectWritePage } from './admin/ProjectWritePage';
|
||||||
import { LangProvider, useLang } from './i18n/lang';
|
import { LangProvider, useLang } from './i18n/lang';
|
||||||
|
|
||||||
// مسیرهای عمومی یک بار تعریف میشوند و دو بار سرو میشوند: ریشه فارسی و /en انگلیسی
|
// مسیرهای عمومی یک بار تعریف میشوند و دو بار سرو میشوند: ریشه فارسی و /en انگلیسی
|
||||||
@@ -69,6 +70,8 @@ function AnimatedRoutes() {
|
|||||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||||
<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/:id" element={<ProjectWritePage />} />
|
||||||
{FA_ROUTES}
|
{FA_ROUTES}
|
||||||
<Route path="*" element={<Home />} />
|
<Route path="*" element={<Home />} />
|
||||||
{EN_ROUTES}
|
{EN_ROUTES}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { KeyRoundIcon, XIcon } from 'lucide-react'
|
import { KeyRoundIcon, XIcon } from 'lucide-react'
|
||||||
import { useAdmin } from './AdminContext'
|
import { useAdmin } from './AdminContext'
|
||||||
import { adminApi } from './adminClient'
|
import { adminApi } from './adminClient'
|
||||||
|
import { useLang } from '../i18n/lang'
|
||||||
|
|
||||||
const STATUS_TEXT: Record<string, string> = {
|
const STATUS_TEXT: Record<string, string> = {
|
||||||
idle: 'همگامسازی شد',
|
idle: 'همگامسازی شد',
|
||||||
@@ -126,9 +128,18 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||||
export function AdminBar() {
|
export function AdminBar() {
|
||||||
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
|
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang, syncEditLang, role, canManageSite } = useAdmin()
|
||||||
|
const { lang } = useLang()
|
||||||
const [pwOpen, setPwOpen] = useState(false)
|
const [pwOpen, setPwOpen] = useState(false)
|
||||||
|
|
||||||
|
// زبان ویرایش از زبان صفحهٔ در حال نمایش پیروی میکند — ویرایش روی /en به ستونهای
|
||||||
|
// انگلیسی میرود و روی صفحات فارسی به فارسی؛ وگرنه ویرایشِ دیدهشده در /en ناخواسته
|
||||||
|
// متن فارسی را بازنویسی میکرد. کلید دستی پایین فقط برای ویرایش ستون زبان دیگر
|
||||||
|
// روی همین صفحه است و با ناوبری بعدی دوباره همگام میشود.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditMode) syncEditLang(lang)
|
||||||
|
}, [isEditMode, lang, syncEditLang])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -140,6 +151,7 @@ export function AdminBar() {
|
|||||||
حالت مدیریت
|
حالت مدیریت
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{canManageSite && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
@@ -160,11 +172,12 @@ export function AdminBar() {
|
|||||||
</span>
|
</span>
|
||||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleEditLang}
|
onClick={toggleEditLang}
|
||||||
title="تعیین میکند ویرایشها روی کدام ستون زبان ذخیره شوند"
|
title="معمولاً خودکار با زبان صفحه همگام میشود — این کلید فقط برای ویرایش ستون زبان دیگر روی همین صفحه است"
|
||||||
className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${
|
className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${
|
||||||
editLang === 'en'
|
editLang === 'en'
|
||||||
? 'border-copper bg-copper/10 text-copper'
|
? 'border-copper bg-copper/10 text-copper'
|
||||||
@@ -188,6 +201,16 @@ export function AdminBar() {
|
|||||||
رمز
|
رمز
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{canManageSite && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/admin/write"
|
||||||
|
data-admin-link
|
||||||
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
|
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
|
||||||
@@ -209,6 +232,8 @@ export function AdminBar() {
|
|||||||
>
|
>
|
||||||
خبرنامه (CSV)
|
خبرنامه (CSV)
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,17 +4,23 @@ import Swal from 'sweetalert2'
|
|||||||
import type { SiteData } from '../api'
|
import type { SiteData } from '../api'
|
||||||
import { useSiteData } from '../api'
|
import { useSiteData } from '../api'
|
||||||
import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
|
import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
|
||||||
import { ENTITIES, isTranslatable } from './entities'
|
import { entityConfig, isTranslatable } from './entities'
|
||||||
|
|
||||||
export type SaveStatus = 'idle' | 'saving' | 'error'
|
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||||
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
||||||
export type EditLang = 'fa' | 'en'
|
export type EditLang = 'fa' | 'en'
|
||||||
|
|
||||||
|
export type AdminRole = 'admin' | 'creator'
|
||||||
|
|
||||||
interface AdminContextValue {
|
interface AdminContextValue {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
|
/** نقش کاربر واردشده — 'creator' فقط نویسندهٔ محتواست */
|
||||||
|
role: AdminRole | null
|
||||||
|
/** فقط نقش admin به کل بخشهای سایت دسترسی دارد */
|
||||||
|
canManageSite: boolean
|
||||||
isEditMode: boolean
|
isEditMode: boolean
|
||||||
toggleEditMode: () => void
|
toggleEditMode: () => void
|
||||||
login: (username: string, password: string) => Promise<void>
|
login: (username: string, password: string) => Promise<AdminRole>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
saveStatus: SaveStatus
|
saveStatus: SaveStatus
|
||||||
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||||
@@ -22,6 +28,9 @@ interface AdminContextValue {
|
|||||||
/** زبان محتوایی که در حالت ویرایش ذخیره میشود (فارسی یا انگلیسی) */
|
/** زبان محتوایی که در حالت ویرایش ذخیره میشود (فارسی یا انگلیسی) */
|
||||||
editLang: EditLang
|
editLang: EditLang
|
||||||
toggleEditLang: () => void
|
toggleEditLang: () => void
|
||||||
|
/** همگامسازی زبان ویرایش با زبان صفحهٔ در حال نمایش — کلید دستی فقط برای
|
||||||
|
* ویرایش ستون زبان دیگر روی همان صفحه است؛ در ناوبری دوباره از صفحه پیروی میکند */
|
||||||
|
syncEditLang: (lang: EditLang) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdminContext = createContext<AdminContextValue | null>(null)
|
const AdminContext = createContext<AdminContextValue | null>(null)
|
||||||
@@ -55,12 +64,26 @@ function showSaveError(error: unknown): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** نقش را از توکن JWT درمیآورد — فال بک sessionStorage */
|
||||||
|
function roleFromToken(token: string | null): AdminRole | null {
|
||||||
|
if (!token) return null
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
|
||||||
|
return payload?.role === 'creator' ? 'creator' : 'admin'
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminProvider({ children }: { children: ReactNode }) {
|
export function AdminProvider({ children }: { children: ReactNode }) {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
|
||||||
|
const [role, setRole] = useState<AdminRole | null>(() =>
|
||||||
|
sessionStorage.getItem('aramesh_role') as AdminRole | null ?? roleFromToken(getToken()),
|
||||||
|
)
|
||||||
const [isEditMode, setIsEditMode] = useState<boolean>(
|
const [isEditMode, setIsEditMode] = useState<boolean>(
|
||||||
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
|
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
|
||||||
)
|
)
|
||||||
const [editLang, setEditLang] = useState<EditLang>(
|
const [editLang, setEditLangState] = useState<EditLang>(
|
||||||
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
|
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
|
||||||
)
|
)
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||||
@@ -74,24 +97,39 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toggleEditLang = useCallback(() => {
|
const toggleEditLang = useCallback(() => {
|
||||||
setEditLang((v) => {
|
setEditLangState((v) => {
|
||||||
const next: EditLang = v === 'fa' ? 'en' : 'fa'
|
const next: EditLang = v === 'fa' ? 'en' : 'fa'
|
||||||
sessionStorage.setItem('aramesh_edit_lang', next)
|
sessionStorage.setItem('aramesh_edit_lang', next)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const login = useCallback(async (username: string, password: string) => {
|
// بدون بازنویسی انتخاب دستی وقتی مقدار همان است — تا اثر همگامسازی بیدلیل رندر نکند
|
||||||
|
const syncEditLang = useCallback((next: EditLang) => {
|
||||||
|
setEditLangState((v) => {
|
||||||
|
if (v === next) return v
|
||||||
|
sessionStorage.setItem('aramesh_edit_lang', next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string): Promise<AdminRole> => {
|
||||||
await adminApi.login(username, password)
|
await adminApi.login(username, password)
|
||||||
|
const nextRole = roleFromToken(getToken()) ?? 'admin'
|
||||||
|
sessionStorage.setItem('aramesh_role', nextRole)
|
||||||
|
setRole(nextRole)
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
// داده سایت دوباره بارگذاری میشود تا پیشنویس مقالهها هم ادغام شوند
|
// داده سایت دوباره بارگذاری میشود تا پیشنویس مقالهها هم ادغام شوند
|
||||||
reload()
|
reload()
|
||||||
|
return nextRole
|
||||||
}, [reload])
|
}, [reload])
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
setIsEditMode(false)
|
setIsEditMode(false)
|
||||||
|
setRole(null)
|
||||||
|
sessionStorage.removeItem('aramesh_role')
|
||||||
sessionStorage.removeItem('aramesh_edit_mode')
|
sessionStorage.removeItem('aramesh_edit_mode')
|
||||||
// داده سایت دوباره بارگذاری میشود تا پیشنویسها از درخت داده حذف شوند
|
// داده سایت دوباره بارگذاری میشود تا پیشنویسها از درخت داده حذف شوند
|
||||||
reload()
|
reload()
|
||||||
@@ -132,7 +170,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
|||||||
// توکن بیرون از این provider هم پاک میشود (پاسخ 401 بارگذاری سایت، خروج در زبانه
|
// توکن بیرون از این provider هم پاک میشود (پاسخ 401 بارگذاری سایت، خروج در زبانه
|
||||||
// دیگر) — وضعیت ورود همگام میماند و نوار ادمین برای نشست مرده باقی نمیماند
|
// دیگر) — وضعیت ورود همگام میماند و نوار ادمین برای نشست مرده باقی نمیماند
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncAuth = () => setIsAuthenticated(!!getToken())
|
const syncAuth = () => {
|
||||||
|
setIsAuthenticated(!!getToken())
|
||||||
|
setRole(roleFromToken(getToken()))
|
||||||
|
}
|
||||||
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||||
window.addEventListener('storage', syncAuth)
|
window.addEventListener('storage', syncAuth)
|
||||||
return () => {
|
return () => {
|
||||||
@@ -151,9 +192,12 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => window.removeEventListener('beforeunload', guard)
|
return () => window.removeEventListener('beforeunload', guard)
|
||||||
}, [saveStatus])
|
}, [saveStatus])
|
||||||
|
|
||||||
|
const canManageSite = role === 'admin'
|
||||||
const value = useMemo<AdminContextValue>(
|
const value = useMemo<AdminContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
|
role,
|
||||||
|
canManageSite,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
toggleEditMode,
|
toggleEditMode,
|
||||||
login,
|
login,
|
||||||
@@ -162,8 +206,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
|||||||
runSave,
|
runSave,
|
||||||
editLang,
|
editLang,
|
||||||
toggleEditLang,
|
toggleEditLang,
|
||||||
|
syncEditLang,
|
||||||
}),
|
}),
|
||||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
|
[isAuthenticated, role, canManageSite, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang, syncEditLang],
|
||||||
)
|
)
|
||||||
|
|
||||||
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
||||||
@@ -201,7 +246,7 @@ export function useFieldSaver() {
|
|||||||
}
|
}
|
||||||
await adminApi.patch(resource, id, changes)
|
await adminApi.patch(resource, id, changes)
|
||||||
updateSite((d) => {
|
updateSite((d) => {
|
||||||
const config = ENTITIES[resource]
|
const config = entityConfig(resource)
|
||||||
let node: Record<string, unknown> | undefined
|
let node: Record<string, unknown> | undefined
|
||||||
if (config) {
|
if (config) {
|
||||||
const target = d[config.dataKey as keyof SiteData] as unknown
|
const target = d[config.dataKey as keyof SiteData] as unknown
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export function AdminLoginPage() {
|
|||||||
setError('')
|
setError('')
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await login(username.trim(), password)
|
const nextRole = await login(username.trim(), password)
|
||||||
navigate('/')
|
navigate(nextRole === 'creator' ? '/admin/write' : '/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
|
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ const STATUS_TEXT: Record<string, string> = {
|
|||||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||||
*/
|
*/
|
||||||
export function ArticleWritePage() {
|
export function ArticleWritePage() {
|
||||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang, role } = useAdmin()
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data, updateSite } = useSiteData()
|
const { data, updateSite } = useSiteData()
|
||||||
@@ -396,6 +396,7 @@ export function ArticleWritePage() {
|
|||||||
</span>
|
</span>
|
||||||
{createdId && (
|
{createdId && (
|
||||||
<>
|
<>
|
||||||
|
{role === 'admin' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
|
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
|
||||||
@@ -407,6 +408,7 @@ export function ArticleWritePage() {
|
|||||||
>
|
>
|
||||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
||||||
data-admin-link
|
data-admin-link
|
||||||
@@ -415,6 +417,7 @@ export function ArticleWritePage() {
|
|||||||
<EyeIcon className="h-3.5 w-3.5" />
|
<EyeIcon className="h-3.5 w-3.5" />
|
||||||
دیدن در سایت
|
دیدن در سایت
|
||||||
</Link>
|
</Link>
|
||||||
|
{role === 'admin' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={remove}
|
onClick={remove}
|
||||||
@@ -424,6 +427,7 @@ export function ArticleWritePage() {
|
|||||||
>
|
>
|
||||||
<Trash2Icon className="h-3.5 w-3.5" />
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
@@ -7,14 +8,14 @@ import { useSiteData } from '../api'
|
|||||||
import type { SiteData } from '../api'
|
import type { SiteData } from '../api'
|
||||||
import { useAdmin } from './AdminContext'
|
import { useAdmin } from './AdminContext'
|
||||||
import { adminApi } from './adminClient'
|
import { adminApi } from './adminClient'
|
||||||
import { ENTITIES } from './entities'
|
import { entityConfig } from './entities'
|
||||||
import { EntityDrawer } from './EntityDrawer'
|
import { EntityDrawer } from './EntityDrawer'
|
||||||
|
|
||||||
type ItemWithId = Record<string, unknown> & { id: number }
|
type ItemWithId = Record<string, unknown> & { id: number }
|
||||||
|
|
||||||
function useEntityList(resource: string): ItemWithId[] {
|
function useEntityList(resource: string): ItemWithId[] {
|
||||||
const { data } = useSiteData()
|
const { data } = useSiteData()
|
||||||
const config = ENTITIES[resource]
|
const config = entityConfig(resource)
|
||||||
if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
|
if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
|
||||||
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
|
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
|
||||||
}
|
}
|
||||||
@@ -69,14 +70,14 @@ export function EditableItem({
|
|||||||
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت میکند (مثل صفحه نوشتن مقاله) */
|
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت میکند (مثل صفحه نوشتن مقاله) */
|
||||||
editHref?: string
|
editHref?: string
|
||||||
}) {
|
}) {
|
||||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
|
||||||
const { reload } = useSiteData()
|
const { reload } = useSiteData()
|
||||||
const config = ENTITIES[resource]
|
const config = entityConfig(resource)
|
||||||
const list = useEntityList(resource)
|
const list = useEntityList(resource)
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
|
||||||
if (!config) return <>{children}</>
|
if (!config) return <>{children}</>
|
||||||
if (!isAuthenticated || !isEditMode) return <>{children}</>
|
if (!isAuthenticated || !isEditMode || !canManageSite) return <>{children}</>
|
||||||
|
|
||||||
const index = list.findIndex((x) => x.id === item.id)
|
const index = list.findIndex((x) => x.id === item.id)
|
||||||
|
|
||||||
@@ -163,17 +164,21 @@ export function AddItemButton({
|
|||||||
resource,
|
resource,
|
||||||
label,
|
label,
|
||||||
variant = 'cell',
|
variant = 'cell',
|
||||||
|
/** در صورت ارسال، پس از ساختهشدن رکورد به صفحهٔ ویرایش اختصاصی هدایت میشود (کشو باز نمیشود) */
|
||||||
|
editHrefBase,
|
||||||
}: {
|
}: {
|
||||||
resource: string
|
resource: string
|
||||||
label?: string
|
label?: string
|
||||||
variant?: 'cell' | 'button'
|
variant?: 'cell' | 'button'
|
||||||
|
editHrefBase?: string
|
||||||
}) {
|
}) {
|
||||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
|
||||||
|
const navigate = useNavigate()
|
||||||
const { updateSite } = useSiteData()
|
const { updateSite } = useSiteData()
|
||||||
const config = ENTITIES[resource]
|
const config = entityConfig(resource)
|
||||||
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
|
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
|
||||||
|
|
||||||
if (!config || !isAuthenticated || !isEditMode) return null
|
if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
|
||||||
|
|
||||||
const create = () => {
|
const create = () => {
|
||||||
void runSave(async () => {
|
void runSave(async () => {
|
||||||
@@ -183,6 +188,10 @@ export function AddItemButton({
|
|||||||
const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[]
|
const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[]
|
||||||
if (Array.isArray(list)) list.push(created)
|
if (Array.isArray(list)) list.push(created)
|
||||||
})
|
})
|
||||||
|
if (editHrefBase) {
|
||||||
|
navigate(`${editHrefBase}/${created.id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
setNewItem(created)
|
setNewItem(created)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -240,13 +249,13 @@ export function SingletonPill({
|
|||||||
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
|
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
|
||||||
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
|
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||||
}) {
|
}) {
|
||||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
|
||||||
const config = ENTITIES[resource]
|
const config = entityConfig(resource)
|
||||||
const { data, updateSite } = useSiteData()
|
const { data, updateSite } = useSiteData()
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
const [created, setCreated] = useState<ItemWithId | null>(null)
|
const [created, setCreated] = useState<ItemWithId | null>(null)
|
||||||
|
|
||||||
if (!config || !isAuthenticated || !isEditMode) return null
|
if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
|
||||||
const item =
|
const item =
|
||||||
created ??
|
created ??
|
||||||
(select
|
(select
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { ArrowRightIcon, Trash2Icon } from 'lucide-react'
|
||||||
|
import Swal from 'sweetalert2'
|
||||||
|
import { useSiteData } from '../api'
|
||||||
|
import { useAdmin } from './AdminContext'
|
||||||
|
import { adminApi } from './adminClient'
|
||||||
|
import { ImageField } from '../components/admin/ImageField'
|
||||||
|
import { GalleryField } from '../components/admin/GalleryField'
|
||||||
|
|
||||||
|
type ProjectDraft = {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
title_en: string
|
||||||
|
location: string
|
||||||
|
location_en: string
|
||||||
|
year: string
|
||||||
|
year_en: string
|
||||||
|
status: 'done' | 'progress'
|
||||||
|
project_type: string
|
||||||
|
project_type_en: string
|
||||||
|
usage: string
|
||||||
|
usage_en: string
|
||||||
|
area: string
|
||||||
|
area_en: string
|
||||||
|
floors: string
|
||||||
|
floors_en: string
|
||||||
|
units: string
|
||||||
|
units_en: string
|
||||||
|
client: string
|
||||||
|
client_en: string
|
||||||
|
summary: string
|
||||||
|
summary_en: string
|
||||||
|
body: string[]
|
||||||
|
body_en: string[]
|
||||||
|
image: string
|
||||||
|
gallery: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: ProjectDraft = {
|
||||||
|
id: 0,
|
||||||
|
slug: `project-${Date.now()}`,
|
||||||
|
title: '', title_en: '',
|
||||||
|
location: '', location_en: '',
|
||||||
|
year: '', year_en: '',
|
||||||
|
status: 'done',
|
||||||
|
project_type: '', project_type_en: '',
|
||||||
|
usage: '', usage_en: '',
|
||||||
|
area: '', area_en: '',
|
||||||
|
floors: '', floors_en: '',
|
||||||
|
units: '', units_en: '',
|
||||||
|
client: '', client_en: '',
|
||||||
|
summary: '', summary_en: '',
|
||||||
|
body: [], body_en: [],
|
||||||
|
image: '', gallery: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const INPUT =
|
||||||
|
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||||
|
|
||||||
|
/** جفت دوزبانه — فارسی و English همیشه هر دو دیده میشوند و با یک ذخیره ثبت میشوند */
|
||||||
|
function Pair({
|
||||||
|
label,
|
||||||
|
fa,
|
||||||
|
en,
|
||||||
|
textarea = false,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
fa: { value: string; onChange: (v: string) => void }
|
||||||
|
en: { value: string; onChange: (v: string) => void }
|
||||||
|
textarea?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||||
|
<div className="mt-1.5 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-light text-ivory/40">فارسی</span>
|
||||||
|
{textarea ? (
|
||||||
|
<textarea dir="rtl" rows={4} className={`${INPUT} mt-1 leading-8`} {...fa} />
|
||||||
|
) : (
|
||||||
|
<input dir="rtl" type="text" className={`${INPUT} mt-1`} {...fa} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-light text-ivory/40">English</span>
|
||||||
|
{textarea ? (
|
||||||
|
<textarea dir="ltr" rows={4} className={`${INPUT} mt-1 leading-8`} {...en} />
|
||||||
|
) : (
|
||||||
|
<input dir="ltr" type="text" className={`${INPUT} mt-1`} {...en} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** صفحهٔ اختصاصی ویرایش/ساخت پروژه — بهجای کشو؛ تصاویر فقط با آپلود به MinIO */
|
||||||
|
export function ProjectWritePage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const isNew = !id || id === 'new'
|
||||||
|
const numericId = Number(id)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data, updateSite } = useSiteData()
|
||||||
|
const { isAuthenticated, runSave } = useAdmin()
|
||||||
|
|
||||||
|
const existing = !isNew && data?.projects.find((p) => p.id === numericId)
|
||||||
|
const [fetched, setFetched] = useState<ProjectDraft | null>(null)
|
||||||
|
const [draft, setDraft] = useState<ProjectDraft>(EMPTY)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// اگر پروژه در دادهٔ سایت نبود (پیشنویس تازه و… ) مستقیم از مسیر مدیریتی خوانده میشود
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew || existing) return
|
||||||
|
let cancelled = false
|
||||||
|
adminApi
|
||||||
|
.get<ProjectDraft>('projects', numericId)
|
||||||
|
.then((p) => {
|
||||||
|
if (!cancelled) setFetched(p)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* پروژه در دادهٔ سایت نبود — از مسیر مدیریتی خوانده میشود */
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id, existing])
|
||||||
|
|
||||||
|
const project = existing ?? fetched
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!project) return
|
||||||
|
setDraft({ ...EMPTY, ...(project as unknown as ProjectDraft) })
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [(project as unknown as ProjectDraft | undefined)?.id])
|
||||||
|
|
||||||
|
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
|
||||||
|
|
||||||
|
const set = <K extends keyof ProjectDraft>(key: K, value: ProjectDraft[K]) =>
|
||||||
|
setDraft((d) => ({ ...d, [key]: value }))
|
||||||
|
|
||||||
|
const fa = (key: keyof ProjectDraft) => ({
|
||||||
|
value: String(draft[key] ?? ''),
|
||||||
|
onChange: (v: string) => set(key, v as ProjectDraft[typeof key]),
|
||||||
|
})
|
||||||
|
const en = (key: keyof ProjectDraft) => ({
|
||||||
|
value: String(draft[`${String(key)}_en` as keyof ProjectDraft] ?? ''),
|
||||||
|
onChange: (v: string) => set(`${String(key)}_en` as keyof ProjectDraft, v as ProjectDraft[typeof key]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const doSave = async () => {
|
||||||
|
if (!draft.title.trim() || !draft.slug.trim()) {
|
||||||
|
void Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'عنوان و نشانی (slug) لازم است',
|
||||||
|
background: '#1e1a16',
|
||||||
|
color: '#F5F3EF',
|
||||||
|
confirmButtonText: 'باشه',
|
||||||
|
buttonsStyling: false,
|
||||||
|
customClass: { popup: 'font-pinar border border-ivory/10' },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
const ok = await runSave(async () => {
|
||||||
|
if (isNew || draft.id === 0) {
|
||||||
|
const created = await adminApi.create<ProjectDraft>('projects', { ...draft })
|
||||||
|
updateSite((d) => {
|
||||||
|
const list = d.projects as unknown as ProjectDraft[]
|
||||||
|
if (Array.isArray(list)) list.push(created)
|
||||||
|
})
|
||||||
|
navigate(`/admin/projects/${created.id}`, { replace: true })
|
||||||
|
} else {
|
||||||
|
await adminApi.patch('projects', draft.id, { ...draft })
|
||||||
|
updateSite((d) => {
|
||||||
|
const list = d.projects as unknown as ProjectDraft[]
|
||||||
|
const index = list.findIndex((x) => x.id === draft.id)
|
||||||
|
if (index >= 0) list[index] = { ...list[index], ...draft }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setSaving(false)
|
||||||
|
// ذخیرهٔ موفق پروژهٔ موجود → بازگشت به فهرست؛ بیصدا ماندن صفحه «ذخیره نشد» القا میکرد
|
||||||
|
if (ok && !isNew && draft.id !== 0) navigate('/projects', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
if (isNew || draft.id === 0) return
|
||||||
|
const result = await Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'حذف پروژه',
|
||||||
|
text: `«${draft.title}» برای همیشه حذف شود؟`,
|
||||||
|
background: '#1e1a16',
|
||||||
|
color: '#F5F3EF',
|
||||||
|
iconColor: '#d5874c',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'حذف',
|
||||||
|
cancelButtonText: 'انصراف',
|
||||||
|
buttonsStyling: false,
|
||||||
|
customClass: {
|
||||||
|
popup: 'font-pinar border border-ivory/10',
|
||||||
|
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||||
|
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!result.isConfirmed) return
|
||||||
|
await runSave(async () => {
|
||||||
|
await adminApi.remove('projects', draft.id)
|
||||||
|
updateSite((d) => {
|
||||||
|
const list = d.projects as unknown as ProjectDraft[]
|
||||||
|
const index = list.findIndex((x) => x.id === draft.id)
|
||||||
|
if (index >= 0) list.splice(index, 1)
|
||||||
|
})
|
||||||
|
navigate('/projects', { replace: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** تصویر شاخص و گالری بلافاصله ذخیره میشوند (پروژهٔ موجود)؛ در پروژهٔ تازه تا اولین ذخیره در فرم میمانند */
|
||||||
|
const persistImage = async (url: string) => {
|
||||||
|
setDraft((d) => ({ ...d, image: url }))
|
||||||
|
if (!isNew && draft.id !== 0) {
|
||||||
|
await adminApi.patch('projects', draft.id, { image: url })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const persistGallery = async (urls: string[]) => {
|
||||||
|
setDraft((d) => ({ ...d, gallery: urls }))
|
||||||
|
if (!isNew && draft.id !== 0) {
|
||||||
|
await adminApi.patch('projects', draft.id, { gallery: urls })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div dir="rtl" className="min-h-screen bg-ink pb-24 pt-24">
|
||||||
|
<div className="mx-auto max-w-4xl px-6 lg:px-10">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to="/projects"
|
||||||
|
data-admin-link
|
||||||
|
className="inline-flex items-center gap-2 text-xs font-light text-ivory/50 transition-colors hover:text-copper"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="h-3.5 w-3.5" />
|
||||||
|
بازگشت به پروژهها
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-3 text-3xl font-bold text-ivory">
|
||||||
|
{isNew ? 'پروژهٔ جدید' : `ویرایش «${draft.title}»`}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{!isNew && draft.id !== 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void doDelete()}
|
||||||
|
className="flex items-center gap-2 border border-red-400/40 px-5 py-2.5 text-sm font-light text-red-300 transition-colors duration-200 hover:bg-red-500/20"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-4 w-4" />
|
||||||
|
حذف پروژه
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void doSave()}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 bg-copper px-8 py-2.5 text-sm font-bold text-char transition-transform duration-200 hover:scale-105 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
ذخیرهٔ پروژه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 space-y-8">
|
||||||
|
<Pair label="عنوان پروژه" fa={fa('title')} en={en('title')} />
|
||||||
|
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">
|
||||||
|
نشانی (slug) <span className="font-light text-ivory/40">— در نشانی صفحه: /projects/…</span>
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
dir="ltr"
|
||||||
|
type="text"
|
||||||
|
className={`${INPUT} mt-1.5`}
|
||||||
|
value={draft.slug}
|
||||||
|
onChange={(e) => set('slug', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">وضعیت</p>
|
||||||
|
<select
|
||||||
|
className={`${INPUT} mt-1.5`}
|
||||||
|
value={draft.status}
|
||||||
|
onChange={(e) => set('status', e.target.value as ProjectDraft['status'])}
|
||||||
|
>
|
||||||
|
<option value="done">انجامشده (done)</option>
|
||||||
|
<option value="progress">در حال اجرا (progress)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pair label="موقعیت" fa={fa('location')} en={en('location')} />
|
||||||
|
<Pair label="سال" fa={fa('year')} en={en('year')} />
|
||||||
|
<Pair label="نوع پروژه" fa={fa('project_type')} en={en('project_type')} />
|
||||||
|
<Pair label="کاربری" fa={fa('usage')} en={en('usage')} />
|
||||||
|
<Pair label="زیربنا" fa={fa('area')} en={en('area')} />
|
||||||
|
<Pair label="تعداد طبقات" fa={fa('floors')} en={en('floors')} />
|
||||||
|
<Pair label="واحدها" fa={fa('units')} en={en('units')} />
|
||||||
|
<Pair label="کارفرما" fa={fa('client')} en={en('client')} />
|
||||||
|
<Pair label="خلاصه" fa={fa('summary')} en={en('summary')} textarea />
|
||||||
|
<Pair
|
||||||
|
label="متن پروژه — هر پاراگراف یک خط"
|
||||||
|
fa={{
|
||||||
|
value: draft.body.join('\n'),
|
||||||
|
onChange: (v) => set('body', v.split('\n')),
|
||||||
|
}}
|
||||||
|
en={{
|
||||||
|
value: draft.body_en.join('\n'),
|
||||||
|
onChange: (v) => set('body_en', v.split('\n')),
|
||||||
|
}}
|
||||||
|
textarea
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border-t border-ivory/10 pt-8">
|
||||||
|
<ImageField
|
||||||
|
label="تصویر شاخص"
|
||||||
|
value={draft.image}
|
||||||
|
onSave={persistImage}
|
||||||
|
altFallback={`پروژه ${draft.title}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-ivory/10 pt-8">
|
||||||
|
<GalleryField
|
||||||
|
label="گالری تصاویر پروژه — فقط آپلود به MinIO"
|
||||||
|
value={draft.gallery}
|
||||||
|
onSave={persistGallery}
|
||||||
|
namePrefix={draft.slug || 'project'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ import { API_URL } from '../config'
|
|||||||
|
|
||||||
const TOKEN_KEY = 'aramesh_admin_token'
|
const TOKEN_KEY = 'aramesh_admin_token'
|
||||||
|
|
||||||
|
/** سقف آپلود — هماندازهٔ سقف کاربر در بکاند (media_max_upload_mb = ۳۵ با احتساب سربار multipart) */
|
||||||
|
const MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
||||||
|
|
||||||
/** رویداد تغییر وضعیت ورود — لایههای دیگر (مثل نوار ادمین) با آن همگام میمانند */
|
/** رویداد تغییر وضعیت ورود — لایههای دیگر (مثل نوار ادمین) با آن همگام میمانند */
|
||||||
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
|
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
|
||||||
|
|
||||||
@@ -43,9 +46,11 @@ async function authFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
|||||||
const detail =
|
const detail =
|
||||||
typeof data?.detail === 'string'
|
typeof data?.detail === 'string'
|
||||||
? data.detail
|
? data.detail
|
||||||
: res.status === 409
|
: res.status === 413
|
||||||
? 'رکوردی با این مقدار تکراری وجود دارد'
|
? 'حجم فایل بیش از حد مجاز است'
|
||||||
: 'خطا در ذخیرهسازی — دوباره تلاش کنید'
|
: res.status === 409
|
||||||
|
? 'رکوردی با این مقدار تکراری وجود دارد'
|
||||||
|
: 'خطا در ذخیرهسازی — دوباره تلاش کنید'
|
||||||
throw new HttpError(res.status, detail)
|
throw new HttpError(res.status, detail)
|
||||||
}
|
}
|
||||||
return res.status === 204 ? (null as T) : ((await res.json()) as T)
|
return res.status === 204 ? (null as T) : ((await res.json()) as T)
|
||||||
@@ -88,6 +93,10 @@ export const adminApi = {
|
|||||||
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
|
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
|
||||||
|
|
||||||
upload: async (file: File, name?: string): Promise<{ url: string; key: string }> => {
|
upload: async (file: File, name?: string): Promise<{ url: string; key: string }> => {
|
||||||
|
// پیشچک سمت کاربر — بازخورد فوری بهجای ۴۱۳ لبه/CDN که بهصورت HTML میرسد
|
||||||
|
if (file.size > MAX_UPLOAD_BYTES) {
|
||||||
|
throw new HttpError(413, 'حجم فایل بیش از ۳۰ مگابایت است — فایل را کوچکتر کنید')
|
||||||
|
}
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
if (name && name.trim()) fd.append('name', name.trim())
|
if (name && name.trim()) fd.append('name', name.trim())
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
|
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
|
||||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
|
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
|
||||||
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
|
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
|
||||||
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
|
F('land_area', 'مساحت عرصه (زمین)'), F('area', 'مساحت زیربنا (اعیان)'),
|
||||||
|
F('usage', 'کاربری ساختمان'), F('pilot_floors', 'طبقات روی همکف (پیلوت)'), F('floors', 'تعداد طبقات'),
|
||||||
|
F('units_residential', 'تعداد واحد مسکونی'), F('units_commercial', 'تعداد واحد تجاری'), F('units_office', 'تعداد واحد اداری'),
|
||||||
|
F('project_type', 'نوع پروژه'),
|
||||||
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
|
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
|
||||||
],
|
],
|
||||||
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
|
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
|
||||||
@@ -173,6 +176,27 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
|
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
|
||||||
defaults: { label: 'آیتم جدید', href: '/' },
|
defaults: { label: 'آیتم جدید', href: '/' },
|
||||||
},
|
},
|
||||||
|
'status-labels': {
|
||||||
|
resource: 'status-labels', dataKey: 'status_labels', title: 'برچسبهای وضعیت پروژه', kind: 'collection',
|
||||||
|
titleKey: 'label',
|
||||||
|
// نبودِ این تعریف یعنی isTranslatable همیشه false برمیگشت و ویرایشِ برچسب روی
|
||||||
|
// صفحهٔ انگلیسی بهجای ستون label_en ستون فارسی را بازنویسی میکرد
|
||||||
|
fields: [F('label', 'برچسب'), F('key', 'کلید وضعیت', 'text', 'done یا progress', true)],
|
||||||
|
defaults: { key: 'done', label: 'برچسب جدید' },
|
||||||
|
},
|
||||||
|
'section-headings': {
|
||||||
|
resource: 'section-headings', dataKey: 'section_headings', title: 'تیتر بخشها', kind: 'collection',
|
||||||
|
titleKey: 'title',
|
||||||
|
// تیتر/توضیح بخشها فقط درجا (InlineEdit) ویرایش میشوند — نبودِ تعریف یعنی
|
||||||
|
// ویرایش روی صفحهٔ انگلیسی بهجای ستونهای _en ستون فارسی را بازنویسی میکرد
|
||||||
|
fields: [
|
||||||
|
F('key', 'کلید بخش', 'text', 'services / featured_projects / gallery / timeline / why_us / testimonials / team / articles', true),
|
||||||
|
F('label', 'برچسب بالای تیتر'), F('title', 'تیتر'), F('lead', 'توضیح', 'textarea'),
|
||||||
|
F('image', 'تصویر', 'image', undefined, true),
|
||||||
|
F('cta_label', 'متن دکمه'), F('cta_href', 'لینک دکمه', 'text', undefined, true),
|
||||||
|
],
|
||||||
|
defaults: { key: '', title: '', lead: '' },
|
||||||
|
},
|
||||||
seo_meta: {
|
seo_meta: {
|
||||||
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||||
titleKey: 'page_key',
|
titleKey: 'page_key',
|
||||||
@@ -192,9 +216,20 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
hero: {
|
hero: {
|
||||||
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
||||||
titleKey: 'title',
|
titleKey: 'title',
|
||||||
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
// title و subtitle باید در فهرست باشند — تیتر و زیرتیتر درجا ویرایش میشوند و
|
||||||
|
// نبودشان یعنی isTranslatable false برمیگرداند و ویرایش روی /en متن فارسی را بازنویسی میکرد
|
||||||
|
fields: [F('title', 'تیتر اصلی'), F('title_highlight', 'واژه مسیِ تیتر'), F('subtitle', 'زیرتیتر', 'textarea'), F('kicker', 'متن بالای تیتر', 'textarea'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول')],
|
||||||
defaults: {},
|
defaults: {},
|
||||||
},
|
},
|
||||||
|
'page-heroes': {
|
||||||
|
resource: 'page-heroes', dataKey: 'page_heroes', title: 'هیروی صفحهها', kind: 'collection',
|
||||||
|
titleKey: 'title',
|
||||||
|
fields: [
|
||||||
|
F('key', 'کلید صفحه', 'text', 'about / services / projects / investment / blog / faq / contact', true),
|
||||||
|
F('title', 'عنوان'), F('lead', 'سرآغاز', 'textarea'), F('image', 'تصویر', 'image', undefined, true),
|
||||||
|
],
|
||||||
|
defaults: { key: 'about', title: 'عنوان صفحه', lead: '', image: '' },
|
||||||
|
},
|
||||||
story: {
|
story: {
|
||||||
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||||
titleKey: 'title',
|
titleKey: 'title',
|
||||||
@@ -235,10 +270,18 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** تعریف موجودیت بر اساس resource (نشانی API) یا کلید فهرست — هر دو سبک در کد
|
||||||
|
* استفاده میشوند: save()/isTranslatable با resource (why-us) و رجکسهای JSX با کلید
|
||||||
|
* (why_us، contact_section، process_steps و…) — هر دو باید پیدا شوند وگرنه رندر
|
||||||
|
* با «منبع ناشناخته» میشکند. */
|
||||||
|
export function entityConfig(resource: string): EntityConfig | undefined {
|
||||||
|
return ENTITIES[resource] ?? Object.values(ENTITIES).find((c) => c.resource === resource)
|
||||||
|
}
|
||||||
|
|
||||||
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
|
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
|
||||||
export function isTranslatable(resource: string, name: string): boolean {
|
export function isTranslatable(resource: string, name: string): boolean {
|
||||||
if (name.endsWith('_en')) return false
|
if (name.endsWith('_en')) return false
|
||||||
const config = ENTITIES[resource]
|
const config = entityConfig(resource)
|
||||||
return !!config?.fields.some((f) => f.name === name && !f.shared)
|
return !!config?.fields.some((f) => f.name === name && !f.shared)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,65 @@
|
|||||||
// لایهٔ نازک تحلیل رفتار (Umami) — اگر اسکریپت ردیاب بارگیری نشده باشد
|
// لایهٔ نازک تحلیل رفتار — OpenPanel.
|
||||||
// (VITE_UMAMI_SRC خالی)، همهٔ فراخوانیها بیاثر و ارزاناند.
|
//
|
||||||
|
// نمونهٔ OpenPanel از قبل روی این سرور بالا است (stats.bennu-api-bahrestan.ir) و
|
||||||
|
// پروژهٔ ERP هم از همان استفاده میکند؛ بهجای اجرای یک ابزار تحلیل دوم (Umami)،
|
||||||
|
// همین یک نمونه با یک «پروژهٔ» دوم برای این سایت به کار میرود.
|
||||||
|
//
|
||||||
|
// اگر VITE_OPENPANEL_URL و VITE_OPENPANEL_CLIENT_ID در زمان بیلد تنظیم نشده باشند،
|
||||||
|
// همهٔ فراخوانیها بیاثر و ارزاناند (مثل حالت فعلی Umami).
|
||||||
|
//
|
||||||
|
// مسیر Umami دستنخورده مانده تا اگر اسکریپت قدیمی جایی فعال بود از کار نیفتد.
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
op?: (...args: unknown[]) => void
|
||||||
umami?: { track: (event?: string, data?: Record<string, unknown>) => void }
|
umami?: { track: (event?: string, data?: Record<string, unknown>) => void }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** رخداد سفارشی — نام رخداد در داشبورد Umami نمایش داده میشود. */
|
const OP_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim()
|
||||||
export function track(event: string, data?: Record<string, unknown>) {
|
const OP_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim()
|
||||||
|
const OP_SCRIPT_URL = (import.meta.env?.VITE_OPENPANEL_SCRIPT_URL as string | undefined)?.trim()
|
||||||
|
|
||||||
|
// شیم صف OpenPanel — تا رسیدن op1.js، فراخوانیها در صف میمانند و بعد تخلیه میشوند.
|
||||||
|
function loadOpenPanelScript(src: string): void {
|
||||||
|
if (document.querySelector('script[data-openpanel]')) return
|
||||||
|
const shim = document.createElement('script')
|
||||||
|
shim.textContent =
|
||||||
|
'window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}},has:function(t,r){return"q"===r}}) }();'
|
||||||
|
document.head.appendChild(shim)
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = src
|
||||||
|
script.async = true
|
||||||
|
script.defer = true
|
||||||
|
script.dataset.openpanel = '1'
|
||||||
|
document.head.appendChild(script)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** یکبار در شروع اپ صدا زده میشود؛ بدون تنظیمات، هیچ کاری نمیکند. */
|
||||||
|
export function initAnalytics(): void {
|
||||||
|
if (!OP_URL || !OP_CLIENT_ID) return
|
||||||
try {
|
try {
|
||||||
window.umami?.track(event, data)
|
if (OP_SCRIPT_URL) loadOpenPanelScript(OP_SCRIPT_URL)
|
||||||
|
window.op?.('init', {
|
||||||
|
apiUrl: OP_URL,
|
||||||
|
clientId: OP_CLIENT_ID,
|
||||||
|
trackScreenViews: true, // بازدید صفحه → کدام صفحات دیده میشوند
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
|
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** رخداد سفارشی — در داشبورد OpenPanel نمایش داده میشود. */
|
||||||
|
export function track(event: string, data?: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
window.op?.('track', event, data ?? {})
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.umami?.track(event, data)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+24
-19
@@ -249,6 +249,24 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
|||||||
setError(false)
|
setError(false)
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
|
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
|
||||||
|
// درخواستهای سایت و پیشنویسهای ادمین «همزمان» شروع میشوند —
|
||||||
|
// پشت سر هم بودنشان یعنی ادمین دو برابر دیرتر محتوا را میبیند.
|
||||||
|
const draftsPromise: Promise<Article[] | null> = getToken()
|
||||||
|
? fetch(`${API_URL}/api/admin/articles`, {
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.status === 401) {
|
||||||
|
// توکن منقضی/نامعتبر — پاک میشود تا بارگذاریهای بعدی درخواست بیفایده نفرستند
|
||||||
|
setToken(null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return res.ok ? ((await res.json()) as Article[]) : null
|
||||||
|
})
|
||||||
|
.catch(() => null)
|
||||||
|
: Promise.resolve(null)
|
||||||
|
|
||||||
fetch(`${API_URL}/api/site`, { signal: controller.signal })
|
fetch(`${API_URL}/api/site`, { signal: controller.signal })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
@@ -256,25 +274,12 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
|||||||
})
|
})
|
||||||
.then(async (json) => {
|
.then(async (json) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
// برای ادمین واردشده، پیشنویس مقالهها از انتهای مدیریتی خوانده و ادغام میشوند —
|
// بکاند در GET /api/site فقط مقالههای منتشرشده را برمیگرداند —
|
||||||
// بکاند در GET /api/site فقط مقالههای منتشرشده را برمیگرداند
|
// پیشنویسها (برای ادمین) از درخواست موازی ادغام میشوند
|
||||||
if (getToken()) {
|
const drafts = await draftsPromise
|
||||||
try {
|
if (drafts) {
|
||||||
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
const published = new Set(json.articles.map((a) => a.id))
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
json.articles = [...json.articles, ...drafts.filter((a) => a.status === 'draft' && !published.has(a.id))]
|
||||||
})
|
|
||||||
if (res.status === 401) {
|
|
||||||
// توکن منقضی/نامعتبر — پاک میشود تا بارگذاریهای بعدی درخواست بیفایده نفرستند
|
|
||||||
setToken(null)
|
|
||||||
} else if (res.ok) {
|
|
||||||
const all = (await res.json()) as Article[]
|
|
||||||
const published = new Set(json.articles.map((a) => a.id))
|
|
||||||
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
|
||||||
json.articles = [...json.articles, ...drafts]
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* بدون پیشنویسها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
|
||||||
|
|
||||||
|
type BaseProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
variant?: Variant
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// استایلهای فوقلوکس شیشهای (Glassmorphic) با رنگهای اختصاصی پروژه
|
||||||
|
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
|
||||||
|
copper: {
|
||||||
|
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
|
||||||
|
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
|
||||||
|
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
|
||||||
|
text: 'group-hover:text-char font-semibold',
|
||||||
|
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبهها
|
||||||
|
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
|
||||||
|
fill: 'bg-white/10',
|
||||||
|
text: 'group-hover:text-copper',
|
||||||
|
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||||
|
fill: 'bg-copper',
|
||||||
|
text: 'group-hover:text-char',
|
||||||
|
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
|
||||||
|
},
|
||||||
|
'dark-ivory': {
|
||||||
|
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||||
|
fill: 'bg-ivory',
|
||||||
|
text: 'group-hover:text-char',
|
||||||
|
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
|
||||||
|
const style = STYLES[variant]
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* خط انعکاس نوری شیشه در بالای دکمه */}
|
||||||
|
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
|
||||||
|
|
||||||
|
{/* انیمیشن پر شدن موجی و نرم */}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* متن و آیکون دکمه */}
|
||||||
|
<span
|
||||||
|
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشهای
|
||||||
|
const SHELL =
|
||||||
|
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
|
||||||
|
|
||||||
|
export function ButtonLink({
|
||||||
|
to,
|
||||||
|
children,
|
||||||
|
variant = 'copper',
|
||||||
|
className = '',
|
||||||
|
}: BaseProps & { to: string }) {
|
||||||
|
const style = STYLES[variant]
|
||||||
|
return (
|
||||||
|
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
|
||||||
|
<Inner variant={variant}>{children}</Inner>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ButtonAnchor({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
variant = 'copper',
|
||||||
|
className = '',
|
||||||
|
...rest
|
||||||
|
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||||
|
const style = STYLES[variant]
|
||||||
|
return (
|
||||||
|
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||||
|
<Inner variant={variant}>{children}</Inner>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
children,
|
||||||
|
variant = 'copper',
|
||||||
|
className = '',
|
||||||
|
...rest
|
||||||
|
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
const style = STYLES[variant]
|
||||||
|
return (
|
||||||
|
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||||
|
<Inner variant={variant}>{children}</Inner>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
|
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
import { Reveal } from './Reveal'
|
import { Reveal } from './Reveal'
|
||||||
import { PremiumButton } from './PremiumButton'
|
import { Button } from './Button'
|
||||||
import { useSite } from '../api'
|
import { useSite } from '../api'
|
||||||
import { useLang } from '../i18n/lang'
|
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 { SingletonPill } from '../admin/Editable'
|
import { SingletonPill } from '../admin/Editable'
|
||||||
import { API_URL } from '../config'
|
import { API_URL } from '../config'
|
||||||
import { track } from '../analytics'
|
|
||||||
|
|
||||||
export function ContactSection() {
|
export function ContactSection() {
|
||||||
const { brand, contact_section: content } = useSite()
|
const { brand, contact_section: content } = useSite()
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
const [form, setForm] = useState({ name: '', phone: '', note: '', website: '' })
|
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||||
const [sent, setSent] = useState(false)
|
const [sent, setSent] = useState(false)
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ export function ContactSection() {
|
|||||||
const res = await fetch(`${API_URL}/api/contact`, {
|
const res = await fetch(`${API_URL}/api/contact`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim(), website: form.website }),
|
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim() }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -91,7 +90,6 @@ export function ContactSection() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
track('contact-submit')
|
|
||||||
setSent(true)
|
setSent(true)
|
||||||
} catch {
|
} catch {
|
||||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||||
@@ -154,7 +152,6 @@ export function ContactSection() {
|
|||||||
<a
|
<a
|
||||||
href={brand.phone_href}
|
href={brand.phone_href}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
onClick={() => track('phone-click', { from: 'contact-section' })}
|
|
||||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||||
>
|
>
|
||||||
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
|
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
|
||||||
@@ -197,29 +194,20 @@ export function ContactSection() {
|
|||||||
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
|
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
|
||||||
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
||||||
</p>
|
</p>
|
||||||
<PremiumButton
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-8 font-light"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSent(false)
|
setSent(false)
|
||||||
setForm({ name: '', phone: '', note: '', website: '' })
|
setForm({ name: '', phone: '', note: '' })
|
||||||
}}
|
}}
|
||||||
className="mt-8 font-light"
|
|
||||||
>
|
>
|
||||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||||
</PremiumButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
||||||
{/* honeypot — خارج از دید؛ فقط رباتها آن را پر میکنند */}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="website"
|
|
||||||
value={form.website}
|
|
||||||
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
|
||||||
tabIndex={-1}
|
|
||||||
autoComplete="off"
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
|
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
|
||||||
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
|
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
|
||||||
@@ -265,17 +253,21 @@ export function ContactSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2 flex justify-start">
|
<div className="md:col-span-2 flex justify-start">
|
||||||
<PremiumButton
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
variant="copper"
|
||||||
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
>
|
>
|
||||||
{sending ? (
|
{sending ? (
|
||||||
content.sending_label
|
content.sending_label
|
||||||
) : (
|
) : (
|
||||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
<>
|
||||||
|
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||||
|
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</PremiumButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { LanguagesIcon } from 'lucide-react'
|
||||||
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
||||||
import { track } from '../analytics'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* تنظیمات ثابت هر زبان.
|
|
||||||
* ترتیب اینجا = ترتیب نمایش در کپسول (فارسی سمت چپ، English سمت راست).
|
|
||||||
* بجای مقادیر پیکسلی، از translate-x-full استفاده شده تا دقیقاً به اندازه عرض خودش (50%) جابجا شود.
|
|
||||||
*/
|
|
||||||
const LANGS = {
|
|
||||||
fa: { label: 'فارسی', sliderTranslate: 'translate-x-0' },
|
|
||||||
en: { label: 'English', sliderTranslate: 'translate-x-full' },
|
|
||||||
} as const
|
|
||||||
|
|
||||||
type LangKey = keyof typeof LANGS
|
|
||||||
|
|
||||||
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
||||||
export function LangToggle({ className = '' }: { className?: string }) {
|
export function LangToggle({ className = '' }: { className?: string }) {
|
||||||
@@ -22,9 +10,7 @@ export function LangToggle({ className = '' }: { className?: string }) {
|
|||||||
const target = counterpartPath(pathname, lang)
|
const target = counterpartPath(pathname, lang)
|
||||||
|
|
||||||
const switchLang = () => {
|
const switchLang = () => {
|
||||||
const next = lang === 'fa' ? 'en' : 'fa'
|
writeLangCookie(lang === 'fa' ? 'en' : 'fa')
|
||||||
track('lang-switch', { to: next })
|
|
||||||
writeLangCookie(next)
|
|
||||||
// پرش خودکار بازدید بعدی با انتخاب دستی همینجا گره نمیخورد
|
// پرش خودکار بازدید بعدی با انتخاب دستی همینجا گره نمیخورد
|
||||||
sessionStorage.setItem('langAutoRedirect', '1')
|
sessionStorage.setItem('langAutoRedirect', '1')
|
||||||
navigate(target)
|
navigate(target)
|
||||||
@@ -36,61 +22,10 @@ export function LangToggle({ className = '' }: { className?: string }) {
|
|||||||
onClick={switchLang}
|
onClick={switchLang}
|
||||||
aria-label={t('switchLangAria')}
|
aria-label={t('switchLangAria')}
|
||||||
title={t('switchLangAria')}
|
title={t('switchLangAria')}
|
||||||
dir="ltr"
|
className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 cursor-pointer ${className}`}
|
||||||
className={[
|
|
||||||
// ── Capsule shell ──────────────────────────────────────────────
|
|
||||||
'relative flex items-center',
|
|
||||||
'w-[152px] h-[42px] rounded-full p-1', // استفاده از p-1 (4px) به جای [4px]
|
|
||||||
'bg-[#1a1715]',
|
|
||||||
// deep inset shadow → recessed track feel
|
|
||||||
'shadow-[inset_0_3px_7px_rgba(0,0,0,0.75),inset_0_-1px_2px_rgba(255,255,255,0.04)]',
|
|
||||||
// interaction
|
|
||||||
'cursor-pointer select-none',
|
|
||||||
'active:scale-[0.975] transition-transform duration-150 ease-lux',
|
|
||||||
className,
|
|
||||||
].join(' ')}
|
|
||||||
>
|
>
|
||||||
{/* ── Inner Track ───────────────────────────────────────────────
|
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
||||||
این دیو داخلی باعث میشود پدینگ دکمه اصلی روی محاسبات 50 درصدی تاثیر نگذارد
|
<span>{t('switchLang')}</span>
|
||||||
*/}
|
|
||||||
<div className="relative w-full h-full flex items-center">
|
|
||||||
|
|
||||||
{/* ── Slider pill ─────────────────────────────────────────────── */}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={[
|
|
||||||
'absolute top-0 left-0', // چسبیده به لبههای Track داخلی
|
|
||||||
'w-1/2 h-full rounded-full', // دقیقاً 50 درصد عرض و کل ارتفاع
|
|
||||||
'bg-[#27221c] border border-[#d5874c]',
|
|
||||||
// subtle raised shadow — tactile without being flashy
|
|
||||||
'shadow-[0_2px_6px_rgba(0,0,0,0.55),0_0_0_1px_rgba(213,135,76,0.18),inset_0_1px_1px_rgba(255,255,255,0.06)]',
|
|
||||||
'transition-transform duration-[380ms] ease-lux',
|
|
||||||
LANGS[lang as LangKey].sliderTranslate,
|
|
||||||
].join(' ')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── Labels ──────────────────────────────────────────────────── */}
|
|
||||||
<span className="relative z-10 flex w-full h-full">
|
|
||||||
{(Object.keys(LANGS) as LangKey[]).map((key) => {
|
|
||||||
const isActive = lang === key
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={key}
|
|
||||||
className={[
|
|
||||||
// each label occupies exactly half the capsule
|
|
||||||
'w-1/2 h-full flex justify-center items-center',
|
|
||||||
'text-[11.5px] font-semibold tracking-wide leading-none',
|
|
||||||
'transition-colors duration-[380ms] ease-lux',
|
|
||||||
isActive ? 'text-[#d5874c]' : 'text-[#6a6662]',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{LANGS[key].label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,8 +74,15 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
|||||||
</motion.p>
|
</motion.p>
|
||||||
<div className="mt-8 h-px w-28 bg-copper" />
|
<div className="mt-8 h-px w-28 bg-copper" />
|
||||||
|
|
||||||
{/* ویرایش تصویر پسزمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
|
{/* ویرایش هیروی صفحه (عنوان/سرآغاز/تصویر)، تصویر پسزمینه و سئو — فقط در حالت ویرایش ادمین */}
|
||||||
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
|
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
|
||||||
|
<SingletonPill
|
||||||
|
resource="page-heroes"
|
||||||
|
label="عنوان، سرآغاز و تصویر هیروی این صفحه"
|
||||||
|
select={(d) => d.page_heroes.find((h) => h.key === pageKey)}
|
||||||
|
// اگر ردیف هیروی این صفحه هنوز ساخته نشده باشد، همینجا ساخته میشود
|
||||||
|
createDefaults={() => ({ key: pageKey, title: '', lead: '', image: '' })}
|
||||||
|
/>
|
||||||
<SingletonPill
|
<SingletonPill
|
||||||
resource="seo-meta"
|
resource="seo-meta"
|
||||||
label="عنوان و توضیح سئوی این صفحه"
|
label="عنوان و توضیح سئوی این صفحه"
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { Link } from 'react-router-dom'
|
|||||||
import { useStatusLabel } from '../api'
|
import { useStatusLabel } from '../api'
|
||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
import { PremiumButton } from './PremiumButton'
|
import { PremiumButton } from './PremiumButton'
|
||||||
import type { StaticProject } from '../data/projectsData'
|
export type StaticProject = {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
name: { en: string; fa: string }
|
||||||
|
location: { en: string; fa: string }
|
||||||
|
area: { en: string; fa: string }
|
||||||
|
status: 'done' | 'progress'
|
||||||
|
}
|
||||||
import type { ProjectCardItem } from '../data/site'
|
import type { ProjectCardItem } from '../data/site'
|
||||||
|
|
||||||
type ProjectCardProps = {
|
type ProjectCardProps = {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
|
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
|
||||||
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود میشود تا بریده نشود */}
|
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود میشود تا بریده نشود */}
|
||||||
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
|
<ul className="no-scrollbar mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isActive = item.id === activeId
|
const isActive = item.id === activeId
|
||||||
return (
|
return (
|
||||||
@@ -120,7 +120,9 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
|
|||||||
onClick={(event) => go(event, item.id)}
|
onClick={(event) => go(event, item.id)}
|
||||||
aria-current={isActive ? 'location' : undefined}
|
aria-current={isActive ? 'location' : undefined}
|
||||||
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
|
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
|
||||||
item.level === 3 ? 'ps-4 text-[13px]' : ''
|
// H3 زیرمجموعهٔ H2 است: تورفتگی واضحتر و یک پله کوچکتر؛ با ps-4
|
||||||
|
// تفاوتش با H2 فقط ۴ پیکسل بود و به چشم نمیآمد.
|
||||||
|
item.level === 3 ? 'ps-6 text-[13px]' : ''
|
||||||
} ${
|
} ${
|
||||||
isActive
|
isActive
|
||||||
? 'font-normal text-copper'
|
? 'font-normal text-copper'
|
||||||
|
|||||||
@@ -311,12 +311,15 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
setUploading(true)
|
setUploading(true)
|
||||||
await runSave(async () => {
|
await runSave(async () => {
|
||||||
const { url } = await adminApi.upload(file, name)
|
const { url } = await adminApi.upload(file, name)
|
||||||
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
// «نام تصویر»ای که کاربر پیش از بارگذاری نوشته، همان Alt تصویر میشود؛
|
||||||
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
// در نبود آن نام فایل و در نبود آن مقدار پیشفرض — تا نیازی به اصلاح دستی
|
||||||
|
// در جعبهٔ زیر تصویر نباشد.
|
||||||
|
const typedName = (name ?? '').trim()
|
||||||
|
const fileAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
|
.setImage({ src: url, alt: typedName || fileAlt || DEFAULT_IMAGE_ALT })
|
||||||
.run()
|
.run()
|
||||||
})
|
})
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
@@ -476,7 +479,7 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
data-editor-file-input
|
data-editor-file-input
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
accept="image/*,video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-matroska"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { ArrowDownIcon, ArrowUpIcon, ImagePlusIcon, Loader2Icon, XIcon } from 'lucide-react'
|
||||||
|
import { adminApi } from '../../admin/adminClient'
|
||||||
|
|
||||||
|
type GalleryFieldProps = {
|
||||||
|
label: string
|
||||||
|
value: string[]
|
||||||
|
/** فهرست کامل نشانیها را ذخیره میکند — همه از MinIO */
|
||||||
|
onSave: (urls: string[]) => Promise<boolean>
|
||||||
|
/** پیشوند نام ذخیرهسازی فایلها — مثلاً اسلاگ پروژه */
|
||||||
|
namePrefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* گالری چندتصویری — فقط آپلود به MinIO.
|
||||||
|
* هیچ ورودی URL وجود ندارد؛ نشانیها فقط از آپلود ساخته میشوند.
|
||||||
|
* هر تصویر: حذف، جابهجایی بالا/پایین. چند فایل همزمان قابل آپلود است.
|
||||||
|
*/
|
||||||
|
export function GalleryField({ label, value, onSave, namePrefix = 'image' }: GalleryFieldProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [uploading, setUploading] = useState(0)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const commit = async (urls: string[]) => {
|
||||||
|
if (!(await onSave(urls))) setError('ذخیرهٔ گالری ناموفق بود — دوباره تلاش کنید')
|
||||||
|
}
|
||||||
|
|
||||||
|
const addFiles = async (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
setError('')
|
||||||
|
setUploading(files.length)
|
||||||
|
const next = [...value]
|
||||||
|
try {
|
||||||
|
let i = 0
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
i += 1
|
||||||
|
const base = file.name.replace(/\.[^.]+$/, '')
|
||||||
|
const name = `${namePrefix}-${Date.now()}-${i}`
|
||||||
|
try {
|
||||||
|
const { url } = await adminApi.upload(file, `${name}-${base}`)
|
||||||
|
next.push(url)
|
||||||
|
} catch {
|
||||||
|
setError(`آپلود «${file.name}» ناموفق بود`)
|
||||||
|
} finally {
|
||||||
|
setUploading((n) => n - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next.length !== value.length) await commit(next)
|
||||||
|
} finally {
|
||||||
|
setUploading(0)
|
||||||
|
if (inputRef.current) inputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAt = async (index: number) => {
|
||||||
|
const next = value.filter((_, i) => i !== index)
|
||||||
|
await commit(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = async (index: number, delta: number) => {
|
||||||
|
const target = index + delta
|
||||||
|
if (target < 0 || target >= value.length) return
|
||||||
|
const next = [...value]
|
||||||
|
;[next[index], next[target]] = [next[target], next[index]]
|
||||||
|
await commit(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||||
|
|
||||||
|
{value.length === 0 ? (
|
||||||
|
<div className="mt-2 flex h-24 w-full flex-col items-center justify-center gap-1 border border-dashed border-ivory/20 text-ivory/30">
|
||||||
|
<ImagePlusIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
<span className="text-[11px] font-light">تصویری در گالری نیست</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{value.map((url, index) => (
|
||||||
|
<li key={`${url}-${index}`} className="group relative overflow-hidden rounded-lg border border-ivory/15 bg-char">
|
||||||
|
<img src={url} alt="" className="h-24 w-full object-cover" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between bg-char/80 px-1.5 py-1 opacity-0 transition-opacity duration-150 group-hover:opacity-100">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="انتقال به قبل"
|
||||||
|
title="انتقال به قبل"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => void move(index, -1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center border border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="انتقال به بعد"
|
||||||
|
title="انتقال به بعد"
|
||||||
|
disabled={index === value.length - 1}
|
||||||
|
onClick={() => void move(index, 1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center border border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="حذف تصویر"
|
||||||
|
title="حذف تصویر"
|
||||||
|
onClick={() => void removeAt(index)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center border border-red-400/40 bg-ink/90 text-red-300 hover:bg-red-500/20"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={uploading > 0}
|
||||||
|
className="mt-3 flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading > 0 ? (
|
||||||
|
<>
|
||||||
|
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
در حال آپلود {uploading} تصویر…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||||
|
افزودن تصویر به گالری (آپلود به MinIO)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && <p className="mt-2 text-xs text-red-300">{error}</p>}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*,video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-matroska"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
void addFiles(event.target.files)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -191,7 +191,7 @@ export function ImageField({
|
|||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
accept="image/*,video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-matroska"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
void pick(event.target.files?.[0])
|
void pick(event.target.files?.[0])
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { ArrowLeftIcon } from 'lucide-react';
|
||||||
import { Reveal } from '../Reveal';
|
import { Reveal } from '../Reveal';
|
||||||
import { PremiumButton } from '../PremiumButton';
|
import { ButtonLink } from '../Button';
|
||||||
import { ArticleCard } from '../ArticleCard';
|
import { ArticleCard } from '../ArticleCard';
|
||||||
import { useSectionHeading, useSite } from '../../api';
|
import { useSectionHeading, useSite } from '../../api';
|
||||||
import { useLang } from '../../i18n/lang';
|
import { useLang } from '../../i18n/lang';
|
||||||
@@ -53,9 +53,10 @@ export function Articles() {
|
|||||||
{heading.cta_label && heading.cta_href && (
|
{heading.cta_label && heading.cta_href && (
|
||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<div className="mt-12 flex justify-center">
|
<div className="mt-12 flex justify-center">
|
||||||
<PremiumButton to={href(heading.cta_href)} className="font-light">
|
<ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
|
||||||
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||||
</PremiumButton>
|
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||||
|
</ButtonLink>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,22 +18,20 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
||||||
<div className="mx-auto max-w-site px-4 md:px-8 lg:px-10">
|
|
||||||
|
|
||||||
{/* Header — centered, full width */}
|
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
|
||||||
|
|
||||||
{!hideHeading && (
|
{!hideHeading && (
|
||||||
<div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center">
|
<div className="lg:col-span-4">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={t('fallbackFaqTitle')}
|
title={t('fallbackFaqTitle')}
|
||||||
lead={t('fallbackFaqLead')}
|
lead={t('fallbackFaqLead')}
|
||||||
headingKey="faq"
|
headingKey="faq"
|
||||||
align="center"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Accordion list — constrained width, direction-aware */}
|
<div className={hideHeading ? "" : "lg:col-span-8"}>
|
||||||
<div className="w-full max-w-3xl mx-auto">
|
|
||||||
<ul>
|
<ul>
|
||||||
{faqs.map((faq, i) => {
|
{faqs.map((faq, i) => {
|
||||||
const isOpen = open === i
|
const isOpen = open === i
|
||||||
@@ -45,9 +43,9 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(isOpen ? null : i)}
|
onClick={() => setOpen(isOpen ? null : i)}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
|
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
||||||
>
|
>
|
||||||
<span className={`text-base font-bold md:text-lg text-start ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||||
{faq.question}
|
{faq.question}
|
||||||
</span>
|
</span>
|
||||||
<PlusIcon
|
<PlusIcon
|
||||||
@@ -65,7 +63,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<p className="pb-7 ps-10 text-sm font-light leading-9 text-ivory/60 text-start">{faq.answer}</p>
|
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import { useFieldSaver } from '../../admin/AdminContext'
|
|||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { InlineEdit } from '../admin/InlineEdit'
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
import type { ProjectStatus } from '../../data/site'
|
import type { ProjectStatus } from '../../data/site'
|
||||||
import { projectsData } from '../../data/projectsData'
|
|
||||||
|
|
||||||
export function FeaturedProjects() {
|
export function FeaturedProjects() {
|
||||||
const { status_labels } = useSite()
|
const { status_labels, projects } = useSite()
|
||||||
const { t, href } = useLang()
|
const { t, href } = useLang()
|
||||||
const heading = useSectionHeading('featured_projects')
|
const heading = useSectionHeading('featured_projects')
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
@@ -34,8 +33,8 @@ export function FeaturedProjects() {
|
|||||||
|
|
||||||
const [status, setStatus] = useState<ProjectStatus>('done')
|
const [status, setStatus] = useState<ProjectStatus>('done')
|
||||||
|
|
||||||
// Filter and slice static project data
|
// سه پروژه از دیتابیس — ویرایش پنل همانجا اعمال میشود
|
||||||
const list = projectsData.filter((p) => p.status === status).slice(0, 3)
|
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
||||||
const BASE_IMG_URL = "/seed-media/2026/09/";
|
const BASE_IMG_URL = "/seed-media/2026/09/";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,8 +86,7 @@ export function FeaturedProjects() {
|
|||||||
className="grid gap-6 lg:grid-cols-3"
|
className="grid gap-6 lg:grid-cols-3"
|
||||||
>
|
>
|
||||||
{list.map((project, i) => {
|
{list.map((project, i) => {
|
||||||
const dummyImageName = `${(i % 6) + 1}.png`;
|
const imageUrl = project.image || undefined;
|
||||||
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||||
import { ChevronDownIcon } from 'lucide-react'
|
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||||
import { PremiumButton } from '../PremiumButton'
|
import { ButtonLink } from '../Button'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { useFieldSaver } from '../../admin/AdminContext'
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
@@ -51,7 +51,7 @@ export function Hero() {
|
|||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0, y: 18 }}
|
initial={{ opacity: 0, y: 18 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.6, delay: 0.05, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="text-sm font-light text-copper tracking-wide"
|
className="text-sm font-light text-copper tracking-wide"
|
||||||
>
|
>
|
||||||
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} />
|
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} />
|
||||||
@@ -61,7 +61,7 @@ export function Hero() {
|
|||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0, y: 28 }}
|
initial={{ opacity: 0, y: 28 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.6, delay: 0.12, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
|
className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
|
||||||
>
|
>
|
||||||
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} />
|
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} />
|
||||||
@@ -71,7 +71,7 @@ export function Hero() {
|
|||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0, y: 22 }}
|
initial={{ opacity: 0, y: 22 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.6, delay: 0.2, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/75"
|
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/75"
|
||||||
>
|
>
|
||||||
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
|
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
|
||||||
@@ -81,23 +81,48 @@ export function Hero() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 22 }}
|
initial={{ opacity: 0, y: 22 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.6, delay: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="mt-10 flex flex-wrap items-center gap-4"
|
className="mt-10 flex flex-wrap items-center gap-4"
|
||||||
>
|
>
|
||||||
{/* دکمه اصلی */}
|
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
||||||
<PremiumButton to={href(hero.cta_primary_href)}>
|
<ButtonLink
|
||||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
to={href(hero.cta_primary_href)}
|
||||||
</PremiumButton>
|
variant="copper"
|
||||||
|
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium tracking-wide text-ivory">
|
||||||
|
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||||
|
</span>
|
||||||
|
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1">
|
||||||
|
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" />
|
||||||
|
</span>
|
||||||
|
</ButtonLink>
|
||||||
|
|
||||||
{/* دکمه ثانویه */}
|
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
||||||
<PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md">
|
<ButtonLink
|
||||||
|
to={href(hero.cta_secondary_href)}
|
||||||
|
variant="outline"
|
||||||
|
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
|
||||||
|
>
|
||||||
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
|
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
|
||||||
</PremiumButton>
|
</ButtonLink>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* کادر آمار شناور (گوشه پایین چپ): شیشهای با لبههای گرد مدرن */}
|
{/* کادر آمار شناور (گوشه پایین چپ): شیشهای با لبههای گرد مدرن */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -24 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.36, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10"
|
||||||
|
>
|
||||||
|
<p className="text-3xl font-bold text-copper font-mono">
|
||||||
|
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
|
||||||
|
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { CheckIcon } from 'lucide-react'
|
import { CheckIcon, SendIcon } from 'lucide-react'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { PremiumButton } from '../PremiumButton'
|
import { Button } from '../Button'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
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 { SingletonPill } from '../../admin/Editable'
|
import { SingletonPill } from '../../admin/Editable'
|
||||||
import { API_URL } from '../../config'
|
import { API_URL } from '../../config'
|
||||||
import { track } from '../../analytics'
|
|
||||||
|
|
||||||
export function Newsletter() {
|
export function Newsletter() {
|
||||||
const { newsletter } = useSite()
|
const { newsletter } = useSite()
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [website, setWebsite] = useState('')
|
|
||||||
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
@@ -77,7 +75,7 @@ export function Newsletter() {
|
|||||||
const res = await fetch(`${API_URL}/api/newsletter`, {
|
const res = await fetch(`${API_URL}/api/newsletter`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email: emailValue, website }),
|
body: JSON.stringify({ email: emailValue }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -89,7 +87,6 @@ export function Newsletter() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
track('newsletter-signup')
|
|
||||||
setStatus('done')
|
setStatus('done')
|
||||||
setEmail('')
|
setEmail('')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -101,7 +98,7 @@ export function Newsletter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative overflow-hidden bg-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
<section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
||||||
<SingletonPill resource="newsletter" label="ویرایش متنهای خبرنامه" className="absolute left-4 top-4" />
|
<SingletonPill resource="newsletter" label="ویرایش متنهای خبرنامه" className="absolute left-4 top-4" />
|
||||||
|
|
||||||
{/* هاله نور پسزمینه برای عمق بصری */}
|
{/* هاله نور پسزمینه برای عمق بصری */}
|
||||||
@@ -120,17 +117,6 @@ export function Newsletter() {
|
|||||||
|
|
||||||
<Reveal delay={0.08}>
|
<Reveal delay={0.08}>
|
||||||
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
|
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
|
||||||
{/* honeypot — خارج از دید؛ فقط رباتها آن را پر میکنند */}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="website"
|
|
||||||
value={website}
|
|
||||||
onChange={(e) => setWebsite(e.target.value)}
|
|
||||||
tabIndex={-1}
|
|
||||||
autoComplete="off"
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label htmlFor="newsletter-email" className="sr-only">
|
<label htmlFor="newsletter-email" className="sr-only">
|
||||||
{newsletter.placeholder}
|
{newsletter.placeholder}
|
||||||
@@ -156,18 +142,19 @@ export function Newsletter() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
variant="dark"
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
className="group/btn relative inline-flex items-center justify-center overflow-hidden rounded-full px-10 py-4 text-sm font-bold tracking-widest text-white border border-ink bg-ink transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg cursor-pointer select-none disabled:opacity-50 disabled:cursor-not-allowed"
|
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
|
||||||
>
|
>
|
||||||
<span className="absolute inset-0 z-0 bg-white scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
|
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||||
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
|
<span>
|
||||||
{sending ? newsletter.sending_label : (
|
{sending ? newsletter.sending_label : (
|
||||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { ArrowLeftIcon } from 'lucide-react'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { PremiumButton } from '../PremiumButton'
|
import { ButtonLink } from '../Button'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { useFieldSaver } from '../../admin/AdminContext'
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
@@ -89,9 +89,10 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
|
|
||||||
{showLink && (
|
{showLink && (
|
||||||
<Reveal delay={0.22}>
|
<Reveal delay={0.22}>
|
||||||
<PremiumButton to={href(story.link_href)} className="mt-10 font-light">
|
<ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light">
|
||||||
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
||||||
</PremiumButton>
|
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||||
|
</ButtonLink>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
export type BilingualText = { en: string; fa: string };
|
|
||||||
|
|
||||||
export type StaticProject = {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
name: BilingualText;
|
|
||||||
location: BilingualText;
|
|
||||||
area: BilingualText;
|
|
||||||
status: 'done' | 'progress';
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export const projectsData: StaticProject[] = [
|
|
||||||
{ id: '1', slug: 'project-1', name: { en: 'Project 1', fa: 'پروژه سروش' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '2', slug: 'project-2', name: { en: 'Project 2', fa: 'چهرازی-زیتون' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '3', slug: 'project-3', name: { en: 'Project 3', fa: 'جعفریان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '4', slug: 'project-4', name: { en: 'Project 4', fa: 'بهار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '5', slug: 'project-5', name: { en: 'Project 5', fa: 'حسن مرادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '6', slug: 'project-6', name: { en: 'Project 6', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '7', slug: 'project-7', name: { en: 'Project 7', fa: 'صبا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '8', slug: 'project-8', name: { en: 'Project 8', fa: 'پورمعالی - بهشت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '9', slug: 'project-9', name: { en: 'Project 9', fa: '2 پروژه- فردوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '10', slug: 'project-10', name: { en: 'Project 10', fa: 'ارم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '11', slug: 'project-11', name: { en: 'Project 11', fa: 'سینا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '12', slug: 'project-12', name: { en: 'Project 12', fa: 'مسجد سید' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '13', slug: 'project-13', name: { en: 'Project 13', fa: 'هفده شهریور' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '14', slug: 'project-14', name: { en: 'Project 14', fa: 'آرامش رفیع زاده' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '15', slug: 'project-15', name: { en: 'Project 15', fa: 'بن بست شریعت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '16', slug: 'project-16', name: { en: 'Project 16', fa: 'قصر - یاسین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '17', slug: 'project-17', name: { en: 'Project 17', fa: 'پردیس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '18', slug: 'project-18', name: { en: 'Project 18', fa: 'آرامش حکیم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '19', slug: 'project-19', name: { en: 'Project 19', fa: 'آرامش دخانیات' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '20', slug: 'project-20', name: { en: 'Project 20', fa: 'بهشتی فرشادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '21', slug: 'project-21', name: { en: 'Project 21', fa: 'کاکتوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '22', slug: 'project-22', name: { en: 'Project 22', fa: 'آرامش - صیاد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '23', slug: 'project-23', name: { en: 'Project 23', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '24', slug: 'project-24', name: { en: 'Project 24', fa: 'آرامش - بوعلی 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '25', slug: 'project-25', name: { en: 'Project 25', fa: 'میرزا نصیر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '26', slug: 'project-26', name: { en: 'Project 26', fa: 'نیکان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '27', slug: 'project-27', name: { en: 'Project 27', fa: 'آرامش-رسا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '28', slug: 'project-28', name: { en: 'Project 28', fa: 'نگین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '29', slug: 'project-29', name: { en: 'Project 29', fa: 'آرامش - بوعلی 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '30', slug: 'project-30', name: { en: 'Project 30', fa: 'آرامش استاد همایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '31', slug: 'project-31', name: { en: 'Project 31', fa: 'آرامش صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '32', slug: 'project-32', name: { en: 'Project 32', fa: 'آرامش - سپهسالار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '33', slug: 'project-33', name: { en: 'Project 33', fa: 'آرامش- صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '34', slug: 'project-34', name: { en: 'Project 34', fa: 'آرامش - سنایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '35', slug: 'project-35', name: { en: 'Project 35', fa: 'مغازه لباس فروشی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '36', slug: 'project-36', name: { en: 'Project 36', fa: 'آرامش - آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '37', slug: 'project-37', name: { en: 'Project 37', fa: 'آرامش - بزرگمهر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '38', slug: 'project-38', name: { en: 'Project 38', fa: 'آرامش - سعدی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '39', slug: 'project-39', name: { en: 'Project 39', fa: 'آرامش - صمدیه لباف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '40', slug: 'project-40', name: { en: 'Project 40', fa: 'آرامش 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '41', slug: 'project-41', name: { en: 'Project 41', fa: 'آرامش 5 رمضان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '42', slug: 'project-42', name: { en: 'Project 42', fa: 'آرامش احمدآباد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '43', slug: 'project-43', name: { en: 'Project 43', fa: 'تجاری آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '44', slug: 'project-44', name: { en: 'Project 44', fa: 'آرامش - شهیدان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '45', slug: 'project-45', name: { en: 'Project 45', fa: 'آرامش خواجو' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '46', slug: 'project-46', name: { en: 'Project 46', fa: 'آرامش- اداری ابوالحسن' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '47', slug: 'project-47', name: { en: 'Project 47', fa: 'آرامش - انقلاب' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '48', slug: 'project-48', name: { en: 'Project 48', fa: 'آرامش - شریف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '49', slug: 'project-49', name: { en: 'Project 49', fa: 'آرامش - کهندژ' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '50', slug: 'project-50', name: { en: 'Project 50', fa: 'آرامش جی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '51', slug: 'project-51', name: { en: 'Project 51', fa: 'آرامش - بابک' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '52', slug: 'project-52', name: { en: 'Project 52', fa: 'آرامش - شمس آبادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '53', slug: 'project-53', name: { en: 'Project 53', fa: 'آرامش - میرزاطاهر 3' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '54', slug: 'project-54', name: { en: 'Project 54', fa: 'آرامش - وحدت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '55', slug: 'project-55', name: { en: 'Project 55', fa: 'آرامش پروین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
|
||||||
{ id: '56', slug: 'project-56', name: { en: 'Project 56', fa: 'خلیفه سلطانی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '57', slug: 'project-57', name: { en: 'Project 57', fa: 'آرامش - میرزا طاهر 5' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '58', slug: 'project-58', name: { en: 'Project 58', fa: 'آرامش - میرزاطاهر 6' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '59', slug: 'project-59', name: { en: 'Project 59', fa: 'آرامش - میرزا طاهر 4' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
{ id: '60', slug: 'project-60', name: { en: 'Project 60', fa: 'آرامش- میرزا طاهر 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
|
||||||
];
|
|
||||||
@@ -53,8 +53,16 @@ export const fa = {
|
|||||||
projectNotFound: 'این پروژه پیدا نشد',
|
projectNotFound: 'این پروژه پیدا نشد',
|
||||||
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژهها را ببینید.',
|
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژهها را ببینید.',
|
||||||
backToProjects: 'بازگشت به پروژهها',
|
backToProjects: 'بازگشت به پروژهها',
|
||||||
|
specStatus: 'وضعیت',
|
||||||
|
specLandArea: 'مساحت عرصه (زمین)',
|
||||||
|
specPilotFloors: 'طبقات روی همکف (پیلوت)',
|
||||||
|
specUnitsResidential: 'تعداد واحد مسکونی',
|
||||||
|
specUnitsCommercial: 'تعداد واحد تجاری',
|
||||||
|
specUnitsOffice: 'تعداد واحد اداری',
|
||||||
specLocation: 'موقعیت',
|
specLocation: 'موقعیت',
|
||||||
specYear: 'سال',
|
specProjectType: 'نوع پروژه',
|
||||||
|
specUsage: 'کاربری',
|
||||||
|
specYear: 'سال ساخت',
|
||||||
specArea: 'زیربنا',
|
specArea: 'زیربنا',
|
||||||
specFloors: 'تعداد طبقات',
|
specFloors: 'تعداد طبقات',
|
||||||
specUnits: 'واحدها',
|
specUnits: 'واحدها',
|
||||||
@@ -166,8 +174,16 @@ export const en: Dict = {
|
|||||||
projectNotFound: 'Project not found',
|
projectNotFound: 'Project not found',
|
||||||
projectNotFoundText: 'The address may have changed. Browse the full list of projects.',
|
projectNotFoundText: 'The address may have changed. Browse the full list of projects.',
|
||||||
backToProjects: 'Back to projects',
|
backToProjects: 'Back to projects',
|
||||||
|
specStatus: 'Status',
|
||||||
|
specLandArea: 'Land area',
|
||||||
|
specPilotFloors: 'Floors above ground',
|
||||||
|
specUnitsResidential: 'Residential units',
|
||||||
|
specUnitsCommercial: 'Commercial units',
|
||||||
|
specUnitsOffice: 'Office units',
|
||||||
specLocation: 'Location',
|
specLocation: 'Location',
|
||||||
specYear: 'Year',
|
specProjectType: 'Project type',
|
||||||
|
specUsage: 'Land use',
|
||||||
|
specYear: 'Year built',
|
||||||
specArea: 'Built area',
|
specArea: 'Built area',
|
||||||
specFloors: 'Floors',
|
specFloors: 'Floors',
|
||||||
specUnits: 'Units',
|
specUnits: 'Units',
|
||||||
|
|||||||
+17
-8
@@ -150,20 +150,16 @@ html[lang='en'] .article-body h3 {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* تیترها و فهرستها همتراز نمیشوند — فقط متن پاراگراف تراز میشود
|
/* فقط تیترها همتراز نمیشوند (تیتر کوتاه با justify کشیده و بدقلق میشود) —
|
||||||
(تیتر کوتاه با justify کشیده و بدقلق میشود و فهرست نقطهگذاریاش بههم میریزد). */
|
پاراگرافها و فهرستها کاملاً justify هستند و کپشن همیشه وسطچین است. */
|
||||||
[data-dir='rtl'] .article-body h1,
|
[data-dir='rtl'] .article-body h1,
|
||||||
[data-dir='rtl'] .article-body h2,
|
[data-dir='rtl'] .article-body h2,
|
||||||
[data-dir='rtl'] .article-body h3,
|
[data-dir='rtl'] .article-body h3 {
|
||||||
[data-dir='rtl'] .article-body li,
|
|
||||||
[data-dir='rtl'] .article-body figcaption {
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
[data-dir='ltr'] .article-body h1,
|
[data-dir='ltr'] .article-body h1,
|
||||||
[data-dir='ltr'] .article-body h2,
|
[data-dir='ltr'] .article-body h2,
|
||||||
[data-dir='ltr'] .article-body h3,
|
[data-dir='ltr'] .article-body h3 {
|
||||||
[data-dir='ltr'] .article-body li,
|
|
||||||
[data-dir='ltr'] .article-body figcaption {
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
[data-dir='ltr'] .article-body h1 {
|
[data-dir='ltr'] .article-body h1 {
|
||||||
@@ -257,6 +253,15 @@ html[lang='en'] .article-body h3 {
|
|||||||
border: 1px solid rgba(245, 243, 239, 0.1);
|
border: 1px solid rgba(245, 243, 239, 0.1);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
/* فهرست مطالب: اسکرول بدون نوار اسکرول — چرخ ماوس و لمس کار میکنند */
|
||||||
|
.no-scrollbar {
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE/Edge legacy */
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome / Safari / Edge */
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- تصویر کپشندار: <figure><img><figcaption> ---------- */
|
/* ---------- تصویر کپشندار: <figure><img><figcaption> ---------- */
|
||||||
.article-body figure {
|
.article-body figure {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
@@ -283,6 +288,10 @@ html[lang='en'] .article-body h3 {
|
|||||||
/* تراز از جهت بلوک پیروی میکند — در مقاله انگلیسی چپ و در فارسی راست */
|
/* تراز از جهت بلوک پیروی میکند — در مقاله انگلیسی چپ و در فارسی راست */
|
||||||
text-align: start;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
/* متن کپشن در ویرایشگر هم وسطچین است تا با خروجی سایت یکی باشد */
|
||||||
|
[data-wysiwyg] .article-figure__caption input[type='text'] {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
[data-wysiwyg] .article-figure-node.is-selected .article-figure {
|
[data-wysiwyg] .article-figure-node.is-selected .article-figure {
|
||||||
outline: 1px dashed rgba(213, 135, 76, 0.8);
|
outline: 1px dashed rgba(213, 135, 76, 0.8);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import "./index.css";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import { initAnalytics } from "./analytics";
|
||||||
|
|
||||||
|
initAnalytics();
|
||||||
|
|
||||||
const rootEl = document.getElementById("root");
|
const rootEl = document.getElementById("root");
|
||||||
if (rootEl) {
|
if (rootEl) {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
import { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
import { renderToHTMLString } from '@tiptap/static-renderer';
|
import { renderToHTMLString } from '@tiptap/static-renderer';
|
||||||
import { Reveal } from '../components/Reveal';
|
import { Reveal } from '../components/Reveal';
|
||||||
import { PremiumButton } from '../components/PremiumButton';
|
import { ButtonLink } from '../components/Button';
|
||||||
import { ArticleCard } from '../components/ArticleCard';
|
import { ArticleCard } from '../components/ArticleCard';
|
||||||
import { ArticleMeta } from '../components/ArticleMeta';
|
import { ArticleMeta } from '../components/ArticleMeta';
|
||||||
import { ArticleFaq } from '../components/ArticleFaq';
|
|
||||||
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
||||||
import { Newsletter } from '../components/home/Newsletter';
|
import { Newsletter } from '../components/home/Newsletter';
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
@@ -48,35 +47,6 @@ export function ArticleDetail() {
|
|||||||
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!article) return;
|
if (!article) return;
|
||||||
let jsonLd: Record<string, unknown> | Record<string, unknown>[] = {
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'Article',
|
|
||||||
headline: article.title,
|
|
||||||
description: article.meta_description || article.excerpt,
|
|
||||||
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
|
||||||
inLanguage: lang,
|
|
||||||
author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
|
|
||||||
publisher: { '@type': 'Organization', name: brand.name },
|
|
||||||
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
|
|
||||||
};
|
|
||||||
// پرسشوپاسخ مقاله → اسکیمای FAQPage در همان بلوک (آرایهای از دو اسکیما)
|
|
||||||
const articleFaqs = article.faqs ?? [];
|
|
||||||
if (articleFaqs.length) {
|
|
||||||
jsonLd = [
|
|
||||||
jsonLd,
|
|
||||||
{
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'FAQPage',
|
|
||||||
mainEntity: articleFaqs
|
|
||||||
.filter((f) => f.question.trim())
|
|
||||||
.map((f) => ({
|
|
||||||
'@type': 'Question',
|
|
||||||
name: f.question,
|
|
||||||
acceptedAnswer: { '@type': 'Answer', text: f.answer },
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${article.title} | ${brand.name}`,
|
title: `${article.title} | ${brand.name}`,
|
||||||
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
||||||
@@ -85,7 +55,17 @@ export function ArticleDetail() {
|
|||||||
image: article.image,
|
image: article.image,
|
||||||
type: 'article',
|
type: 'article',
|
||||||
lang,
|
lang,
|
||||||
jsonLd,
|
jsonLd: {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: article.title,
|
||||||
|
description: article.meta_description || article.excerpt,
|
||||||
|
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
||||||
|
inLanguage: lang,
|
||||||
|
author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
|
||||||
|
publisher: { '@type': 'Organization', name: brand },
|
||||||
|
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}, [article?.id, lang, href]);
|
}, [article?.id, lang, href]);
|
||||||
|
|
||||||
@@ -94,9 +74,10 @@ export function ArticleDetail() {
|
|||||||
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
||||||
<h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1>
|
<h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1>
|
||||||
<p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p>
|
<p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p>
|
||||||
<PremiumButton to={href('/blog')} className="mt-8">
|
<ButtonLink to={href('/blog')} variant="copper" className="mt-8">
|
||||||
{t('backToBlog')}
|
{t('backToBlog')}
|
||||||
</PremiumButton>
|
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||||
|
</ButtonLink>
|
||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -111,7 +92,7 @@ export function ArticleDetail() {
|
|||||||
*/
|
*/
|
||||||
const hasToc = toc.length > 0;
|
const hasToc = toc.length > 0;
|
||||||
// در چیدمان راستبهچپ ستون شماره ۱ سمت راست است؛ پس ستونها صریح شماره میخورند
|
// در چیدمان راستبهچپ ستون شماره ۱ سمت راست است؛ پس ستونها صریح شماره میخورند
|
||||||
const gridColumns = hasToc ? 'lg:grid-cols-[17rem_minmax(0,1fr)]' : '';
|
const gridColumns = hasToc ? 'lg:grid-cols-[21rem_minmax(0,1fr)]' : '';
|
||||||
const bodyColumns = 'lg:col-start-2';
|
const bodyColumns = 'lg:col-start-2';
|
||||||
const tocColumns = 'lg:col-start-1';
|
const tocColumns = 'lg:col-start-1';
|
||||||
|
|
||||||
@@ -190,14 +171,6 @@ export function ArticleDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
{/* پرسشوپاسخهای پایان مقاله — همان ظاهر آکاردئونی FAQ سایت */}
|
|
||||||
<div className="bg-ink pb-4">
|
|
||||||
<div className="mx-auto max-w-5xl px-6 lg:px-10">
|
|
||||||
<h2 className="text-section font-bold text-ivory">{t('fallbackFaqTitle')}</h2>
|
|
||||||
<ArticleFaq faqs={article.faqs ?? []} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
|
<section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
|
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
|
||||||
|
|||||||
@@ -1,98 +1,81 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { ChevronLeftIcon } from 'lucide-react'
|
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
|
||||||
import { Reveal } from '../components/Reveal'
|
import { Reveal } from '../components/Reveal'
|
||||||
import { PremiumButton } from '../components/PremiumButton'
|
import { ButtonLink } from '../components/Button'
|
||||||
import { ProjectCard } from '../components/ProjectCard'
|
import { ProjectCard } from '../components/ProjectCard'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { Lightbox } from '../components/Lightbox'
|
import { Lightbox } from '../components/Lightbox'
|
||||||
import { LazyProjectsMapSingle } from '../map/LazyMaps';
|
import { useEffect } from 'react';
|
||||||
import { createPlaceholderProject, findMapProjectByName } from '../data/mapProjects'
|
|
||||||
import { useSite, useStatusLabel } from '../api';
|
import { useSite, useStatusLabel } from '../api';
|
||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
import { applySeo } from '../seo';
|
import { applySeo } from '../seo';
|
||||||
import { projectsData } from '../data/projectsData';
|
|
||||||
|
|
||||||
export function ProjectDetail() {
|
export function ProjectDetail() {
|
||||||
const { slug } = useParams<{ slug: string }>()
|
const { slug } = useParams<{ slug: string }>()
|
||||||
const { projects, brand } = useSite()
|
const { projects, brand } = useSite()
|
||||||
const { t, href, lang } = useLang()
|
const { t, href, lang } = useLang()
|
||||||
|
const project = projects.find((p) => p.slug === slug)
|
||||||
const currentProject = projectsData.find((p) => p.slug === slug)
|
const statusLabel = useStatusLabel(project?.status ?? '')
|
||||||
|
|
||||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||||
|
|
||||||
if (!currentProject) {
|
// عنوان تب، توضیح متا و OG از خود پروژه
|
||||||
return <div className="text-center py-20 text-ivory">Project not found</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const backendProject = projects.find((p) => p.slug === slug)
|
|
||||||
|
|
||||||
// Dynamic bilingual text from projectsData
|
|
||||||
const displayTitle = currentProject.name[lang as 'en' | 'fa'] || currentProject.name.en;
|
|
||||||
const displayLocation = currentProject.location[lang as 'en' | 'fa'] || currentProject.location.en;
|
|
||||||
const displayArea = currentProject.area[lang as 'en' | 'fa'] || currentProject.area.en;
|
|
||||||
const displayStatus = currentProject.status;
|
|
||||||
|
|
||||||
const statusLabel = useStatusLabel(displayStatus)
|
|
||||||
|
|
||||||
// Fallbacks for layout structure fields missing in projectsData
|
|
||||||
const summary = backendProject?.summary || (lang === 'en' ? 'A modern and luxurious architectural project built with high-quality materials and smart design.' : 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ.');
|
|
||||||
const body = backendProject?.body || (lang === 'en'
|
|
||||||
? ['Located in the heart of the city, this project offers unparalleled access to major urban hubs.', 'It features state-of-the-art facilities, green spaces, and a sustainable approach to modern living.']
|
|
||||||
: ['لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است.', 'چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.']);
|
|
||||||
const gallery = backendProject?.gallery?.length ? backendProject.gallery : [
|
|
||||||
'/seed-media/2026/09/1.png',
|
|
||||||
'/seed-media/2026/09/2.png',
|
|
||||||
'/seed-media/2026/09/3.png',
|
|
||||||
'/seed-media/2026/09/4.png'
|
|
||||||
];
|
|
||||||
const image = backendProject?.image || '/seed-media/2026/09/1.png';
|
|
||||||
const year = backendProject?.year || (lang === 'en' ? '2024' : '1402');
|
|
||||||
const floors = backendProject?.floors || '5';
|
|
||||||
const units = backendProject?.units || '20';
|
|
||||||
const client = backendProject?.client || (lang === 'en' ? 'Aramesh Group' : 'گروه آرامش');
|
|
||||||
|
|
||||||
// تصاویر پاپآپ نقشه
|
|
||||||
const mapImages = useMemo(
|
|
||||||
() => [image, ...gallery].filter(Boolean),
|
|
||||||
[image, gallery]
|
|
||||||
)
|
|
||||||
|
|
||||||
// مختصات همین پروژه
|
|
||||||
const mapLocation = useMemo(
|
|
||||||
() => findMapProjectByName(displayTitle) ?? createPlaceholderProject(displayTitle, displayLocation),
|
|
||||||
[displayTitle, displayLocation]
|
|
||||||
)
|
|
||||||
|
|
||||||
// SEO
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!project) return;
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${displayTitle} | ${brand.name}`,
|
title: `${project.title} | ${brand.name}`,
|
||||||
description: summary,
|
description: project.summary,
|
||||||
path: href(`/projects/${currentProject.slug}`),
|
path: href(`/projects/${project.slug}`),
|
||||||
image: image,
|
image: project.image,
|
||||||
lang,
|
lang,
|
||||||
});
|
});
|
||||||
}, [currentProject.slug, lang, href, displayTitle, summary, image, brand.name]);
|
}, [project?.id, lang, href]);
|
||||||
|
|
||||||
const related = projectsData.filter((p) => p.slug !== currentProject.slug).slice(0, 3)
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
||||||
|
<h1 className="text-section font-bold text-ivory">{t('projectNotFound')}</h1>
|
||||||
|
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||||
|
{t('projectNotFoundText')}
|
||||||
|
</p>
|
||||||
|
<ButtonLink to={href('/projects')} variant="copper" className="mt-8">
|
||||||
|
{t('backToProjects')}
|
||||||
|
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||||
|
</ButtonLink>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
|
||||||
|
|
||||||
|
// مشخصات هر پروژه — همان فهرستی که تیم داده دارد:
|
||||||
|
// وضعیت / سال / تعداد طبقات / واحدها / نوع پروژه / زیربنا / کاربری
|
||||||
|
// ردیفهای خالی نمایش داده نمیشوند.
|
||||||
const facts = [
|
const facts = [
|
||||||
{ label: t('specLocation'), value: displayLocation },
|
{ label: t('specStatus'), value: statusLabel },
|
||||||
{ label: t('specYear'), value: year },
|
{ label: t('specYear'), value: project.year },
|
||||||
{ label: t('specArea'), value: displayArea },
|
{ label: t('specLandArea'), value: project.land_area },
|
||||||
{ label: t('specFloors'), value: floors },
|
{ label: t('specArea'), value: project.area },
|
||||||
{ label: t('specUnits'), value: units },
|
{ label: t('specUsage'), value: project.usage },
|
||||||
{ label: t('specClient'), value: client },
|
{ label: t('specPilotFloors'), value: project.pilot_floors },
|
||||||
]
|
{ label: t('specFloors'), value: project.floors },
|
||||||
|
{ label: t('specUnitsResidential'), value: project.units_residential },
|
||||||
|
{ label: t('specUnitsCommercial'), value: project.units_commercial },
|
||||||
|
{ label: t('specUnitsOffice'), value: project.units_office },
|
||||||
|
].filter((f) => String(f.value ?? '').trim() !== '')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="bg-char pt-24 pb-12 lg:pt-32 lg:pb-16" aria-label={t('projectDetailsAria')}>
|
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
|
||||||
<nav aria-label={t('breadcrumbAria')} className="mb-6 flex items-center gap-2 text-xs font-light text-ivory/50">
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||||
|
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||||
{t('home')}
|
{t('home')}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -101,80 +84,85 @@ export function ProjectDetail() {
|
|||||||
{t('projects')}
|
{t('projects')}
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
||||||
<span className="text-copper">{displayTitle}</span>
|
<span className="text-copper">{project.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
|
||||||
<div className="mb-8">
|
<p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
|
||||||
<h1 className="text-4xl lg:text-5xl font-bold text-ivory">{displayTitle}</h1>
|
{/* ویرایش این پروژه — صفحهٔ اختصاصی ویرایش */}
|
||||||
</div>
|
<div className="mt-5">
|
||||||
|
<Link
|
||||||
{/* Gallery Desktop: Grid. Mobile: Grid */}
|
to={`/admin/projects/${project.id}`}
|
||||||
<div className="grid grid-cols-2 gap-2 md:gap-3 rounded-xl lg:rounded-2xl overflow-hidden lg:h-[500px] xl:h-[600px]">
|
data-admin-link
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-copper/50 bg-ink/90 px-4 py-2 text-xs font-light text-copper backdrop-blur-sm transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||||
{/* Main large image */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="col-span-2 lg:col-span-1 h-[250px] md:h-[400px] lg:h-full relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block"
|
|
||||||
onClick={() => setLightbox(0)}
|
|
||||||
>
|
>
|
||||||
<img src={mapImages[0]} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
|
ویرایش این پروژه (عنوان، مشخصات، گالری، متن)
|
||||||
</button>
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* 2x2 Small images */}
|
<section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}>
|
||||||
<div className="col-span-2 lg:col-span-1 grid grid-cols-2 gap-2 md:gap-3 lg:grid-rows-2 lg:h-full">
|
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
|
||||||
{mapImages.slice(1, 5).map((img, i) => (
|
|
||||||
<button
|
<Reveal className="order-1">
|
||||||
key={img}
|
<span className="flex items-center gap-3 text-sm font-light text-copper">
|
||||||
|
<span aria-hidden="true" className="h-px w-8 bg-copper" />
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
<h2 className="mt-4 text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
|
||||||
|
|
||||||
|
{project.body.map((para) => (
|
||||||
|
<p key={para} className="mt-5 text-justify text-base font-light leading-9 text-ivory/70">
|
||||||
|
{para}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10">
|
||||||
|
{facts.map((f) => (
|
||||||
|
<div key={f.label} className="bg-ink p-6 md:p-8 ltr:text-left rtl:text-right">
|
||||||
|
<dt className="text-xs font-light text-ivory/50">{f.label}</dt>
|
||||||
|
<dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-10">
|
||||||
|
<ButtonLink to={href('/contact')} variant="outline" className="font-light">
|
||||||
|
{t('similarProjectCta')}
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={0.1} className="order-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLightbox(0)}
|
||||||
|
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={project.gallery[0] || project.image}
|
||||||
|
alt=""
|
||||||
|
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||||
|
{project.gallery.slice(1).map((img, i) => (
|
||||||
|
<button
|
||||||
|
key={img}
|
||||||
type="button"
|
type="button"
|
||||||
className={`relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block h-[120px] md:h-[200px] lg:h-full ${i >= 2 ? 'hidden md:block' : ''}`}
|
|
||||||
onClick={() => setLightbox(i + 1)}
|
onClick={() => setLightbox(i + 1)}
|
||||||
|
className="group block overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||||
>
|
>
|
||||||
<img src={img} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
|
<img
|
||||||
|
src={img}
|
||||||
|
alt=""
|
||||||
|
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Smart Content Layout */}
|
|
||||||
<div className="max-w-4xl mx-auto mt-16 lg:mt-24 flex flex-col gap-16 lg:gap-20">
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<Reveal>
|
|
||||||
<span className="flex items-center gap-3 text-sm font-light text-copper mb-6">
|
|
||||||
<span aria-hidden="true" className="h-px w-8 bg-copper" />
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
<h2 className="text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
|
|
||||||
|
|
||||||
<p className="mt-8 text-xl font-medium leading-9 text-ivory/90">{summary}</p>
|
|
||||||
{body.map((para) => (
|
|
||||||
<p key={para} className="mt-6 text-base font-light leading-9 text-ivory/70">
|
|
||||||
{para}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
{/* Specifications Table - Perfectly center-aligned */}
|
|
||||||
<Reveal>
|
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-px border border-ivory/10 bg-ivory/10 rounded-xl overflow-hidden">
|
|
||||||
{facts.map((f) => (
|
|
||||||
<div key={f.label} className="bg-ink p-6 md:p-8 flex flex-col items-center justify-center text-center">
|
|
||||||
<dt className="text-xs font-light text-ivory/50 mb-2">{f.label}</dt>
|
|
||||||
<dd className="text-base font-bold text-ivory">{f.value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
{/* Map (بدون Reveal) */}
|
|
||||||
{mapLocation && (
|
|
||||||
<div className="h-80 md:h-[400px] rounded-xl overflow-hidden border border-white/10 shadow-2xl">
|
|
||||||
<LazyProjectsMapSingle location={mapLocation} images={mapImages} className="h-full w-full" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -183,24 +171,17 @@ export function ProjectDetail() {
|
|||||||
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
|
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
|
||||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||||
{related.map((item, i) => {
|
{related.map((item, i) => (
|
||||||
const dummyImageName = `${(i % 6) + 1}.png`;
|
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
|
||||||
return (
|
<ProjectCard project={item} />
|
||||||
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
|
</Reveal>
|
||||||
<ProjectCard
|
))}
|
||||||
project={item}
|
|
||||||
imageUrl={`/seed-media/2026/09/${dummyImageName}`}
|
|
||||||
detailHref={href(`/projects/${item.slug}`)}
|
|
||||||
/>
|
|
||||||
</Reveal>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Lightbox
|
<Lightbox
|
||||||
images={gallery}
|
images={project.gallery}
|
||||||
index={lightbox}
|
index={lightbox}
|
||||||
onClose={() => setLightbox(null)}
|
onClose={() => setLightbox(null)}
|
||||||
onChange={setLightbox}
|
onChange={setLightbox}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import { useSite, useStatusLabel } from '../api';
|
|||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
import { useFieldSaver } from '../admin/AdminContext';
|
import { useFieldSaver } from '../admin/AdminContext';
|
||||||
import { InlineEdit } from '../components/admin/InlineEdit';
|
import { InlineEdit } from '../components/admin/InlineEdit';
|
||||||
import { projectsData } from '../data/projectsData';
|
import { AddItemButton, EditableItem } from '../admin/Editable';
|
||||||
import { LazyProjectsMapAll } from '../map/LazyMaps';
|
import { LazyProjectsMapAll } from '../map/LazyMaps';
|
||||||
import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
|
import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
|
||||||
|
|
||||||
export function Projects() {
|
export function Projects() {
|
||||||
const { status_labels } = useSite();
|
const { status_labels, projects: dbProjects } = useSite();
|
||||||
const { t } = useLang();
|
const { t } = useLang();
|
||||||
const save = useFieldSaver();
|
const save = useFieldSaver();
|
||||||
|
|
||||||
@@ -35,6 +35,10 @@ export function Projects() {
|
|||||||
];
|
];
|
||||||
const [status, setStatus] = useState<'done' | 'progress'>('done');
|
const [status, setStatus] = useState<'done' | 'progress'>('done');
|
||||||
|
|
||||||
|
// پروژهها از دیتابیس خوانده میشوند تا ویرایش پنل همانجا دیده شود؛
|
||||||
|
// فایل استاتیک فقط وقتی دیتابیس خالی است جانشین میشود (محتوای قدیمی).
|
||||||
|
const cardProjects = useMemo(() => dbProjects, [dbProjects]);
|
||||||
|
|
||||||
// Filtering and Pagination State
|
// Filtering and Pagination State
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -42,8 +46,8 @@ export function Projects() {
|
|||||||
|
|
||||||
// Filter projects by status
|
// Filter projects by status
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
return projectsData.filter((p) => p.status === status);
|
return cardProjects.filter((p) => p.status === status);
|
||||||
}, [status]);
|
}, [cardProjects, status]);
|
||||||
|
|
||||||
// Currently displayed projects
|
// Currently displayed projects
|
||||||
const displayedProjects = useMemo(() => {
|
const displayedProjects = useMemo(() => {
|
||||||
@@ -68,12 +72,13 @@ export function Projects() {
|
|||||||
|
|
||||||
const BASE_IMG_URL = "/seed-media/2026/09/";
|
const BASE_IMG_URL = "/seed-media/2026/09/";
|
||||||
|
|
||||||
// Resolve a project href from a MapProject entry by matching against projectsData slugs
|
// Resolve a project href from a MapProject entry by matching names — first in the
|
||||||
|
// database (so pins follow panel edits), then in the static legacy list.
|
||||||
const resolveProjectHref = (mapProject: MapProject): string | undefined => {
|
const resolveProjectHref = (mapProject: MapProject): string | undefined => {
|
||||||
const match = projectsData.find(
|
const dbMatch = dbProjects.find(
|
||||||
(p) => p.name.fa === mapProject.name || p.name.en === mapProject.name
|
(p) => p.title === mapProject.name || p.title_en === mapProject.name,
|
||||||
);
|
);
|
||||||
return match ? `/projects/${match.slug}` : undefined;
|
return dbMatch ? `/projects/${dbMatch.slug}` : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,19 +136,24 @@ export function Projects() {
|
|||||||
|
|
||||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||||
{displayedProjects.map((project, index) => {
|
{displayedProjects.map((project, index) => {
|
||||||
const dummyImageName = `${(index % 6) + 1}.png`;
|
// تصویر رکورد دیتابیس اولویت دارد؛ فقط ردیفهای بدون تصویر از
|
||||||
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
|
// تصاویر جایگزین تکراری استفاده میکنند.
|
||||||
|
const card = (
|
||||||
|
<ProjectCard
|
||||||
|
project={project}
|
||||||
|
detailHref={`/projects/${project.slug}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
|
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
|
||||||
<ProjectCard
|
<EditableItem resource="projects" item={project} editHref={`/admin/projects/${project.id}`} className="h-full">
|
||||||
project={project}
|
{card}
|
||||||
imageUrl={imageUrl}
|
</EditableItem>
|
||||||
detailHref={`/projects/${project.slug}`}
|
|
||||||
/>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<AddItemButton resource="projects" editHrefBase="/admin/projects" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Load More Button */}
|
{/* Load More Button */}
|
||||||
|
|||||||
+7
-1
@@ -15,13 +15,19 @@ if [ -f .env ] && [ "${1:-}" != "--force" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rand() { openssl rand -"$1" 2>/dev/null || head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n'; }
|
rand() { local bytes="${2:-32}"; openssl rand -hex "$bytes" 2>/dev/null || head -c "$bytes" /dev/urandom | od -An -tx1 | tr -d ' \n'; }
|
||||||
|
|
||||||
SECRET_KEY="$(rand hex 48)"
|
SECRET_KEY="$(rand hex 48)"
|
||||||
POSTGRES_PASSWORD="$(rand hex 16)"
|
POSTGRES_PASSWORD="$(rand hex 16)"
|
||||||
MINIO_ROOT_PASSWORD="$(rand hex 16)"
|
MINIO_ROOT_PASSWORD="$(rand hex 16)"
|
||||||
ADMIN_PASSWORD="$(rand hex 12)"
|
ADMIN_PASSWORD="$(rand hex 12)"
|
||||||
|
|
||||||
|
# بدون این بررسی، شکست تولید تصادفی به .env خالی میرسد و بعداً با خطای مبهم
|
||||||
|
# ${VAR:?} در compose ظاهر میشود.
|
||||||
|
for v in SECRET_KEY POSTGRES_PASSWORD MINIO_ROOT_PASSWORD ADMIN_PASSWORD; do
|
||||||
|
[ -n "${!v}" ] || { echo "خطا: تولید مقدار تصادفی برای $v ناموفق بود" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
cat > .env <<EOF
|
cat > .env <<EOF
|
||||||
# تولیدشده با scripts/gen-env.sh — این فایل هرگز به گیت نمیرود.
|
# تولیدشده با scripts/gen-env.sh — این فایل هرگز به گیت نمیرود.
|
||||||
# پیش از انتشار واقعی، ADMIN_PASSWORD را به مقداری که به یاد میسپارید اما قوی است تغییر دهید:
|
# پیش از انتشار واقعی، ADMIN_PASSWORD را به مقداری که به یاد میسپارید اما قوی است تغییر دهید:
|
||||||
|
|||||||
+14
-5
@@ -21,25 +21,31 @@ cd "$(dirname "$0")/.."
|
|||||||
ENV_FILE=.env
|
ENV_FILE=.env
|
||||||
[ -f "$ENV_FILE" ] || { echo "فایل .env پیدا نشد — اول bash scripts/gen-env.sh" >&2; exit 1; }
|
[ -f "$ENV_FILE" ] || { echo "فایل .env پیدا نشد — اول bash scripts/gen-env.sh" >&2; exit 1; }
|
||||||
|
|
||||||
# مقدار فعلی یک کلید در .env (یا خالی)
|
# مقدار فعلی یک کلید در .env (یا خالی) — کوتیشن تکنویسهای هم برداشته میشود
|
||||||
env_get() {
|
env_get() {
|
||||||
local key="$1"
|
local key="$1"
|
||||||
sed -n "s/^${key}=//p" "$ENV_FILE" | tail -1
|
sed -n "s/^${key}=//p" "$ENV_FILE" | tail -1 | sed -e "s/^'//" -e "s/'\$//"
|
||||||
}
|
}
|
||||||
|
|
||||||
# تنظیم/جایگزینی یک کلید در .env (بدون آسیب به بقیه خطوط)
|
# تنظیم/جایگزینی یک کلید در .env (بدون آسیب به بقیه خطوط)
|
||||||
|
# نکتهٔ مهم: docker compose مقادیر .env را خودش تفسیر میکند؛ هش htpasswd شامل
|
||||||
|
# $apr1$... است و اگر کوتیشن نخورد، $apr1 و بخشهای بعدی بهعنوان متغیر خالی
|
||||||
|
# جایگزین میشوند و Basic Auth هرگز رمز را نمیپذیرد. پس هر مقدار دارای $ را
|
||||||
|
# تککوتیشن میگذاریم (compose کوتیشن را برمیدارد و مقدار را عیناً میخواند).
|
||||||
env_set() {
|
env_set() {
|
||||||
local key="$1" value="$2"
|
local key="$1" value="$2"
|
||||||
if grep -q "^${key}=" "$ENV_FILE"; then
|
if grep -q "^${key}=" "$ENV_FILE"; then
|
||||||
# جداکننده | تا کاراکترهای ویژه مقدار (مثل / و $) مشکلی نسازند
|
python3 - "$ENV_FILE" "$key" "$value" <<'PY'
|
||||||
python - "$ENV_FILE" "$key" "$value" <<'PY'
|
|
||||||
import io, sys
|
import io, sys
|
||||||
path, key, value = sys.argv[1], sys.argv[2], sys.argv[3]
|
path, key, value = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
if "$" in value:
|
||||||
|
value = "'" + value + "'"
|
||||||
lines = io.open(path, encoding="utf-8").read().split("\n")
|
lines = io.open(path, encoding="utf-8").read().split("\n")
|
||||||
out = [f"{key}={value}" if line.startswith(f"{key}=") else line for line in lines]
|
out = [f"{key}={value}" if line.startswith(f"{key}=") else line for line in lines]
|
||||||
io.open(path, "w", encoding="utf-8", newline="\n").write("\n".join(out))
|
io.open(path, "w", encoding="utf-8", newline="\n").write("\n".join(out))
|
||||||
PY
|
PY
|
||||||
else
|
else
|
||||||
|
if [ "${value#*\$}" != "$value" ]; then value="'$value'"; fi
|
||||||
printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
|
printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,7 @@ make_htpasswd() {
|
|||||||
|
|
||||||
# رمز تصادفی خوانا (بدون نویسههای گیجکننده)
|
# رمز تصادفی خوانا (بدون نویسههای گیجکننده)
|
||||||
random_password() {
|
random_password() {
|
||||||
python - <<'PY'
|
python3 - <<'PY'
|
||||||
import secrets, string
|
import secrets, string
|
||||||
alphabet = string.ascii_letters + string.digits
|
alphabet = string.ascii_letters + string.digits
|
||||||
print("".join(secrets.choice(alphabet) for _ in range(16)))
|
print("".join(secrets.choice(alphabet) for _ in range(16)))
|
||||||
@@ -81,6 +87,8 @@ case "${1:-status}" in
|
|||||||
fi
|
fi
|
||||||
env_set PREVIEW_MODE on
|
env_set PREVIEW_MODE on
|
||||||
env_set PREVIEW_MIDDLEWARES "$PREVIEW_MIDDLEWARES_VALUE"
|
env_set PREVIEW_MIDDLEWARES "$PREVIEW_MIDDLEWARES_VALUE"
|
||||||
|
# /api و /admin گیت Basic Auth نمیگیرند (پنل با ورود خودش کار کند)؛ فقط noindex
|
||||||
|
env_set PREVIEW_MIDDLEWARES_API "preview-noindex@docker"
|
||||||
# در پیشنمایش، دامنه دوم به اصلی ریدایرکت نمیشود تا هر دو قابل بررسی باشند
|
# در پیشنمایش، دامنه دوم به اصلی ریدایرکت نمیشود تا هر دو قابل بررسی باشند
|
||||||
env_set ALIAS_REDIRECT_MIDDLEWARES ""
|
env_set ALIAS_REDIRECT_MIDDLEWARES ""
|
||||||
echo "پیشنمایش خصوصی فعال شد — سایت آنلاین است، ورود با نام کاربری/رمز، بدون ایندکس."
|
echo "پیشنمایش خصوصی فعال شد — سایت آنلاین است، ورود با نام کاربری/رمز، بدون ایندکس."
|
||||||
@@ -90,6 +98,7 @@ case "${1:-status}" in
|
|||||||
off)
|
off)
|
||||||
env_set PREVIEW_MODE off
|
env_set PREVIEW_MODE off
|
||||||
env_set PREVIEW_MIDDLEWARES ""
|
env_set PREVIEW_MIDDLEWARES ""
|
||||||
|
env_set PREVIEW_MIDDLEWARES_API ""
|
||||||
# در انتشار، دامنه دوم به اصلی ریدایرکت میشود تا محتوای تکراری ایجاد نشود
|
# در انتشار، دامنه دوم به اصلی ریدایرکت میشود تا محتوای تکراری ایجاد نشود
|
||||||
env_set ALIAS_REDIRECT_MIDDLEWARES "to-primary@docker"
|
env_set ALIAS_REDIRECT_MIDDLEWARES "to-primary@docker"
|
||||||
echo "حالت انتشار عمومی فعال شد — گیت ورود حذف و ایندکس مجاز میشود."
|
echo "حالت انتشار عمومی فعال شد — گیت ورود حذف و ایندکس مجاز میشود."
|
||||||
|
|||||||
Reference in New Issue
Block a user