Compare commits
36
Commits
846682ac4a
...
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 .
|
||||
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 alembic.ini ./
|
||||
COPY migrations ./migrations
|
||||
|
||||
+239
-79
@@ -3,8 +3,13 @@
|
||||
import csv
|
||||
import ipaddress
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -17,9 +22,10 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
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 ..database import get_db
|
||||
from ..rate_limit import clear as clear_rate_limit
|
||||
@@ -113,7 +119,46 @@ from ..schemas import (
|
||||
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)
|
||||
@@ -127,7 +172,7 @@ def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
|
||||
detail="نام کاربری یا رمز عبور اشتباه است",
|
||||
)
|
||||
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")
|
||||
@@ -144,13 +189,21 @@ def change_password(
|
||||
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 (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
||||
حذف و ترتیببندی) روی روتر مدیریت."""
|
||||
حذف و ترتیببندی) روی روتر مدیریت.
|
||||
|
||||
creator_scoped=True (فقط مقالهها): نقش creator فقط رکوردهای خودش را میبیند،
|
||||
ساختش همیشه پیشنویس است و ویرایشش هرگز منتشر نمیشود."""
|
||||
|
||||
@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)
|
||||
if creator_scoped and admin.role == "creator":
|
||||
query = query.filter(model.author_id == admin.id)
|
||||
if hasattr(model, "position"):
|
||||
query = query.order_by(model.position, model.id)
|
||||
else:
|
||||
@@ -158,8 +211,13 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
return query.all()
|
||||
|
||||
@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())
|
||||
if creator_scoped:
|
||||
# نویسنده ثبت میشود؛ ساخت توسط creator همیشه پیشنویس است
|
||||
item.author_id = admin.id
|
||||
if admin.role == "creator":
|
||||
item.status = "draft"
|
||||
if hasattr(model, "position"):
|
||||
# آیتم جدید در انتهای لیست قرار میگیرد
|
||||
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
|
||||
|
||||
@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)
|
||||
if item is None:
|
||||
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
|
||||
|
||||
@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)
|
||||
if item is None:
|
||||
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():
|
||||
setattr(item, key, value)
|
||||
if creator_scoped and admin.role == "creator":
|
||||
item.status = "draft" # نویسنده حق انتشار ندارد
|
||||
try:
|
||||
db.commit()
|
||||
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:
|
||||
|
||||
@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)
|
||||
if item is None:
|
||||
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():
|
||||
setattr(item, key, value)
|
||||
if creator_scoped and admin.role == "creator":
|
||||
item.status = "draft" # نویسنده حق انتشار ندارد
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
@@ -213,7 +282,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
return item
|
||||
|
||||
@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های ارسالی."""
|
||||
if not hasattr(model, "position"):
|
||||
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()
|
||||
|
||||
@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)
|
||||
if item is None:
|
||||
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("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
|
||||
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("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
|
||||
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)
|
||||
|
||||
|
||||
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمیگرداند تا بکاند ذخیرهسازی قابل تعویض باشد
|
||||
ALLOWED_IMAGE_TYPES = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"image/avif": "avif",
|
||||
}
|
||||
# آپلود رسانه به Object Storage (MinIO) — هر ورودی قابلرمزگشایی به WebP نرمال میشود:
|
||||
# تصویر ثابت → WebP تکفریمی | gif/apng/webp متحرک → WebP متحرک | ویدیو → WebP متحرک با سقف.
|
||||
# URL نسبی برمیگردد تا بکاند ذخیرهسازی قابل تعویض باشد.
|
||||
MAX_DIMENSION = 2000 # سقف ضلع تصویر ثابت — ابعاد بزرگ اصلیترین عامل کندی سایت است
|
||||
MAX_ANIM_DIMENSION = 800 # انیمیشن: سقف ضلع فریم
|
||||
MAX_ANIM_FRAMES = 150 # انیمیشن: سقف فریم
|
||||
VIDEO_MAX_SECONDS = 8 # ویدیو: سقف مدت ورودی
|
||||
VIDEO_FPS = 12 # ویدیو: نرخ فریم خروجی
|
||||
VIDEO_WEBP_LIMIT = 5 * 1024 * 1024 # انیمیشن ویدیویی حجیمتر از این → فقط فریم پوستر
|
||||
WEBP_QUALITY = 82
|
||||
WEBP_CONTENT_TYPE = "image/webp"
|
||||
|
||||
# بهینهسازی در زمان آپلود — ابعاد بزرگ و فشردهسازی ضعیف، اصلیترین عامل کندی سایت است
|
||||
MAX_DIMENSION = 2000
|
||||
REENCODE_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
SUPPORTED_MEDIA_DETAIL = "فرمت پشتیبانی نمیشود — فقط تصویر یا ویدیویی که بتوان به WebP تبدیل کرد مجاز است"
|
||||
|
||||
|
||||
def _optimize_image(data: bytes, content_type: str) -> tuple[bytes, str, int, int]:
|
||||
"""تصویر را به حداکثر ۲۰۰۰ پیکسل محدود و دوباره فشرده میکند؛
|
||||
اگر خروجی بزرگتر از اصلی شد، همان اصلی برگردد. (gif/avif دستنخورده)"""
|
||||
img = Image.open(io.BytesIO(data))
|
||||
def _to_webp_mode(img: Image.Image) -> Image.Image:
|
||||
"""ذخیرهٔ WebP در Pillow فقط RGB/RGBA/L را میپذیرد — مدهای دیگر با حفظ شفافیت تبدیل میشوند."""
|
||||
if img.mode in ("RGB", "RGBA", "L"):
|
||||
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)
|
||||
orig_w, orig_h = img.size
|
||||
if max(img.size) > MAX_DIMENSION:
|
||||
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
|
||||
img = _to_webp_mode(img)
|
||||
buf = io.BytesIO()
|
||||
if content_type == "image/jpeg":
|
||||
if img.mode not in ("RGB", "L"):
|
||||
img = img.convert("RGB")
|
||||
img.save(buf, "JPEG", quality=82, optimize=True, progressive=True)
|
||||
elif content_type == "image/png":
|
||||
img.save(buf, "PNG", optimize=True)
|
||||
else:
|
||||
img.save(buf, "WEBP", quality=82, method=4)
|
||||
optimized = buf.getvalue()
|
||||
if len(optimized) < len(data):
|
||||
return optimized, content_type, *img.size
|
||||
return data, content_type, orig_w, orig_h
|
||||
img.save(buf, "WEBP", quality=WEBP_QUALITY, method=4)
|
||||
return buf.getvalue(), *img.size
|
||||
|
||||
|
||||
def _webp_animated(img: Image.Image) -> tuple[bytes, int, int] | None:
|
||||
"""چندفریم (gif/apng/webp متحرک) → WebP متحرک؛ None یعنی ورودی تکفریمی است.
|
||||
فریمهای بیش از سقف رها میشوند تا حافظه و حجم خروجی مهار شود."""
|
||||
total = getattr(img, "n_frames", 1)
|
||||
if total <= 1:
|
||||
return None
|
||||
count = min(total, MAX_ANIM_FRAMES)
|
||||
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:
|
||||
@@ -313,10 +498,9 @@ def _readable_key(name: str, ext: str) -> str:
|
||||
return f"{datetime.now(timezone.utc):%Y/%m}/{base}.{ext}"
|
||||
|
||||
|
||||
def _store_image(data: bytes, content_type: str, name: str) -> str:
|
||||
"""ذخیره تصویر در Object Storage با کلید خوانا — URL نسبی برمیگردد."""
|
||||
ext = ALLOWED_IMAGE_TYPES[content_type]
|
||||
key = _readable_key(name, ext)
|
||||
def _store_image(data: bytes, name: str) -> str:
|
||||
"""ذخیرهٔ WebP در Object Storage با کلید خوانا — URL نسبی برمیگردد."""
|
||||
key = _readable_key(name, "webp")
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
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",
|
||||
)
|
||||
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:
|
||||
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
||||
return f"{settings.media_public_base}/{key}"
|
||||
@@ -337,14 +521,6 @@ async def upload_media(
|
||||
name: str = Form(""),
|
||||
_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
|
||||
data = await file.read()
|
||||
if len(data) == 0:
|
||||
@@ -355,23 +531,19 @@ async def upload_media(
|
||||
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||
)
|
||||
|
||||
width = height = None
|
||||
if content_type in REENCODE_TYPES:
|
||||
try:
|
||||
data, content_type, width, height = _optimize_image(data, content_type)
|
||||
except Exception:
|
||||
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره میشود
|
||||
pass
|
||||
# هر ورودی قابلرمزگشایی (تصویر رایج/خاص، متحرک، ویدیو) → WebP نرمال — روی ترد،
|
||||
# تا رمزگذاری فایل حجیم رویدادلوپ و بقیهٔ درخواستها را قفل نکند
|
||||
data, width, height = await run_in_threadpool(_normalize_media, data)
|
||||
|
||||
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
|
||||
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 {
|
||||
"url": url,
|
||||
"key": url.removeprefix(f"{settings.media_public_base}/"),
|
||||
"size": len(data),
|
||||
"content_type": content_type,
|
||||
"content_type": WEBP_CONTENT_TYPE,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
@@ -414,15 +586,7 @@ async def import_image(
|
||||
)
|
||||
try:
|
||||
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)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(status_code=502, detail="دانلود تصویر از نشانی ناموفق بود")
|
||||
if len(data) == 0:
|
||||
@@ -433,22 +597,18 @@ async def import_image(
|
||||
detail=f"حجم تصویر بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||
)
|
||||
|
||||
width = height = None
|
||||
if content_type in REENCODE_TYPES:
|
||||
try:
|
||||
data, content_type, width, height = _optimize_image(data, content_type)
|
||||
except Exception:
|
||||
pass
|
||||
# هدر Content-Type دروغ میگوید — خود بدنه رمزگشایی و به WebP نرمال میشود (روی ترد)
|
||||
data, width, height = await run_in_threadpool(_normalize_media, data)
|
||||
|
||||
# نام خوانا از مسیر نشانی — بدون پارامترهای کوئری
|
||||
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 {
|
||||
"url": media_url,
|
||||
"key": media_url.removeprefix(f"{settings.media_public_base}/"),
|
||||
"size": len(data),
|
||||
"content_type": content_type,
|
||||
"content_type": WEBP_CONTENT_TYPE,
|
||||
"width": width,
|
||||
"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"))
|
||||
|
||||
|
||||
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)
|
||||
payload = {"sub": username, "exp": expire}
|
||||
payload = {"sub": username, "role": role, "exp": expire}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AdminUser:
|
||||
"""وابستگی محافظتشده — فقط با توکن JWT معتبر دسترسی میدهد."""
|
||||
def get_user_from_token(token: str, db: Session) -> AdminUser:
|
||||
"""decode توکن و برگرداندن کاربر — هم برای get_current_admin و هم نقشگارد"""
|
||||
credentials_error = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="احراز هویت نامعتبر است",
|
||||
@@ -50,3 +47,11 @@ def get_current_admin(
|
||||
if user is None:
|
||||
raise credentials_error
|
||||
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"
|
||||
# پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی میکند
|
||||
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
|
||||
site_url: str = ""
|
||||
|
||||
@@ -27,8 +27,16 @@ EN_COLUMNS: list[tuple[str, str, str]] = [
|
||||
("projects", "year_en", "VARCHAR(64)"),
|
||||
("projects", "area_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", "project_type", "VARCHAR(128)"),
|
||||
("projects", "project_type_en", "VARCHAR(128)"),
|
||||
("projects", "usage", "VARCHAR(128)"),
|
||||
("projects", "usage_en", "VARCHAR(128)"),
|
||||
("projects", "summary_en", "TEXT"),
|
||||
("projects", "body_en", "JSON"),
|
||||
("articles", "title_en", "VARCHAR(256)"),
|
||||
@@ -145,6 +153,14 @@ def run_ddl_migrations() -> None:
|
||||
conn.execute(
|
||||
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(
|
||||
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)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
# 'admin' همهکاره — 'creator' فقط نویسندهٔ محتوا (پیشنویس مقالههای خودش)
|
||||
role: Mapped[str] = mapped_column(String(16), default='admin')
|
||||
|
||||
|
||||
class Brand(Base):
|
||||
@@ -72,10 +74,18 @@ class Project(Base):
|
||||
area_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
floors: Mapped[str] = mapped_column(String(64), default="")
|
||||
floors_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
units: Mapped[str] = mapped_column(String(64), default="")
|
||||
units_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
land_area: 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_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_en: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||
@@ -108,6 +118,7 @@ class Article(Base):
|
||||
# پرسش و پاسخهای پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
|
||||
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
||||
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
||||
author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقالههای ادمین NULL
|
||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||
|
||||
+20
-4
@@ -57,10 +57,17 @@ class ProjectOut(ORMModel):
|
||||
area_en: str = ""
|
||||
floors: str
|
||||
floors_en: str = ""
|
||||
units: str
|
||||
units_en: str = ""
|
||||
land_area: str = ""
|
||||
pilot_floors: str = ""
|
||||
units_residential: str = ""
|
||||
units_commercial: str = ""
|
||||
units_office: str = ""
|
||||
client: str
|
||||
client_en: str = ""
|
||||
project_type: str = ""
|
||||
project_type_en: str = ""
|
||||
usage: str = ""
|
||||
usage_en: str = ""
|
||||
summary: str
|
||||
summary_en: str = ""
|
||||
body: list[str]
|
||||
@@ -70,6 +77,7 @@ class ProjectOut(ORMModel):
|
||||
class ArticleOut(ORMModel):
|
||||
id: int
|
||||
slug: str
|
||||
author_id: int | None = None
|
||||
title: str
|
||||
title_en: str = ""
|
||||
date: str
|
||||
@@ -414,6 +422,7 @@ class LoginIn(BaseModel):
|
||||
class TokenOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
role: str = "admin"
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
@@ -451,14 +460,21 @@ class ProjectIn(BaseModel):
|
||||
year: str = ""
|
||||
year_en: str = ""
|
||||
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 = ""
|
||||
gallery: list[str] = []
|
||||
area: str = ""
|
||||
area_en: str = ""
|
||||
floors: str = ""
|
||||
floors_en: str = ""
|
||||
units: str = ""
|
||||
units_en: str = ""
|
||||
client: str = ""
|
||||
client_en: str = ""
|
||||
summary: str = ""
|
||||
|
||||
@@ -404,12 +404,103 @@ def test_upload_with_readable_name(auth_client, monkeypatch):
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
key = res.json()["key"]
|
||||
# نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + پسوند تصویر
|
||||
# نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + نرمالشدن به WebP
|
||||
assert "برج-الهیه-tower" in key
|
||||
assert key.endswith(".png")
|
||||
assert key.endswith(".webp")
|
||||
assert res.json()["content_type"] == "image/webp"
|
||||
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):
|
||||
_mock_storage(monkeypatch)
|
||||
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"})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["url"].startswith("/media/")
|
||||
assert res.json()["key"].endswith(".webp")
|
||||
assert "Elahieh-pic" in res.json()["key"]
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ WORKDIR /srv
|
||||
# همهٔ وابستگیهای سنگین (numpy/scikit-learn/torch/psycopg) ویل prebuilt دارند —
|
||||
# بدون کامپایلر؛ دسترسی apt در شبکهٔ بیلد قابلاعتماد نیست
|
||||
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
|
||||
|
||||
COPY . /srv/chatbot/
|
||||
|
||||
+5
-5
@@ -115,7 +115,7 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
||||
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(
|
||||
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||
"service", _clean(row["title"]), "/services",
|
||||
@@ -166,7 +166,7 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
||||
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 []))
|
||||
d = _doc(
|
||||
"\n".join(filter(None, [
|
||||
@@ -179,7 +179,7 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
||||
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(
|
||||
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||
"process", _clean(row["title"]), "/investment",
|
||||
@@ -188,7 +188,7 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
||||
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")
|
||||
if d:
|
||||
docs.append(d)
|
||||
@@ -209,7 +209,7 @@ def fetch_site_documents(database_url: str) -> list[dict]:
|
||||
if 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(
|
||||
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||
"timeline", _clean(row["title"]), "/about",
|
||||
|
||||
@@ -77,6 +77,7 @@ def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
||||
خروجی: آمار عملیات برای لاگ.
|
||||
"""
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .embeddings import get_embeddings
|
||||
@@ -84,7 +85,10 @@ def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
||||
model = settings.embedding_model
|
||||
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] = {}
|
||||
if not force:
|
||||
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]:
|
||||
"""همه قطعات ذخیرهشده (برای ساخت BM25 در حافظه)."""
|
||||
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(
|
||||
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
|
||||
).fetchall()
|
||||
|
||||
+14
-6
@@ -7,15 +7,23 @@
|
||||
root * /srv
|
||||
encode zstd gzip
|
||||
|
||||
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمیگردند
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
|
||||
# فایلهای هشدار بیلد: کش بلندمدت بیرحم
|
||||
# فایلهای هشدار بیلد: کش بلندمدت بیرحم — ولی هرگز fallback به index.html نداشته باشند؛
|
||||
# پاسخ 200ٔ HTML برای یک مسیر /assets/* نبوده (مثلاً هنگام پنجرهٔ تعویض کانتینر در استقرار)
|
||||
# توسط CDN (WCDN، سیاست SMART) با همین هدر immutable یک سال کش میشود و اسکریپت ماژول
|
||||
# را برای همه بازدیدکنندهها میشکند. فایل ناموجود در assets باید 404 بدهد، نه HTML.
|
||||
@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 اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
|
||||
@notassets not path /assets/*
|
||||
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 { AdminLoginPage } from './admin/AdminLoginPage';
|
||||
import { ArticleWritePage } from './admin/ArticleWritePage';
|
||||
import { ProjectWritePage } from './admin/ProjectWritePage';
|
||||
import { LangProvider, useLang } from './i18n/lang';
|
||||
|
||||
// مسیرهای عمومی یک بار تعریف میشوند و دو بار سرو میشوند: ریشه فارسی و /en انگلیسی
|
||||
@@ -69,6 +70,8 @@ function AnimatedRoutes() {
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
<Route path="/admin/write" 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}
|
||||
<Route path="*" element={<Home />} />
|
||||
{EN_ROUTES}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { KeyRoundIcon, XIcon } from 'lucide-react'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
idle: 'همگامسازی شد',
|
||||
@@ -126,9 +128,18 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||
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)
|
||||
|
||||
// زبان ویرایش از زبان صفحهٔ در حال نمایش پیروی میکند — ویرایش روی /en به ستونهای
|
||||
// انگلیسی میرود و روی صفحات فارسی به فارسی؛ وگرنه ویرایشِ دیدهشده در /en ناخواسته
|
||||
// متن فارسی را بازنویسی میکرد. کلید دستی پایین فقط برای ویرایش ستون زبان دیگر
|
||||
// روی همین صفحه است و با ناوبری بعدی دوباره همگام میشود.
|
||||
useEffect(() => {
|
||||
if (isEditMode) syncEditLang(lang)
|
||||
}, [isEditMode, lang, syncEditLang])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -140,6 +151,7 @@ export function AdminBar() {
|
||||
حالت مدیریت
|
||||
</span>
|
||||
|
||||
{canManageSite && (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
@@ -160,11 +172,12 @@ export function AdminBar() {
|
||||
</span>
|
||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
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 ${
|
||||
editLang === 'en'
|
||||
? 'border-copper bg-copper/10 text-copper'
|
||||
@@ -188,6 +201,16 @@ export function AdminBar() {
|
||||
رمز
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
|
||||
@@ -209,6 +232,8 @@ export function AdminBar() {
|
||||
>
|
||||
خبرنامه (CSV)
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,17 +4,23 @@ import Swal from 'sweetalert2'
|
||||
import type { SiteData } from '../api'
|
||||
import { useSiteData } from '../api'
|
||||
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 EditLang = 'fa' | 'en'
|
||||
|
||||
export type AdminRole = 'admin' | 'creator'
|
||||
|
||||
interface AdminContextValue {
|
||||
isAuthenticated: boolean
|
||||
/** نقش کاربر واردشده — 'creator' فقط نویسندهٔ محتواست */
|
||||
role: AdminRole | null
|
||||
/** فقط نقش admin به کل بخشهای سایت دسترسی دارد */
|
||||
canManageSite: boolean
|
||||
isEditMode: boolean
|
||||
toggleEditMode: () => void
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
login: (username: string, password: string) => Promise<AdminRole>
|
||||
logout: () => void
|
||||
saveStatus: SaveStatus
|
||||
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||
@@ -22,6 +28,9 @@ interface AdminContextValue {
|
||||
/** زبان محتوایی که در حالت ویرایش ذخیره میشود (فارسی یا انگلیسی) */
|
||||
editLang: EditLang
|
||||
toggleEditLang: () => void
|
||||
/** همگامسازی زبان ویرایش با زبان صفحهٔ در حال نمایش — کلید دستی فقط برای
|
||||
* ویرایش ستون زبان دیگر روی همان صفحه است؛ در ناوبری دوباره از صفحه پیروی میکند */
|
||||
syncEditLang: (lang: EditLang) => void
|
||||
}
|
||||
|
||||
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 }) {
|
||||
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>(
|
||||
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
|
||||
)
|
||||
const [editLang, setEditLang] = useState<EditLang>(
|
||||
const [editLang, setEditLangState] = useState<EditLang>(
|
||||
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
|
||||
)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
@@ -74,24 +97,39 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
const toggleEditLang = useCallback(() => {
|
||||
setEditLang((v) => {
|
||||
setEditLangState((v) => {
|
||||
const next: EditLang = v === 'fa' ? 'en' : 'fa'
|
||||
sessionStorage.setItem('aramesh_edit_lang', 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)
|
||||
const nextRole = roleFromToken(getToken()) ?? 'admin'
|
||||
sessionStorage.setItem('aramesh_role', nextRole)
|
||||
setRole(nextRole)
|
||||
setIsAuthenticated(true)
|
||||
// داده سایت دوباره بارگذاری میشود تا پیشنویس مقالهها هم ادغام شوند
|
||||
reload()
|
||||
return nextRole
|
||||
}, [reload])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setToken(null)
|
||||
setIsAuthenticated(false)
|
||||
setIsEditMode(false)
|
||||
setRole(null)
|
||||
sessionStorage.removeItem('aramesh_role')
|
||||
sessionStorage.removeItem('aramesh_edit_mode')
|
||||
// داده سایت دوباره بارگذاری میشود تا پیشنویسها از درخت داده حذف شوند
|
||||
reload()
|
||||
@@ -132,7 +170,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
// توکن بیرون از این provider هم پاک میشود (پاسخ 401 بارگذاری سایت، خروج در زبانه
|
||||
// دیگر) — وضعیت ورود همگام میماند و نوار ادمین برای نشست مرده باقی نمیماند
|
||||
useEffect(() => {
|
||||
const syncAuth = () => setIsAuthenticated(!!getToken())
|
||||
const syncAuth = () => {
|
||||
setIsAuthenticated(!!getToken())
|
||||
setRole(roleFromToken(getToken()))
|
||||
}
|
||||
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||
window.addEventListener('storage', syncAuth)
|
||||
return () => {
|
||||
@@ -151,9 +192,12 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener('beforeunload', guard)
|
||||
}, [saveStatus])
|
||||
|
||||
const canManageSite = role === 'admin'
|
||||
const value = useMemo<AdminContextValue>(
|
||||
() => ({
|
||||
isAuthenticated,
|
||||
role,
|
||||
canManageSite,
|
||||
isEditMode,
|
||||
toggleEditMode,
|
||||
login,
|
||||
@@ -162,8 +206,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
runSave,
|
||||
editLang,
|
||||
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>
|
||||
@@ -201,7 +246,7 @@ export function useFieldSaver() {
|
||||
}
|
||||
await adminApi.patch(resource, id, changes)
|
||||
updateSite((d) => {
|
||||
const config = ENTITIES[resource]
|
||||
const config = entityConfig(resource)
|
||||
let node: Record<string, unknown> | undefined
|
||||
if (config) {
|
||||
const target = d[config.dataKey as keyof SiteData] as unknown
|
||||
|
||||
@@ -20,8 +20,8 @@ export function AdminLoginPage() {
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(username.trim(), password)
|
||||
navigate('/')
|
||||
const nextRole = await login(username.trim(), password)
|
||||
navigate(nextRole === 'creator' ? '/admin/write' : '/')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
|
||||
} finally {
|
||||
|
||||
@@ -134,7 +134,7 @@ const STATUS_TEXT: Record<string, string> = {
|
||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||
*/
|
||||
export function ArticleWritePage() {
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang, role } = useAdmin()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { data, updateSite } = useSiteData()
|
||||
@@ -396,6 +396,7 @@ export function ArticleWritePage() {
|
||||
</span>
|
||||
{createdId && (
|
||||
<>
|
||||
{role === 'admin' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
|
||||
@@ -407,6 +408,7 @@ export function ArticleWritePage() {
|
||||
>
|
||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
||||
data-admin-link
|
||||
@@ -415,6 +417,7 @@ export function ArticleWritePage() {
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
دیدن در سایت
|
||||
</Link>
|
||||
{role === 'admin' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
@@ -424,6 +427,7 @@ export function ArticleWritePage() {
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Swal from 'sweetalert2'
|
||||
@@ -7,14 +8,14 @@ import { useSiteData } from '../api'
|
||||
import type { SiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { ENTITIES } from './entities'
|
||||
import { entityConfig } from './entities'
|
||||
import { EntityDrawer } from './EntityDrawer'
|
||||
|
||||
type ItemWithId = Record<string, unknown> & { id: number }
|
||||
|
||||
function useEntityList(resource: string): ItemWithId[] {
|
||||
const { data } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
const config = entityConfig(resource)
|
||||
if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
|
||||
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
|
||||
}
|
||||
@@ -69,14 +70,14 @@ export function EditableItem({
|
||||
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت میکند (مثل صفحه نوشتن مقاله) */
|
||||
editHref?: string
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
const config = entityConfig(resource)
|
||||
const list = useEntityList(resource)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
if (!config) return <>{children}</>
|
||||
if (!isAuthenticated || !isEditMode) return <>{children}</>
|
||||
if (!isAuthenticated || !isEditMode || !canManageSite) return <>{children}</>
|
||||
|
||||
const index = list.findIndex((x) => x.id === item.id)
|
||||
|
||||
@@ -163,17 +164,21 @@ export function AddItemButton({
|
||||
resource,
|
||||
label,
|
||||
variant = 'cell',
|
||||
/** در صورت ارسال، پس از ساختهشدن رکورد به صفحهٔ ویرایش اختصاصی هدایت میشود (کشو باز نمیشود) */
|
||||
editHrefBase,
|
||||
}: {
|
||||
resource: string
|
||||
label?: string
|
||||
variant?: 'cell' | 'button'
|
||||
editHrefBase?: string
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
|
||||
const navigate = useNavigate()
|
||||
const { updateSite } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
const config = entityConfig(resource)
|
||||
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
|
||||
|
||||
if (!config || !isAuthenticated || !isEditMode) return null
|
||||
if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
|
||||
|
||||
const create = () => {
|
||||
void runSave(async () => {
|
||||
@@ -183,6 +188,10 @@ export function AddItemButton({
|
||||
const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[]
|
||||
if (Array.isArray(list)) list.push(created)
|
||||
})
|
||||
if (editHrefBase) {
|
||||
navigate(`${editHrefBase}/${created.id}`)
|
||||
return
|
||||
}
|
||||
setNewItem(created)
|
||||
})
|
||||
}
|
||||
@@ -240,13 +249,13 @@ export function SingletonPill({
|
||||
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
|
||||
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const config = ENTITIES[resource]
|
||||
const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
|
||||
const config = entityConfig(resource)
|
||||
const { data, updateSite } = useSiteData()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [created, setCreated] = useState<ItemWithId | null>(null)
|
||||
|
||||
if (!config || !isAuthenticated || !isEditMode) return null
|
||||
if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
|
||||
const item =
|
||||
created ??
|
||||
(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'
|
||||
|
||||
/** سقف آپلود — هماندازهٔ سقف کاربر در بکاند (media_max_upload_mb = ۳۵ با احتساب سربار multipart) */
|
||||
const MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
||||
|
||||
/** رویداد تغییر وضعیت ورود — لایههای دیگر (مثل نوار ادمین) با آن همگام میمانند */
|
||||
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
|
||||
|
||||
@@ -43,9 +46,11 @@ async function authFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
||||
const detail =
|
||||
typeof data?.detail === 'string'
|
||||
? data.detail
|
||||
: res.status === 409
|
||||
? 'رکوردی با این مقدار تکراری وجود دارد'
|
||||
: 'خطا در ذخیرهسازی — دوباره تلاش کنید'
|
||||
: res.status === 413
|
||||
? 'حجم فایل بیش از حد مجاز است'
|
||||
: res.status === 409
|
||||
? 'رکوردی با این مقدار تکراری وجود دارد'
|
||||
: 'خطا در ذخیرهسازی — دوباره تلاش کنید'
|
||||
throw new HttpError(res.status, detail)
|
||||
}
|
||||
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 }) }),
|
||||
|
||||
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()
|
||||
fd.append('file', file)
|
||||
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('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', 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'),
|
||||
],
|
||||
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)],
|
||||
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: {
|
||||
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||
titleKey: 'page_key',
|
||||
@@ -192,9 +216,20 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
hero: {
|
||||
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
||||
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: {},
|
||||
},
|
||||
'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: {
|
||||
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||
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 {
|
||||
if (name.endsWith('_en')) return false
|
||||
const config = ENTITIES[resource]
|
||||
const config = entityConfig(resource)
|
||||
return !!config?.fields.some((f) => f.name === name && !f.shared)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,65 @@
|
||||
// لایهٔ نازک تحلیل رفتار (Umami) — اگر اسکریپت ردیاب بارگیری نشده باشد
|
||||
// (VITE_UMAMI_SRC خالی)، همهٔ فراخوانیها بیاثر و ارزاناند.
|
||||
// لایهٔ نازک تحلیل رفتار — OpenPanel.
|
||||
//
|
||||
// نمونهٔ OpenPanel از قبل روی این سرور بالا است (stats.bennu-api-bahrestan.ir) و
|
||||
// پروژهٔ ERP هم از همان استفاده میکند؛ بهجای اجرای یک ابزار تحلیل دوم (Umami)،
|
||||
// همین یک نمونه با یک «پروژهٔ» دوم برای این سایت به کار میرود.
|
||||
//
|
||||
// اگر VITE_OPENPANEL_URL و VITE_OPENPANEL_CLIENT_ID در زمان بیلد تنظیم نشده باشند،
|
||||
// همهٔ فراخوانیها بیاثر و ارزاناند (مثل حالت فعلی Umami).
|
||||
//
|
||||
// مسیر Umami دستنخورده مانده تا اگر اسکریپت قدیمی جایی فعال بود از کار نیفتد.
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
op?: (...args: unknown[]) => void
|
||||
umami?: { track: (event?: string, data?: Record<string, unknown>) => void }
|
||||
}
|
||||
}
|
||||
|
||||
/** رخداد سفارشی — نام رخداد در داشبورد Umami نمایش داده میشود. */
|
||||
export function track(event: string, data?: Record<string, unknown>) {
|
||||
const OP_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim()
|
||||
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 {
|
||||
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 {
|
||||
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
|
||||
}
|
||||
}
|
||||
|
||||
/** رخداد سفارشی — در داشبورد 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)
|
||||
const controller = new AbortController()
|
||||
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 })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
@@ -256,25 +274,12 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
.then(async (json) => {
|
||||
if (cancelled) return
|
||||
// برای ادمین واردشده، پیشنویس مقالهها از انتهای مدیریتی خوانده و ادغام میشوند —
|
||||
// بکاند در GET /api/site فقط مقالههای منتشرشده را برمیگرداند
|
||||
if (getToken()) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
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 {
|
||||
/* بدون پیشنویسها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
|
||||
}
|
||||
// بکاند در GET /api/site فقط مقالههای منتشرشده را برمیگرداند —
|
||||
// پیشنویسها (برای ادمین) از درخواست موازی ادغام میشوند
|
||||
const drafts = await draftsPromise
|
||||
if (drafts) {
|
||||
const published = new Set(json.articles.map((a) => a.id))
|
||||
json.articles = [...json.articles, ...drafts.filter((a) => a.status === 'draft' && !published.has(a.id))]
|
||||
}
|
||||
if (!cancelled) {
|
||||
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 { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
|
||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from './Reveal'
|
||||
import { PremiumButton } from './PremiumButton'
|
||||
import { Button } from './Button'
|
||||
import { useSite } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { API_URL } from '../config'
|
||||
import { track } from '../analytics'
|
||||
|
||||
export function ContactSection() {
|
||||
const { brand, contact_section: content } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '', website: '' })
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||
const [sent, setSent] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
@@ -80,7 +79,7 @@ export function ContactSection() {
|
||||
const res = await fetch(`${API_URL}/api/contact`, {
|
||||
method: 'POST',
|
||||
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) {
|
||||
@@ -91,7 +90,6 @@ export function ContactSection() {
|
||||
return
|
||||
}
|
||||
|
||||
track('contact-submit')
|
||||
setSent(true)
|
||||
} catch {
|
||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||
@@ -154,7 +152,6 @@ export function ContactSection() {
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
||||
</p>
|
||||
<PremiumButton
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-8 font-light"
|
||||
onClick={() => {
|
||||
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)} />
|
||||
</PremiumButton>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
|
||||
@@ -265,17 +253,21 @@ export function ContactSection() {
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-start">
|
||||
<PremiumButton
|
||||
<Button
|
||||
type="submit"
|
||||
variant="copper"
|
||||
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? (
|
||||
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>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { LanguagesIcon } from 'lucide-react'
|
||||
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 انگلیسی همان صفحه جابهجا میشود */
|
||||
export function LangToggle({ className = '' }: { className?: string }) {
|
||||
@@ -22,9 +10,7 @@ export function LangToggle({ className = '' }: { className?: string }) {
|
||||
const target = counterpartPath(pathname, lang)
|
||||
|
||||
const switchLang = () => {
|
||||
const next = lang === 'fa' ? 'en' : 'fa'
|
||||
track('lang-switch', { to: next })
|
||||
writeLangCookie(next)
|
||||
writeLangCookie(lang === 'fa' ? 'en' : 'fa')
|
||||
// پرش خودکار بازدید بعدی با انتخاب دستی همینجا گره نمیخورد
|
||||
sessionStorage.setItem('langAutoRedirect', '1')
|
||||
navigate(target)
|
||||
@@ -36,61 +22,10 @@ export function LangToggle({ className = '' }: { className?: string }) {
|
||||
onClick={switchLang}
|
||||
aria-label={t('switchLangAria')}
|
||||
title={t('switchLangAria')}
|
||||
dir="ltr"
|
||||
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(' ')}
|
||||
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}`}
|
||||
>
|
||||
{/* ── Inner Track ───────────────────────────────────────────────
|
||||
این دیو داخلی باعث میشود پدینگ دکمه اصلی روی محاسبات 50 درصدی تاثیر نگذارد
|
||||
*/}
|
||||
<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>
|
||||
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
||||
<span>{t('switchLang')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,8 +74,15 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
||||
</motion.p>
|
||||
<div className="mt-8 h-px w-28 bg-copper" />
|
||||
|
||||
{/* ویرایش تصویر پسزمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
|
||||
{/* ویرایش هیروی صفحه (عنوان/سرآغاز/تصویر)، تصویر پسزمینه و سئو — فقط در حالت ویرایش ادمین */}
|
||||
<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
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی این صفحه"
|
||||
|
||||
@@ -2,7 +2,14 @@ import { Link } from 'react-router-dom'
|
||||
import { useStatusLabel } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
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'
|
||||
|
||||
type ProjectCardProps = {
|
||||
|
||||
@@ -110,7 +110,7 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
|
||||
</p>
|
||||
<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) => {
|
||||
const isActive = item.id === activeId
|
||||
return (
|
||||
@@ -120,7 +120,9 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
|
||||
onClick={(event) => go(event, item.id)}
|
||||
aria-current={isActive ? 'location' : undefined}
|
||||
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
|
||||
? 'font-normal text-copper'
|
||||
|
||||
@@ -311,12 +311,15 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
||||
setUploading(true)
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file, name)
|
||||
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
||||
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||
// «نام تصویر»ای که کاربر پیش از بارگذاری نوشته، همان Alt تصویر میشود؛
|
||||
// در نبود آن نام فایل و در نبود آن مقدار پیشفرض — تا نیازی به اصلاح دستی
|
||||
// در جعبهٔ زیر تصویر نباشد.
|
||||
const typedName = (name ?? '').trim()
|
||||
const fileAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
|
||||
.setImage({ src: url, alt: typedName || fileAlt || DEFAULT_IMAGE_ALT })
|
||||
.run()
|
||||
})
|
||||
setUploading(false)
|
||||
@@ -476,7 +479,7 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
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"
|
||||
onChange={(e) => {
|
||||
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
|
||||
ref={inputRef}
|
||||
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"
|
||||
onChange={(event) => {
|
||||
void pick(event.target.files?.[0])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { PremiumButton } from '../PremiumButton';
|
||||
import { ButtonLink } from '../Button';
|
||||
import { ArticleCard } from '../ArticleCard';
|
||||
import { useSectionHeading, useSite } from '../../api';
|
||||
import { useLang } from '../../i18n/lang';
|
||||
@@ -53,9 +53,10 @@ export function Articles() {
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<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)} />
|
||||
</PremiumButton>
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
@@ -18,22 +18,20 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center">
|
||||
<div className="lg:col-span-4">
|
||||
<SectionHeading
|
||||
title={t('fallbackFaqTitle')}
|
||||
lead={t('fallbackFaqLead')}
|
||||
headingKey="faq"
|
||||
align="center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accordion list — constrained width, direction-aware */}
|
||||
<div className="w-full max-w-3xl mx-auto">
|
||||
<div className={hideHeading ? "" : "lg:col-span-8"}>
|
||||
<ul>
|
||||
{faqs.map((faq, i) => {
|
||||
const isOpen = open === i
|
||||
@@ -45,9 +43,9 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
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}
|
||||
</span>
|
||||
<PlusIcon
|
||||
@@ -65,7 +63,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -10,10 +10,9 @@ import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import type { ProjectStatus } from '../../data/site'
|
||||
import { projectsData } from '../../data/projectsData'
|
||||
|
||||
export function FeaturedProjects() {
|
||||
const { status_labels } = useSite()
|
||||
const { status_labels, projects } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const heading = useSectionHeading('featured_projects')
|
||||
const save = useFieldSaver()
|
||||
@@ -34,8 +33,8 @@ export function FeaturedProjects() {
|
||||
|
||||
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/";
|
||||
|
||||
return (
|
||||
@@ -87,8 +86,7 @@ export function FeaturedProjects() {
|
||||
className="grid gap-6 lg:grid-cols-3"
|
||||
>
|
||||
{list.map((project, i) => {
|
||||
const dummyImageName = `${(i % 6) + 1}.png`;
|
||||
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
|
||||
const imageUrl = project.image || undefined;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
@@ -51,7 +51,7 @@ export function Hero() {
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
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"
|
||||
>
|
||||
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} />
|
||||
@@ -61,7 +61,7 @@ export function Hero() {
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
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]"
|
||||
>
|
||||
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} />
|
||||
@@ -71,7 +71,7 @@ export function Hero() {
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
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"
|
||||
>
|
||||
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
|
||||
@@ -81,23 +81,48 @@ export function Hero() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
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"
|
||||
>
|
||||
{/* دکمه اصلی */}
|
||||
<PremiumButton to={href(hero.cta_primary_href)}>
|
||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||
</PremiumButton>
|
||||
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
||||
<ButtonLink
|
||||
to={href(hero.cta_primary_href)}
|
||||
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>
|
||||
|
||||
{/* دکمه ثانویه */}
|
||||
<PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md">
|
||||
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
||||
<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)} />
|
||||
</PremiumButton>
|
||||
</ButtonLink>
|
||||
</motion.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>
|
||||
|
||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import React, { useState } from 'react'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
import { CheckIcon, SendIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { Button } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
import { API_URL } from '../../config'
|
||||
import { track } from '../../analytics'
|
||||
|
||||
export function Newsletter() {
|
||||
const { newsletter } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [email, setEmail] = useState('')
|
||||
const [website, setWebsite] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
@@ -77,7 +75,7 @@ export function Newsletter() {
|
||||
const res = await fetch(`${API_URL}/api/newsletter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailValue, website }),
|
||||
body: JSON.stringify({ email: emailValue }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -89,7 +87,6 @@ export function Newsletter() {
|
||||
return
|
||||
}
|
||||
|
||||
track('newsletter-signup')
|
||||
setStatus('done')
|
||||
setEmail('')
|
||||
} catch {
|
||||
@@ -101,7 +98,7 @@ export function Newsletter() {
|
||||
}
|
||||
|
||||
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" />
|
||||
|
||||
{/* هاله نور پسزمینه برای عمق بصری */}
|
||||
@@ -120,17 +117,6 @@ export function Newsletter() {
|
||||
|
||||
<Reveal delay={0.08}>
|
||||
<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">
|
||||
<label htmlFor="newsletter-email" className="sr-only">
|
||||
{newsletter.placeholder}
|
||||
@@ -156,18 +142,19 @@ export function Newsletter() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
variant="dark"
|
||||
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" />
|
||||
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
<span>
|
||||
{sending ? newsletter.sending_label : (
|
||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
@@ -89,9 +89,10 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
|
||||
{showLink && (
|
||||
<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)} />
|
||||
</PremiumButton>
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</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: 'این پروژه پیدا نشد',
|
||||
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژهها را ببینید.',
|
||||
backToProjects: 'بازگشت به پروژهها',
|
||||
specStatus: 'وضعیت',
|
||||
specLandArea: 'مساحت عرصه (زمین)',
|
||||
specPilotFloors: 'طبقات روی همکف (پیلوت)',
|
||||
specUnitsResidential: 'تعداد واحد مسکونی',
|
||||
specUnitsCommercial: 'تعداد واحد تجاری',
|
||||
specUnitsOffice: 'تعداد واحد اداری',
|
||||
specLocation: 'موقعیت',
|
||||
specYear: 'سال',
|
||||
specProjectType: 'نوع پروژه',
|
||||
specUsage: 'کاربری',
|
||||
specYear: 'سال ساخت',
|
||||
specArea: 'زیربنا',
|
||||
specFloors: 'تعداد طبقات',
|
||||
specUnits: 'واحدها',
|
||||
@@ -166,8 +174,16 @@ export const en: Dict = {
|
||||
projectNotFound: 'Project not found',
|
||||
projectNotFoundText: 'The address may have changed. Browse the full list of 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',
|
||||
specYear: 'Year',
|
||||
specProjectType: 'Project type',
|
||||
specUsage: 'Land use',
|
||||
specYear: 'Year built',
|
||||
specArea: 'Built area',
|
||||
specFloors: 'Floors',
|
||||
specUnits: 'Units',
|
||||
|
||||
+17
-8
@@ -150,20 +150,16 @@ html[lang='en'] .article-body h3 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* تیترها و فهرستها همتراز نمیشوند — فقط متن پاراگراف تراز میشود
|
||||
(تیتر کوتاه با justify کشیده و بدقلق میشود و فهرست نقطهگذاریاش بههم میریزد). */
|
||||
/* فقط تیترها همتراز نمیشوند (تیتر کوتاه با justify کشیده و بدقلق میشود) —
|
||||
پاراگرافها و فهرستها کاملاً justify هستند و کپشن همیشه وسطچین است. */
|
||||
[data-dir='rtl'] .article-body h1,
|
||||
[data-dir='rtl'] .article-body h2,
|
||||
[data-dir='rtl'] .article-body h3,
|
||||
[data-dir='rtl'] .article-body li,
|
||||
[data-dir='rtl'] .article-body figcaption {
|
||||
[data-dir='rtl'] .article-body h3 {
|
||||
text-align: right;
|
||||
}
|
||||
[data-dir='ltr'] .article-body h1,
|
||||
[data-dir='ltr'] .article-body h2,
|
||||
[data-dir='ltr'] .article-body h3,
|
||||
[data-dir='ltr'] .article-body li,
|
||||
[data-dir='ltr'] .article-body figcaption {
|
||||
[data-dir='ltr'] .article-body h3 {
|
||||
text-align: left;
|
||||
}
|
||||
[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);
|
||||
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> ---------- */
|
||||
.article-body figure {
|
||||
margin-top: 2rem;
|
||||
@@ -283,6 +288,10 @@ html[lang='en'] .article-body h3 {
|
||||
/* تراز از جهت بلوک پیروی میکند — در مقاله انگلیسی چپ و در فارسی راست */
|
||||
text-align: start;
|
||||
}
|
||||
/* متن کپشن در ویرایشگر هم وسطچین است تا با خروجی سایت یکی باشد */
|
||||
[data-wysiwyg] .article-figure__caption input[type='text'] {
|
||||
text-align: center;
|
||||
}
|
||||
[data-wysiwyg] .article-figure-node.is-selected .article-figure {
|
||||
outline: 1px dashed rgba(213, 135, 76, 0.8);
|
||||
outline-offset: 3px;
|
||||
|
||||
@@ -2,6 +2,9 @@ import "./index.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { initAnalytics } from "./analytics";
|
||||
|
||||
initAnalytics();
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (rootEl) {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React from 'react';
|
||||
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 { Reveal } from '../components/Reveal';
|
||||
import { PremiumButton } from '../components/PremiumButton';
|
||||
import { ButtonLink } from '../components/Button';
|
||||
import { ArticleCard } from '../components/ArticleCard';
|
||||
import { ArticleMeta } from '../components/ArticleMeta';
|
||||
import { ArticleFaq } from '../components/ArticleFaq';
|
||||
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
||||
import { Newsletter } from '../components/home/Newsletter';
|
||||
import { useSite } from '../api';
|
||||
@@ -48,35 +47,6 @@ export function ArticleDetail() {
|
||||
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
||||
useEffect(() => {
|
||||
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({
|
||||
title: `${article.title} | ${brand.name}`,
|
||||
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
||||
@@ -85,7 +55,17 @@ export function ArticleDetail() {
|
||||
image: article.image,
|
||||
type: 'article',
|
||||
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]);
|
||||
|
||||
@@ -94,9 +74,10 @@ export function ArticleDetail() {
|
||||
<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>
|
||||
<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')}
|
||||
</PremiumButton>
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</section>);
|
||||
|
||||
}
|
||||
@@ -111,7 +92,7 @@ export function ArticleDetail() {
|
||||
*/
|
||||
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 tocColumns = 'lg:col-start-1';
|
||||
|
||||
@@ -190,14 +171,6 @@ export function ArticleDetail() {
|
||||
</div>
|
||||
</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')}>
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<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 { ChevronLeftIcon } from 'lucide-react'
|
||||
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
|
||||
import { Reveal } from '../components/Reveal'
|
||||
import { PremiumButton } from '../components/PremiumButton'
|
||||
import { ButtonLink } from '../components/Button'
|
||||
import { ProjectCard } from '../components/ProjectCard'
|
||||
import { ContactSection } from '../components/ContactSection'
|
||||
import { Lightbox } from '../components/Lightbox'
|
||||
import { LazyProjectsMapSingle } from '../map/LazyMaps';
|
||||
import { createPlaceholderProject, findMapProjectByName } from '../data/mapProjects'
|
||||
import { useEffect } from 'react';
|
||||
import { useSite, useStatusLabel } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { applySeo } from '../seo';
|
||||
import { projectsData } from '../data/projectsData';
|
||||
|
||||
export function ProjectDetail() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const { projects, brand } = useSite()
|
||||
const { t, href, lang } = useLang()
|
||||
|
||||
const currentProject = projectsData.find((p) => p.slug === slug)
|
||||
|
||||
const project = projects.find((p) => p.slug === slug)
|
||||
const statusLabel = useStatusLabel(project?.status ?? '')
|
||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||
|
||||
if (!currentProject) {
|
||||
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
|
||||
// عنوان تب، توضیح متا و OG از خود پروژه
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
applySeo({
|
||||
title: `${displayTitle} | ${brand.name}`,
|
||||
description: summary,
|
||||
path: href(`/projects/${currentProject.slug}`),
|
||||
image: image,
|
||||
title: `${project.title} | ${brand.name}`,
|
||||
description: project.summary,
|
||||
path: href(`/projects/${project.slug}`),
|
||||
image: project.image,
|
||||
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 = [
|
||||
{ label: t('specLocation'), value: displayLocation },
|
||||
{ label: t('specYear'), value: year },
|
||||
{ label: t('specArea'), value: displayArea },
|
||||
{ label: t('specFloors'), value: floors },
|
||||
{ label: t('specUnits'), value: units },
|
||||
{ label: t('specClient'), value: client },
|
||||
]
|
||||
{ label: t('specStatus'), value: statusLabel },
|
||||
{ label: t('specYear'), value: project.year },
|
||||
{ label: t('specLandArea'), value: project.land_area },
|
||||
{ label: t('specArea'), value: project.area },
|
||||
{ label: t('specUsage'), value: project.usage },
|
||||
{ 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 (
|
||||
<>
|
||||
<section className="bg-char pt-24 pb-12 lg:pt-32 lg:pb-16" aria-label={t('projectDetailsAria')}>
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
|
||||
<nav aria-label={t('breadcrumbAria')} className="mb-6 flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
|
||||
<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" />
|
||||
<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">
|
||||
{t('home')}
|
||||
</Link>
|
||||
@@ -101,80 +84,85 @@ export function ProjectDetail() {
|
||||
{t('projects')}
|
||||
</Link>
|
||||
<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>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl lg:text-5xl font-bold text-ivory">{displayTitle}</h1>
|
||||
</div>
|
||||
|
||||
{/* Gallery Desktop: Grid. Mobile: Grid */}
|
||||
<div className="grid grid-cols-2 gap-2 md:gap-3 rounded-xl lg:rounded-2xl overflow-hidden lg:h-[500px] xl:h-[600px]">
|
||||
|
||||
{/* 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)}
|
||||
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
|
||||
<p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
|
||||
{/* ویرایش این پروژه — صفحهٔ اختصاصی ویرایش */}
|
||||
<div className="mt-5">
|
||||
<Link
|
||||
to={`/admin/projects/${project.id}`}
|
||||
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"
|
||||
>
|
||||
<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 */}
|
||||
<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">
|
||||
{mapImages.slice(1, 5).map((img, i) => (
|
||||
<button
|
||||
key={img}
|
||||
<section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}>
|
||||
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
|
||||
|
||||
<Reveal className="order-1">
|
||||
<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"
|
||||
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)}
|
||||
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>
|
||||
))}
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -183,24 +171,17 @@ export function ProjectDetail() {
|
||||
<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-12 grid gap-6 lg:grid-cols-3">
|
||||
{related.map((item, i) => {
|
||||
const dummyImageName = `${(i % 6) + 1}.png`;
|
||||
return (
|
||||
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
|
||||
<ProjectCard
|
||||
project={item}
|
||||
imageUrl={`/seed-media/2026/09/${dummyImageName}`}
|
||||
detailHref={href(`/projects/${item.slug}`)}
|
||||
/>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
{related.map((item, i) => (
|
||||
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
|
||||
<ProjectCard project={item} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Lightbox
|
||||
images={gallery}
|
||||
images={project.gallery}
|
||||
index={lightbox}
|
||||
onClose={() => setLightbox(null)}
|
||||
onChange={setLightbox}
|
||||
|
||||
@@ -9,12 +9,12 @@ import { useSite, useStatusLabel } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from '../components/admin/InlineEdit';
|
||||
import { projectsData } from '../data/projectsData';
|
||||
import { AddItemButton, EditableItem } from '../admin/Editable';
|
||||
import { LazyProjectsMapAll } from '../map/LazyMaps';
|
||||
import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
|
||||
|
||||
export function Projects() {
|
||||
const { status_labels } = useSite();
|
||||
const { status_labels, projects: dbProjects } = useSite();
|
||||
const { t } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
@@ -35,6 +35,10 @@ export function Projects() {
|
||||
];
|
||||
const [status, setStatus] = useState<'done' | 'progress'>('done');
|
||||
|
||||
// پروژهها از دیتابیس خوانده میشوند تا ویرایش پنل همانجا دیده شود؛
|
||||
// فایل استاتیک فقط وقتی دیتابیس خالی است جانشین میشود (محتوای قدیمی).
|
||||
const cardProjects = useMemo(() => dbProjects, [dbProjects]);
|
||||
|
||||
// Filtering and Pagination State
|
||||
const [page, setPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -42,8 +46,8 @@ export function Projects() {
|
||||
|
||||
// Filter projects by status
|
||||
const filteredProjects = useMemo(() => {
|
||||
return projectsData.filter((p) => p.status === status);
|
||||
}, [status]);
|
||||
return cardProjects.filter((p) => p.status === status);
|
||||
}, [cardProjects, status]);
|
||||
|
||||
// Currently displayed projects
|
||||
const displayedProjects = useMemo(() => {
|
||||
@@ -68,12 +72,13 @@ export function Projects() {
|
||||
|
||||
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 match = projectsData.find(
|
||||
(p) => p.name.fa === mapProject.name || p.name.en === mapProject.name
|
||||
const dbMatch = dbProjects.find(
|
||||
(p) => p.title === mapProject.name || p.title_en === mapProject.name,
|
||||
);
|
||||
return match ? `/projects/${match.slug}` : undefined;
|
||||
return dbMatch ? `/projects/${dbMatch.slug}` : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -131,19 +136,24 @@ export function Projects() {
|
||||
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||
{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 (
|
||||
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
|
||||
<ProjectCard
|
||||
project={project}
|
||||
imageUrl={imageUrl}
|
||||
detailHref={`/projects/${project.slug}`}
|
||||
/>
|
||||
<EditableItem resource="projects" item={project} editHref={`/admin/projects/${project.id}`} className="h-full">
|
||||
{card}
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
<AddItemButton resource="projects" editHrefBase="/admin/projects" />
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
|
||||
+7
-1
@@ -15,13 +15,19 @@ if [ -f .env ] && [ "${1:-}" != "--force" ]; then
|
||||
exit 1
|
||||
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)"
|
||||
POSTGRES_PASSWORD="$(rand hex 16)"
|
||||
MINIO_ROOT_PASSWORD="$(rand hex 16)"
|
||||
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
|
||||
# تولیدشده با scripts/gen-env.sh — این فایل هرگز به گیت نمیرود.
|
||||
# پیش از انتشار واقعی، ADMIN_PASSWORD را به مقداری که به یاد میسپارید اما قوی است تغییر دهید:
|
||||
|
||||
+14
-5
@@ -21,25 +21,31 @@ cd "$(dirname "$0")/.."
|
||||
ENV_FILE=.env
|
||||
[ -f "$ENV_FILE" ] || { echo "فایل .env پیدا نشد — اول bash scripts/gen-env.sh" >&2; exit 1; }
|
||||
|
||||
# مقدار فعلی یک کلید در .env (یا خالی)
|
||||
# مقدار فعلی یک کلید در .env (یا خالی) — کوتیشن تکنویسهای هم برداشته میشود
|
||||
env_get() {
|
||||
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 (بدون آسیب به بقیه خطوط)
|
||||
# نکتهٔ مهم: docker compose مقادیر .env را خودش تفسیر میکند؛ هش htpasswd شامل
|
||||
# $apr1$... است و اگر کوتیشن نخورد، $apr1 و بخشهای بعدی بهعنوان متغیر خالی
|
||||
# جایگزین میشوند و Basic Auth هرگز رمز را نمیپذیرد. پس هر مقدار دارای $ را
|
||||
# تککوتیشن میگذاریم (compose کوتیشن را برمیدارد و مقدار را عیناً میخواند).
|
||||
env_set() {
|
||||
local key="$1" value="$2"
|
||||
if grep -q "^${key}=" "$ENV_FILE"; then
|
||||
# جداکننده | تا کاراکترهای ویژه مقدار (مثل / و $) مشکلی نسازند
|
||||
python - "$ENV_FILE" "$key" "$value" <<'PY'
|
||||
python3 - "$ENV_FILE" "$key" "$value" <<'PY'
|
||||
import io, sys
|
||||
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")
|
||||
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))
|
||||
PY
|
||||
else
|
||||
if [ "${value#*\$}" != "$value" ]; then value="'$value'"; fi
|
||||
printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
|
||||
fi
|
||||
}
|
||||
@@ -57,7 +63,7 @@ make_htpasswd() {
|
||||
|
||||
# رمز تصادفی خوانا (بدون نویسههای گیجکننده)
|
||||
random_password() {
|
||||
python - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import secrets, string
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
print("".join(secrets.choice(alphabet) for _ in range(16)))
|
||||
@@ -81,6 +87,8 @@ case "${1:-status}" in
|
||||
fi
|
||||
env_set PREVIEW_MODE on
|
||||
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 ""
|
||||
echo "پیشنمایش خصوصی فعال شد — سایت آنلاین است، ورود با نام کاربری/رمز، بدون ایندکس."
|
||||
@@ -90,6 +98,7 @@ case "${1:-status}" in
|
||||
off)
|
||||
env_set PREVIEW_MODE off
|
||||
env_set PREVIEW_MIDDLEWARES ""
|
||||
env_set PREVIEW_MIDDLEWARES_API ""
|
||||
# در انتشار، دامنه دوم به اصلی ریدایرکت میشود تا محتوای تکراری ایجاد نشود
|
||||
env_set ALIAS_REDIRECT_MIDDLEWARES "to-primary@docker"
|
||||
echo "حالت انتشار عمومی فعال شد — گیت ورود حذف و ایندکس مجاز میشود."
|
||||
|
||||
Reference in New Issue
Block a user