Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e68a84879 |
@@ -40,28 +40,18 @@ Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
|
||||
|
||||
## اجرا با Docker (سادهترین راه)
|
||||
|
||||
پیشنیاز: Docker Desktop. بیلد فرانتاند روی هاست انجام میشود (کانتینر فقط فایلهای
|
||||
`dist/` را سرو میکند)، پس اولین بار باید یکبار `npm run build` بزنید:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمیآید
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
docker compose up -d --build
|
||||
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمیآید
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
سایت: http://localhost:5173 — ورود مدیریت: `/admin/login` (کاربر/رمز از `ADMIN_*` در `.env`)
|
||||
|
||||
- سایت: http://localhost:5173 (از طریق Traefik)
|
||||
- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی)
|
||||
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env`
|
||||
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در compose
|
||||
- دیتابیس PostgreSQL و آبجکتاستور MinIO هر دو در کانتینر جدا با volume دائمی
|
||||
- بکاند هنگام شروع، خودش جداول را میسازد و **فقط جدولهای خالی** را seed میکند —
|
||||
ارتقای نسخه دادههای فعلی را از بین نمیبرد
|
||||
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را میسازد، خواندن عمومی را
|
||||
فعال میکند و تصاویر داخل `seed-media/` را در باکت میریزد — پس یک کلون تازه هم
|
||||
همان تصاویر سایت را دارد
|
||||
|
||||
> نکته برای تازهواردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
|
||||
> فایلهای `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
|
||||
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را میسازد و خواندن عمومی را فعال میکند
|
||||
|
||||
## انتشار واقعی (TLS)
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ services:
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-5173}:80"
|
||||
# پورت به 8080 تغییر کرد تا با پورت 5173 سرور لوکال Vite تداخل نکند
|
||||
- "${TRAEFIK_HTTP_PORT:-8080}:80"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
@@ -46,8 +47,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
ports:
|
||||
- "9001:9001" # کنسول MinIO — در محیط واقعی با فایروال ببندید
|
||||
- "19000:9000" # دسترسی مستقیم/لوکال به MinIO (اختیاری — تصاویر از مسیر /media سرو میشوند)
|
||||
- "9001:9001" # کنسول MinIO
|
||||
- "9000:9000" # دسترسی لوکال به تصاویر و مدیا
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.media.rule=PathPrefix(`/media`)
|
||||
@@ -58,10 +59,7 @@ services:
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
||||
- traefik.http.services.media.loadbalancer.server.port=9000
|
||||
|
||||
# یکبار اجرا میشود: ساخت باکت، بازگذاشتن خواندن عمومی و پیشبارگذاری تصویرهای seed.
|
||||
# تصویرهای داخل seed-media/ (که با پیشوند /media در دیتابیس ثبتاند) روی یک حجم تازه
|
||||
# وارد باکت میشوند تا هر نسخه تازهکلون/بیلد، همان عکسها را داشته باشد؛
|
||||
# روی حجم موجود، mc cp فایلهای تکراری را رد میکند و دادهتازه را نمیخورد.
|
||||
# یکبار اجرا میشود: ساخت باکت و بازگذاشتن خواندن عمومی برای سرو تصاویر
|
||||
minio-setup:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
@@ -71,26 +69,24 @@ services:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
MINIO_BUCKET: aramesh-media
|
||||
volumes:
|
||||
- ./seed-media:/seed-media:ro
|
||||
restart: "no"
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||
mc mb --ignore-existing local/$$MINIO_BUCKET &&
|
||||
mc anonymous set download local/$$MINIO_BUCKET &&
|
||||
echo 'minio-setup: bucket ready' &&
|
||||
cd /seed-media && mc mirror --overwrite . local/$$MINIO_BUCKET/ &&
|
||||
echo 'minio-setup: seed media mirrored'
|
||||
echo 'minio-setup: bucket ready'
|
||||
"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000" # اتصال مستقیم بکاند به ویندوز برای فرانتاند لوکال
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost:5173","http://127.0.0.1:5173"]}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-'["http://localhost:5173","http://127.0.0.1:5173"]'}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD را در .env تنظیم کنید}
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
@@ -159,4 +155,4 @@ services:
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
minio_data:
|
||||
minio_data:
|
||||
@@ -1,47 +0,0 @@
|
||||
// تست موقت رندر استاتیک و ابزارها — پس از اجرا حذف میشود
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer'
|
||||
import { ARTICLE_EXTENSIONS } from './src/components/admin/ArticleEditor'
|
||||
import { formatReadingTime, readingTimeMinutes } from './src/utils/readingTime'
|
||||
import { buildLinkRel, normalizeHref, parseLinkRel } from './src/utils/link'
|
||||
import { docHeadings, docImages, docToText } from './src/utils/tiptapDoc'
|
||||
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'متن نمونه مقاله برای تست رندر' }] },
|
||||
{
|
||||
type: 'image',
|
||||
attrs: { src: '/media/a.jpg', alt: 'تصویر تست', title: 'Photo 1', caption: 'کپشن تصویر تست' },
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'لینک خارجی',
|
||||
marks: [{ type: 'link', attrs: { href: 'https://example.com', target: '_blank', rel: 'noopener noreferrer nofollow' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'تیتر دوم' }] },
|
||||
],
|
||||
}
|
||||
|
||||
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: doc as never })
|
||||
|
||||
console.log('--- rendered html ---')
|
||||
console.log(html)
|
||||
console.log('--- checks ---')
|
||||
console.log('has figure:', html.includes('<figure'))
|
||||
console.log('has figcaption:', html.includes('<figcaption') && html.includes('کپشن تصویر تست'))
|
||||
console.log('has img src:', html.includes('src="/media/a.jpg"'))
|
||||
console.log('has alt:', html.includes('alt="تصویر تست"'))
|
||||
console.log('has title:', html.includes('title="Photo 1"'))
|
||||
console.log('link rel/target:', html.includes('rel="noopener noreferrer nofollow"'), html.includes('target="_blank"'))
|
||||
console.log('--- text utils ---')
|
||||
console.log('text:', docToText(doc))
|
||||
console.log('h2:', docHeadings(doc, 2), '| images:', docImages(doc).length)
|
||||
console.log('reading time:', formatReadingTime(doc), '| minutes:', readingTimeMinutes(doc))
|
||||
console.log('--- link helpers ---')
|
||||
console.log('normalize:', normalizeHref('example.com'), '|', normalizeHref('/contact'), '|', normalizeHref('javascript:alert(1)'))
|
||||
console.log('rel:', buildLinkRel({ newTab: true, nofollow: true }), '|', buildLinkRel({ newTab: false, nofollow: false }), '|', parseLinkRel('noopener noreferrer').newTab)
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: { entry: '.tmp-render-test.ts', formats: ['cjs'], fileName: () => 'tmp-render-test.cjs' },
|
||||
outDir: '.tmp-render-out',
|
||||
minify: false,
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
@@ -1,62 +1,33 @@
|
||||
# فرانتاند گروه ساختمانی آرامش
|
||||
|
||||
# Aramesh Construction Group — Frontend
|
||||
سایت React + Vite + Tailwind — تمام متنها و تصاویر از بکاند (`GET /api/site`) خوانده میشود.
|
||||
|
||||
A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
|
||||
|
||||
## Typography & Smart Bidi Text
|
||||
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
|
||||
|
||||
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
|
||||
|
||||
## Recent UI/UX Improvements
|
||||
* **Tiptap Editor Fixes**:
|
||||
* Resolved the initialization bug where the editor body remained empty during edit mode.
|
||||
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
|
||||
* **Dynamic TOC & Layout**:
|
||||
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
|
||||
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
|
||||
|
||||
## CMS, SEO & Editor Features
|
||||
|
||||
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
|
||||
|
||||
* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting.
|
||||
* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema.
|
||||
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
|
||||
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
|
||||
* **Custom Tiptap WYSIWYG Editor:**
|
||||
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
|
||||
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls.
|
||||
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
|
||||
* **Full Jalali (Shamsi) Calendar Integration:**
|
||||
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
|
||||
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
|
||||
|
||||
## Development Setup
|
||||
## اجرا
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
```
|
||||
|
||||
In development mode, Vite proxies `/api` and `/media` requests to Traefik (port 5173). Therefore, the Docker stack must be running.
|
||||
The backend API URL is read from the `VITE_API_URL` environment variable in `src/config.ts`; an empty default implies same-origin requests.
|
||||
در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند —
|
||||
پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `src/config.ts` از `VITE_API_URL`
|
||||
خوانده میشود؛ پیشفرض خالی یعنی درخواستهای هممبدأ.
|
||||
|
||||
## Build & Production
|
||||
## ساخت
|
||||
|
||||
The build process runs on the host machine. The Docker container only serves the generated static files in the `dist/` directory using Caddy:
|
||||
بیلد روی هاست انجام میشود (کانتینر فقط با Caddy فایلهای `dist/` را سرو میکند):
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
|
||||
```
|
||||
|
||||
> **Note:** The backend reads the `dist/` directory (mounted at `/srv/frontend` in Docker Compose) to inject SEO metadata into the `/blog` and `/projects` pages. After making any frontend changes, running `npm run build` and restarting the backend is sufficient.
|
||||
> نکته: بکاند برای تزریق متای سئو در صفحات `/blog` و `/projects` همین پوشه `dist/` را
|
||||
> (در compose به `/srv/frontend` سوار است) میخواند — بعد از هر تغییر فرانت، `npm run build`
|
||||
> و سپس ریاستارت بکاند کافی است.
|
||||
|
||||
## Key Modules & Architecture
|
||||
## بخشهای کلیدی
|
||||
|
||||
* `src/api.tsx` — Handles site data loading and draft article merging for the admin view.
|
||||
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
|
||||
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
|
||||
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
|
||||
- `src/api.tsx` — بارگذاری داده سایت و ادغام پیشنویس مقالهها برای ادمین
|
||||
- `src/admin/` — لایه ویرایش زنده (دوقلوی دیجیتال): نوار مدیریت، کشوی موجودیتها، صفحه نوشتن مقاله
|
||||
- `src/seo.ts` — تگهای title / description / OG / canonical / JSON-LD هر صفحه
|
||||
- `src/data/site.ts` — انواع داده مشترک
|
||||
|
||||
@@ -19,11 +19,7 @@
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
"slugify": "^1.6.9",
|
||||
"sweetalert2": "^11.26.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -937,6 +933,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
@@ -946,6 +943,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
@@ -955,7 +953,8 @@
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
@@ -2021,15 +2020,6 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
|
||||
@@ -2773,16 +2763,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.407",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
||||
@@ -3753,12 +3733,6 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -3886,6 +3860,7 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -4263,17 +4238,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
|
||||
@@ -4456,12 +4420,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-date-object": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmmirror.com/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
@@ -4475,52 +4433,12 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-element-popper": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/react-element-popper/-/react-element-popper-2.1.7.tgz",
|
||||
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.88.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz",
|
||||
"integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-multi-date-picker": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
|
||||
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-date-object": "^2.1.8",
|
||||
"react-element-popper": "^2.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -4563,43 +4481,6 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-select": {
|
||||
"version": "5.10.2",
|
||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
|
||||
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.0",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.8.1",
|
||||
"@floating-ui/dom": "^1.0.1",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"prop-types": "^15.6.0",
|
||||
"react-transition-group": "^4.3.0",
|
||||
"use-isomorphic-layout-effect": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -4812,15 +4693,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/slugify": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
|
||||
"integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
@@ -5183,20 +5055,6 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-isomorphic-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
|
||||
@@ -21,11 +21,7 @@
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
"slugify": "^1.6.9",
|
||||
"sweetalert2": "^11.26.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -97,10 +97,8 @@ function AppChrome() {
|
||||
function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
|
||||
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
||||
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
||||
return (
|
||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
|
||||
<Preloader />
|
||||
{error ? (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Controller, useController, useForm } from 'react-hook-form'
|
||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
@@ -9,49 +8,22 @@ import { adminApi } from './adminClient'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import type { CategoryOption } from '../components/admin/LuxeSelect'
|
||||
import { ARTICLE_CATEGORIES } from './entities'
|
||||
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||
import { formatReadingTime } from '../utils/readingTime'
|
||||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||
import type { Article, ArticleBodyDoc } from '../data/site'
|
||||
import type { ArticleBodyDoc } from '../data/site'
|
||||
|
||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||||
|
||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته */
|
||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc }
|
||||
|
||||
/**
|
||||
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت میشوند:
|
||||
* عنوان (مبنای Alt پویا)، دستهبندی و متادیتای تصویر شاخص.
|
||||
* `category` در فرم یک شیء تکمقداری {value,label} یا null است
|
||||
* که هنگام ذخیره به رشته (ستون category در بکاند) نگاشت میشود.
|
||||
*/
|
||||
type ArticleMetaForm = {
|
||||
type Draft = {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
category: CategoryOption | null
|
||||
image_alt: string
|
||||
image_title: string
|
||||
}
|
||||
|
||||
/** مقدار رشتهای ذخیرهشده → گزینه react-select (یا null وقتی خالی است) */
|
||||
function toCategoryOption(value: string): CategoryOption | null {
|
||||
const category = (value ?? '').trim()
|
||||
return category ? { label: category, value: category } : null
|
||||
}
|
||||
|
||||
/** گزینه react-select → مقدار رشتهای ذخیرهسازی در بکاند */
|
||||
function categoryValue(option: CategoryOption | null): string {
|
||||
return option?.value.trim() ?? ''
|
||||
}
|
||||
|
||||
/** دادههای قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
|
||||
if (typeof value === 'string') return value.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
return []
|
||||
date: string
|
||||
category: string
|
||||
image: string
|
||||
read: string
|
||||
excerpt: string
|
||||
body: ArticleBodyDoc
|
||||
status: 'draft' | 'published'
|
||||
seo_keyphrase: string
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
@@ -78,45 +50,16 @@ export function ArticleWritePage() {
|
||||
id: 0,
|
||||
slug: `article-${Date.now()}`,
|
||||
title: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
tags: [],
|
||||
date: new Date().toLocaleDateString('fa-IR'),
|
||||
category: 'یادداشت',
|
||||
image: '',
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
body: EMPTY_DOC,
|
||||
status: 'draft',
|
||||
seo_keyphrase: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* فرم متادیتا با React Hook Form — منبع نمایش فیلدها.
|
||||
* هر تغییر، هم فرم و هم draft (مبنای ذخیرهسازی خودکار) را بهروز میکند.
|
||||
*/
|
||||
const { control, watch, reset } = useForm<ArticleMetaForm>({
|
||||
defaultValues: {
|
||||
title: '',
|
||||
category: toCategoryOption(ARTICLE_CATEGORIES[0]),
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
/** عنوان زنده مقاله — مبنای متن جایگزین (Alt) تصویر شاخص */
|
||||
const watchedTitle = watch('title')
|
||||
const { field: imageAltField } = useController({ name: 'image_alt', control })
|
||||
const { field: imageTitleField } = useController({ name: 'image_title', control })
|
||||
|
||||
const [createdId, setCreatedId] = useState<number | null>(null)
|
||||
/**
|
||||
* شناسه محتوای بارگذاریشده — با رسیدن مقاله از سرور عوض میشود تا
|
||||
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
|
||||
*/
|
||||
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const draftRef = useRef(draft)
|
||||
draftRef.current = draft
|
||||
@@ -149,20 +92,9 @@ export function ArticleWritePage() {
|
||||
if (!article || createdRef.current) return
|
||||
setDraft({
|
||||
...article,
|
||||
tags: normalizeTags(article.tags),
|
||||
// بدنه ذخیرهشده (سند جدید یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
body: toArticleBodyDoc(article.body),
|
||||
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
|
||||
})
|
||||
setCreatedId(article.id)
|
||||
// بدنه ویرایشگر با محتوای بارگذاریشده همگام میشود
|
||||
setLoadedContentKey(article.id)
|
||||
// فرم متادیتا هم با مقادیر مقاله بارگذاریشده همگام میشود
|
||||
reset({
|
||||
title: article.title ?? '',
|
||||
category: toCategoryOption(article.category ?? ''),
|
||||
image_alt: article.image_alt ?? '',
|
||||
image_title: article.image_title ?? '',
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [article?.id])
|
||||
|
||||
@@ -258,13 +190,6 @@ export function ArticleWritePage() {
|
||||
queueSave({ [name]: e.target.value }),
|
||||
})
|
||||
|
||||
const metaLength = (draft.meta_description ?? '').trim().length
|
||||
|
||||
// متن جایگزین تصویر شاخص — با watch('title') بهصورت زنده ساخته میشود
|
||||
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||||
{/* نوار بالا */}
|
||||
@@ -345,101 +270,49 @@ export function ArticleWritePage() {
|
||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||
</p>
|
||||
|
||||
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند */}
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
type="text"
|
||||
{...field}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value)
|
||||
queueSave({ title: event.target.value })
|
||||
}}
|
||||
placeholder="عنوان مقاله…"
|
||||
aria-label="عنوان مقاله"
|
||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
{/* عنوان */}
|
||||
<input
|
||||
type="text"
|
||||
{...textField('title')}
|
||||
placeholder="عنوان مقاله…"
|
||||
aria-label="عنوان مقاله"
|
||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||
/>
|
||||
<div className="mt-4 h-px w-28 bg-copper" />
|
||||
|
||||
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصلاند) */}
|
||||
{/* تصویر شاخص */}
|
||||
<div className="mt-8">
|
||||
<ImageField
|
||||
label="تصویر شاخص مقاله"
|
||||
value={draft.image}
|
||||
alt={imageAltField.value}
|
||||
altFallback={imageAltFallback}
|
||||
title={imageTitleField.value}
|
||||
onSave={async (url) => {
|
||||
queueSave({ image: url })
|
||||
return true
|
||||
}}
|
||||
onSaveAlt={async (alt) => {
|
||||
imageAltField.onChange(alt)
|
||||
queueSave({ image_alt: alt })
|
||||
return true
|
||||
}}
|
||||
onSaveTitle={async (imageTitle) => {
|
||||
imageTitleField.onChange(imageTitle)
|
||||
queueSave({ image_title: imageTitle })
|
||||
return true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* متن */}
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
contentKey={loadedContentKey}
|
||||
doc={draft.body as unknown as Record<string, unknown>}
|
||||
onChange={(json) => {
|
||||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود (۲۰۰ واژه در دقیقه)
|
||||
queueSave({ body: json, read: formatReadingTime(json) })
|
||||
}}
|
||||
onChange={(json) => queueSave({ body: json })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* توضیحات متای سئو */}
|
||||
<section className="mt-8 border border-ivory/15 bg-char p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-bold text-copper">توضیحات سئو (Meta Description)</h2>
|
||||
<span
|
||||
className={`text-[11px] font-light ${
|
||||
metaLength >= 150 && metaLength <= 160 ? 'text-emerald-400' : 'text-ivory/40'
|
||||
}`}
|
||||
>
|
||||
{metaLength.toLocaleString('fa-IR')} از ۱۶۰ نویسه
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-light text-ivory/50">
|
||||
این همان متنی است که در صفحه نتایج گوگل زیر لینک مقاله به کاربران نمایش داده میشود (۱۵۰ تا ۱۶۰ نویسه).
|
||||
</p>
|
||||
<textarea
|
||||
id="meta-description"
|
||||
rows={3}
|
||||
value={draft.meta_description ?? ''}
|
||||
onChange={(e) => queueSave({ meta_description: e.target.value })}
|
||||
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
|
||||
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* چکیده و متادیتا */}
|
||||
<SeoChecklist
|
||||
input={{
|
||||
title: draft.title,
|
||||
slug: draft.slug,
|
||||
excerpt: draft.meta_description || draft.excerpt,
|
||||
excerpt: draft.excerpt,
|
||||
body: draft.body,
|
||||
keyphrase: draft.seo_keyphrase,
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
|
||||
<h2 className="text-sm font-bold text-copper">جزئیات انتشار، دستهبندی و هشتگها</h2>
|
||||
<h2 className="text-sm font-bold text-copper">جزئیات انتشار</h2>
|
||||
<div className="mt-5 grid gap-5">
|
||||
<div>
|
||||
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمیشود)</label>
|
||||
@@ -451,79 +324,37 @@ export function ArticleWritePage() {
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
{...textField('excerpt')}
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
{...textField('excerpt')}
|
||||
placeholder="خلاصه کوتاه مقاله برای کارتهای وبلاگ…"
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
<label className="text-xs font-light text-ivory/50">دسته</label>
|
||||
<input
|
||||
type="text"
|
||||
{...textField('category')}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="article-author" className="text-xs font-light text-ivory/50">نویسنده</label>
|
||||
<label className="text-xs font-light text-ivory/50">تاریخ</label>
|
||||
<input
|
||||
id="article-author"
|
||||
type="text"
|
||||
{...textField('author')}
|
||||
placeholder="نام و نام خانوادگی نویسنده"
|
||||
className="mt-2 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"
|
||||
{...textField('date')}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">دستهبندی مقاله (Category)</label>
|
||||
<div className="mt-2">
|
||||
{/* تکانتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
|
||||
<Controller
|
||||
name="category"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CategorySelect
|
||||
value={field.value}
|
||||
options={ARTICLE_CATEGORIES}
|
||||
onChange={(option) => {
|
||||
field.onChange(option)
|
||||
queueSave({ category: categoryValue(option) })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">هشتگها / برچسبها</label>
|
||||
<div className="mt-2">
|
||||
<LuxeTagsSelect
|
||||
value={normalizeTags(draft.tags)}
|
||||
onChange={(tags) => queueSave({ tags })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="article-date" className="text-xs font-light text-ivory/50">تاریخ انتشار (شمسی)</label>
|
||||
<div className="mt-2">
|
||||
<JalaliDatePicker
|
||||
id="article-date"
|
||||
value={draft.date ?? ''}
|
||||
onChange={(isoDate) => queueSave({ date: isoDate })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
{...textField('read')}
|
||||
className="mt-2 w-full cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-sm font-light text-ivory/50"
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -5,11 +5,8 @@ import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||
import type { EntityConfig, FieldConfig } from './entities'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
|
||||
type EntityDrawerProps = {
|
||||
@@ -114,45 +111,21 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="grid gap-6">
|
||||
{config.fields.map((field) => {
|
||||
const isImage = field.type === 'image'
|
||||
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
||||
const altKey = isImage
|
||||
? field.name === 'src' && 'alt' in item
|
||||
? 'alt'
|
||||
: `${field.name}_alt`
|
||||
: ''
|
||||
const imageTitleKey = isImage ? `${field.name}_title` : ''
|
||||
// پیشنهاد Alt از عنوان رکورد ساخته میشود (هرگز از URL)
|
||||
const imageAltFallback = [
|
||||
String(item[config.titleKey] ?? '').trim(),
|
||||
field.altFallbackSuffix,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
return (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
altValue={altKey ? String(item[altKey] ?? '') : undefined}
|
||||
altFallback={imageAltFallback}
|
||||
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
|
||||
onSaveImageTitle={
|
||||
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
|
||||
}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
? (fn) => {
|
||||
flushRef.current = fn
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{config.fields.map((field) => (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
? (fn) => {
|
||||
flushRef.current = fn
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -177,33 +150,16 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
altValue,
|
||||
altFallback,
|
||||
imageTitleValue,
|
||||
onSave,
|
||||
onSaveAlt,
|
||||
onSaveImageTitle,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
/** مقدار Alt تصویر — فقط برای فیلدهای image */
|
||||
altValue?: string
|
||||
altFallback?: string
|
||||
/** مقدار Title تصویر — فقط برای فیلدهای image */
|
||||
imageTitleValue?: string
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||
onSaveImageTitle?: (title: string) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [text, setText] = useState<string>(String(value ?? ''))
|
||||
|
||||
// همگامسازی مقدار محلی با دادهای که از بیرون میآید (بارگذاری/بهروزرسانی خوشبینانه)
|
||||
useEffect(() => {
|
||||
setText(String(value ?? ''))
|
||||
}, [value])
|
||||
|
||||
const commitText = async () => {
|
||||
const next = field.type === 'number' ? Number(text) || 0 : text
|
||||
if (String(value ?? '') === String(next)) return
|
||||
@@ -211,61 +167,7 @@ function FieldControl({
|
||||
}
|
||||
|
||||
if (field.type === 'image') {
|
||||
return (
|
||||
<ImageField
|
||||
label={field.label}
|
||||
value={String(value ?? '')}
|
||||
alt={altValue}
|
||||
altFallback={altFallback}
|
||||
title={imageTitleValue}
|
||||
onSave={(url) => onSave(url)}
|
||||
onSaveAlt={onSaveAlt}
|
||||
onSaveTitle={onSaveImageTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
// مقدار نمایشی از وضعیت محلی میآید تا افزودن/حذف گزینه فوراً دیده شود
|
||||
const selectProps = {
|
||||
value: text,
|
||||
options: field.options ?? [],
|
||||
onChange: (next: string) => {
|
||||
setText(next)
|
||||
void onSave(next)
|
||||
},
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
{field.creatable ? (
|
||||
<LuxeCreatableSelect {...selectProps} />
|
||||
) : (
|
||||
<LuxeSelect {...selectProps} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'date') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
<JalaliDatePicker
|
||||
value={String(value ?? '')}
|
||||
onChange={(isoDate) => {
|
||||
setText(isoDate)
|
||||
void onSave(isoDate)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <ImageField label={field.label} value={String(value ?? '')} onSave={(url) => onSave(url)} />
|
||||
}
|
||||
|
||||
if (field.type === 'wysiwyg') {
|
||||
@@ -273,11 +175,7 @@ function FieldControl({
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
return field.ui === 'tags' ? (
|
||||
<TagsField field={field} value={value} onSave={onSave} />
|
||||
) : (
|
||||
<ListField field={field} value={value} onSave={onSave} />
|
||||
)
|
||||
return <ListField field={field} value={value} onSave={onSave} />
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
@@ -312,34 +210,6 @@ function FieldControl({
|
||||
)
|
||||
}
|
||||
|
||||
/** فیلد هشتگها — react-select قابل ساخت (Creatable) با ذخیره فوری */
|
||||
function TagsField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
}) {
|
||||
const [tags, setTags] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||
|
||||
const update = (next: string[]) => {
|
||||
setTags(next)
|
||||
void onSave(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
<LuxeTagsSelect value={tags} onChange={update} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
||||
function ListField({
|
||||
field,
|
||||
@@ -414,9 +284,10 @@ function WysiwygField({
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(
|
||||
// بدنه ذخیرهشده (سند جدید، رشته JSON یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
() => toArticleBodyDoc(value),
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pending = useRef<Record<string, unknown> | null>(null)
|
||||
|
||||
@@ -1,56 +1,37 @@
|
||||
import type { SiteData } from '../api'
|
||||
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg'
|
||||
|
||||
export type FieldConfig = {
|
||||
name: string
|
||||
label: string
|
||||
type: FieldType
|
||||
hint?: string
|
||||
/** فقط برای type === 'select' — گزینههای ثابت react-select */
|
||||
options?: string[]
|
||||
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
|
||||
creatable?: boolean
|
||||
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
||||
ui?: 'lines' | 'tags'
|
||||
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
|
||||
altFallbackSuffix?: string
|
||||
}
|
||||
|
||||
export type EntityConfig = {
|
||||
/** مسیر منبع در پنل مدیریت، مثل services یا why-us */
|
||||
resource: string
|
||||
/** کلید همان منبع در پاسخ GET /api/site */
|
||||
dataKey: string
|
||||
title: string
|
||||
/** مجموعه یا رکورد یگانه */
|
||||
kind: 'collection' | 'singleton'
|
||||
/** فیلدی که عنوان آیتم را نشان میدهد */
|
||||
titleKey: string
|
||||
fields: FieldConfig[]
|
||||
/** مقادیر پیشفرض برای «افزودن» */
|
||||
defaults: Record<string, unknown>
|
||||
}
|
||||
|
||||
const F = (
|
||||
name: string,
|
||||
label: string,
|
||||
type: FieldType = 'text',
|
||||
hint?: string,
|
||||
extra?: Partial<FieldConfig>,
|
||||
): FieldConfig => ({
|
||||
const F = (name: string, label: string, type: FieldType = 'text', hint?: string): FieldConfig => ({
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
hint,
|
||||
...extra,
|
||||
})
|
||||
|
||||
/** دستهبندیهای استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده میشوند */
|
||||
export const ARTICLE_CATEGORIES = [
|
||||
'معماری و ساخت',
|
||||
'سرمایهگذاری ملکی',
|
||||
'طراحی داخلی',
|
||||
'اخبار و اطلاعیهها',
|
||||
'یادداشت مدیریت',
|
||||
]
|
||||
|
||||
/** پیکربندی همه منابع قابل ویرایش — هم مجموعهها و هم رکوردهای یگانه */
|
||||
export const ENTITIES: Record<string, EntityConfig> = {
|
||||
services: {
|
||||
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
||||
@@ -76,32 +57,14 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
fields: [
|
||||
F('title', 'عنوان'),
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده (روی کارت مقاله)'),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود'),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX }),
|
||||
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true }),
|
||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags' }),
|
||||
F('author', 'نویسنده'),
|
||||
F('date', 'تاریخ انتشار (شمسی)', 'date'),
|
||||
F('read', 'زمان مطالعه (دقیقه)'),
|
||||
F('excerpt', 'چکیده', 'textarea'),
|
||||
F('image', 'تصویر شاخص', 'image'),
|
||||
F('category', 'دسته'),
|
||||
F('date', 'تاریخ'),
|
||||
F('read', 'زمان مطالعه'),
|
||||
F('slug', 'نشانی (slug)'),
|
||||
],
|
||||
defaults: {
|
||||
title: 'مقاله جدید',
|
||||
slug: `article-${Date.now()}`,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
tags: ['مدیریت پروژه', 'ساختوساز'],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
seo_keyphrase: '',
|
||||
body: { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||
},
|
||||
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: { type: 'doc', content: [{ type: 'paragraph' }] } },
|
||||
},
|
||||
testimonials: {
|
||||
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
|
||||
@@ -169,6 +132,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
|
||||
defaults: { label: 'آیتم جدید', href: '/' },
|
||||
},
|
||||
|
||||
seo_meta: {
|
||||
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||
titleKey: 'page_key',
|
||||
@@ -179,6 +143,8 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
],
|
||||
defaults: { page_key: '', title: '', description: '' },
|
||||
},
|
||||
|
||||
// ---------- رکوردهای یگانه ----------
|
||||
brand: {
|
||||
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||
titleKey: 'name',
|
||||
@@ -231,6 +197,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
},
|
||||
}
|
||||
|
||||
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */
|
||||
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
|
||||
return data[config.dataKey as keyof SiteData] as never
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { formatJalali } from '../utils/date'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
export function ArticleCard({ article }: { article: Article }) {
|
||||
return (
|
||||
@@ -35,10 +33,10 @@ export function ArticleCard({ article }: { article: Article }) {
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-3">
|
||||
<span>{formatJalali(article.date)}</span>
|
||||
<span>{article.date}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||
{toPersianDigits(article.read)}
|
||||
{article.read}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { CalendarIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
type ArticleMetaProps = {
|
||||
article: Article
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* سربرگ نویسنده/تاریخ مقاله — زیر تیتر اصلی نمایش داده میشود:
|
||||
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
|
||||
*/
|
||||
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
|
||||
const rawDate = (article.date ?? '').trim()
|
||||
const author = (article.author ?? '').trim() || 'گروه ساختمانی آرامش'
|
||||
const initial = author.charAt(0)
|
||||
|
||||
// تاریخ ذخیرهشده (میلادی) به شمسی تبدیل میشود؛ در نبود تاریخ، امروز با ساعت نمایش داده میشود
|
||||
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
|
||||
const dateLabel = formatJalali(rawDate, todayLabel)
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
|
||||
{/* آواتار و نام نویسنده */}
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-lg font-bold text-copper shadow-[0_0_20px_rgba(213,135,76,0.25),inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||
{initial}
|
||||
</span>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-bold text-ivory">{author}</span>
|
||||
<span className="text-[11px] font-light text-ivory/45">نویسنده</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="hidden h-9 w-px bg-ivory/10 sm:block" aria-hidden="true" />
|
||||
|
||||
{/* تاریخ جلالی */}
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
<CalendarIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||
{dateLabel}
|
||||
</span>
|
||||
|
||||
{/* زمان مطالعه — با ارقام فارسی */}
|
||||
{article.read && (
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||
{toPersianDigits(article.read)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export function FloatingContact() {
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
aria-label="تماس تلفنی با گروه ساختمانی آرامش"
|
||||
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
|
||||
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.3),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
|
||||
>
|
||||
{/* پالس هاله نوری در پسزمینه مانند رادار */}
|
||||
<span
|
||||
@@ -17,6 +17,12 @@ export function FloatingContact() {
|
||||
className="pointer-events-none absolute inset-0 rounded-full border border-copper/60 animate-ping opacity-30"
|
||||
/>
|
||||
|
||||
{/* خط انعکاس نوری کریستال در بالای گوی */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/25 to-transparent"
|
||||
/>
|
||||
|
||||
{/* آیکون تلفن با انیمیشن چرخش ظریف در زمان هاور */}
|
||||
<PhoneIcon className="relative z-10 h-6 w-6 transition-transform duration-300 ease-lux group-hover:rotate-12" />
|
||||
</a>
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { MouseEvent } from 'react'
|
||||
import { ListIcon } from 'lucide-react'
|
||||
import { slugifyFa, uniqueSlug } from '../utils/slug'
|
||||
|
||||
export type TocItem = { id: string; text: string; level: 2 | 3 }
|
||||
|
||||
/**
|
||||
* متن یک تیتر از HTML — همه گرههای تودرتو پیمایش میشوند و <br> به فاصله تبدیل
|
||||
* میشود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
|
||||
*/
|
||||
function headingText(element: Element): string {
|
||||
const parts: string[] = []
|
||||
|
||||
element.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
|
||||
else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const child = node as Element
|
||||
parts.push(child.tagName === 'BR' ? ' ' : headingText(child))
|
||||
}
|
||||
})
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* استخراج فهرست مطالب از HTML ویرایشگر — همه تیترهای H2 و H3 در هر عمقی از سند
|
||||
* (حتی داخل نقلقول) شناسه لنگر میگیرند؛ متن فارسی در شناسه حفظ میشود و تیترهای
|
||||
* تکراری پسوند عددی میگیرند. HTML برگشتی همان است که باید رندر شود.
|
||||
*/
|
||||
export function extractToc(
|
||||
html: string,
|
||||
transform?: (root: HTMLElement) => void,
|
||||
): { items: TocItem[]; html: string } {
|
||||
if (!html || typeof window === 'undefined') return { items: [], html }
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
const used = new Map<string, number>()
|
||||
const items: TocItem[] = []
|
||||
|
||||
doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
|
||||
const text = headingText(element).replace(/\s+/g, ' ').trim()
|
||||
if (!text) return
|
||||
// تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی میگیرد تا لنگرش بینام نماند
|
||||
const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
|
||||
element.setAttribute('id', id)
|
||||
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
|
||||
})
|
||||
|
||||
// تبدیل اختیاری (بستهبندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا میشود
|
||||
transform?.(doc.body)
|
||||
|
||||
return { items, html: doc.body.innerHTML }
|
||||
}
|
||||
|
||||
type TableOfContentsProps = {
|
||||
items: TocItem[]
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* فهرست مطالب کلیکپذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
|
||||
* همه اندازهها و ترازبندیها منطقیاند (start/end) تا در چیدمان راستبهچپ و
|
||||
* چپبهراست — بر اساس جهت مقاله — درست بنشیند.
|
||||
*/
|
||||
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
|
||||
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setActiveId(items[0]?.id ?? '')
|
||||
}, [items])
|
||||
|
||||
useEffect(() => {
|
||||
if (!items.length || typeof IntersectionObserver === 'undefined') return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||
const first = visible[0]?.target.id
|
||||
if (first) setActiveId(first)
|
||||
},
|
||||
{ rootMargin: '-15% 0px -70% 0px', threshold: [0, 1] },
|
||||
)
|
||||
items.forEach((item) => {
|
||||
const el = document.getElementById(item.id)
|
||||
if (el) observer.observe(el)
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}, [items])
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
const go = (event: MouseEvent<HTMLButtonElement>, id: string) => {
|
||||
event.preventDefault()
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
window.history.replaceState(null, '', `#${id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="فهرست مطالب"
|
||||
className={`rounded-2xl border border-white/10 bg-char/60 p-6 backdrop-blur-xl shadow-[0_20px_50px_rgba(0,0,0,0.35),inset_0_1px_1px_rgba(255,255,255,0.08)] ${className}`}
|
||||
>
|
||||
<p className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||
<ListIcon className="h-4 w-4" aria-hidden="true" />
|
||||
{title}
|
||||
</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)]">
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
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]' : ''
|
||||
} ${
|
||||
isActive
|
||||
? 'font-normal text-copper'
|
||||
: 'text-ivory/60 hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +1,47 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||
import {
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
BoldIcon,
|
||||
Heading1Icon,
|
||||
ItalicIcon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ImagePlusIcon,
|
||||
ItalicIcon,
|
||||
Link2Icon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
Loader2Icon,
|
||||
PilcrowIcon,
|
||||
QuoteIcon,
|
||||
Redo2Icon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
Undo2Icon,
|
||||
Loader2Icon,
|
||||
} from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { DEFAULT_IMAGE_ALT } from '../../data/constants'
|
||||
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
|
||||
import { ArticleImage } from './ArticleImage'
|
||||
import { BidiTypography, bidiPluginKey } from './BidiTypography'
|
||||
import { EnglishText } from './EnglishText'
|
||||
import { LinkModal } from './LinkModal'
|
||||
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
|
||||
import type { LinkModalValue } from './LinkModal'
|
||||
|
||||
/**
|
||||
* مارکهای قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آنها از نوار ابزار برداشته شده و
|
||||
* فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شدهاند.
|
||||
*/
|
||||
const LegacySmallSizeMark = Mark.create({
|
||||
name: 'smallSize',
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-small]' }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
|
||||
},
|
||||
})
|
||||
|
||||
/** اکستنشنهای مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
|
||||
export const ARTICLE_EXTENSIONS = [
|
||||
StarterKit.configure({
|
||||
link: false,
|
||||
heading: { levels: [1, 2, 3] },
|
||||
heading: { levels: [2, 3] },
|
||||
}),
|
||||
ArticleImage.configure({ allowBase64: false }),
|
||||
Image.configure({ allowBase64: false }),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||
isAllowedUri: (uri: string) => isAllowedHref(uri),
|
||||
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
|
||||
}),
|
||||
LegacySmallSizeMark,
|
||||
EnglishText,
|
||||
BidiTypography,
|
||||
]
|
||||
|
||||
type ArticleEditorProps = {
|
||||
doc: Record<string, unknown>
|
||||
onChange: (json: Record<string, unknown>) => void
|
||||
/**
|
||||
* شناسه محتوا — با هر تغییر آن، بدنه ویرایشگر از `doc` جاری دوباره بارگذاری میشود
|
||||
* (مثلاً وقتی مقاله از سرور میرسد). بدون این prop محتوا فقط یکبار مقداردهی میشود.
|
||||
*/
|
||||
contentKey?: string | number
|
||||
}
|
||||
|
||||
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمیگرداند */
|
||||
type EditorInstance = NonNullable<ReturnType<typeof useEditor>>
|
||||
|
||||
type TextDirection = 'rtl' | 'ltr'
|
||||
|
||||
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپشده */
|
||||
type LinkApplyMode = 'selection' | 'insert' | 'range'
|
||||
|
||||
type LinkModalState = {
|
||||
open: boolean
|
||||
editing: boolean
|
||||
mode: LinkApplyMode
|
||||
range: { from: number; to: number } | null
|
||||
initial: LinkModalValue
|
||||
}
|
||||
|
||||
const CLOSED_LINK_MODAL: LinkModalState = {
|
||||
open: false,
|
||||
editing: false,
|
||||
mode: 'selection',
|
||||
range: null,
|
||||
initial: { href: '', newTab: true, nofollow: false },
|
||||
}
|
||||
|
||||
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
||||
const URL_LOOKBACK = 300
|
||||
|
||||
function ToolButton({
|
||||
onClick,
|
||||
active = false,
|
||||
@@ -118,7 +58,6 @@ function ToolButton({
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
|
||||
@@ -135,15 +74,13 @@ function ToolButton({
|
||||
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
|
||||
|
||||
/**
|
||||
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشندار، مودال لینک سئو-محور،
|
||||
* کلید راستچین/چپچین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
|
||||
* ویرایشگر WYSIWYG مقاله (TipTap) — راستبهچپ، با آپلود مستقیم تصویر به MinIO
|
||||
* (انتخاب فایل، چسباندن یا کشیدن تصویر داخل متن).
|
||||
*/
|
||||
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
|
||||
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
const { runSave } = useAdmin()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [direction, setDirection] = useState<TextDirection>('rtl')
|
||||
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: ARTICLE_EXTENSIONS,
|
||||
@@ -152,30 +89,16 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
||||
editorProps: {
|
||||
attributes: {
|
||||
dir: 'rtl',
|
||||
lang: 'fa',
|
||||
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
||||
'data-wysiwyg': 'true',
|
||||
},
|
||||
handlePaste: (_view, event, _slice) => {
|
||||
handlePaste: (_event_ignored, event) => {
|
||||
const file = event.clipboardData?.files?.[0]
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
event.preventDefault()
|
||||
void uploadAndInsert(file)
|
||||
return true
|
||||
}
|
||||
// چسباندن یک نشانی → مودال لینک بلافاصله باز میشود
|
||||
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
|
||||
if (isUrlLike(text)) {
|
||||
event.preventDefault()
|
||||
setLinkModal({
|
||||
open: true,
|
||||
editing: false,
|
||||
mode: 'insert',
|
||||
range: null,
|
||||
initial: { href: normalizeHref(text) ?? text, newTab: true, nofollow: false },
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
handleDrop: (_view, event, _slice, moved) => {
|
||||
@@ -188,245 +111,157 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
||||
}
|
||||
return false
|
||||
},
|
||||
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
||||
handleTextInput: (view, from, to, text) => {
|
||||
if (text !== ' ' || from !== to) return false
|
||||
const start = Math.max(0, from - URL_LOOKBACK)
|
||||
const before = view.state.doc.textBetween(start, from, ' ', ' ').trimEnd()
|
||||
const lastToken = before.slice(before.lastIndexOf(' ') + 1)
|
||||
if (!isUrlLike(lastToken)) return false
|
||||
setLinkModal({
|
||||
open: true,
|
||||
editing: false,
|
||||
mode: 'range',
|
||||
range: { from: from - lastToken.length, to: from },
|
||||
initial: { href: normalizeHref(lastToken) ?? lastToken, newTab: true, nofollow: false },
|
||||
})
|
||||
return false
|
||||
},
|
||||
// Enter داخل تیتر، بلوک تازه را پاراگراف میکند تا قالب تیتر سرایت نکند
|
||||
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
||||
})
|
||||
|
||||
// جهت متن فقط روی ناحیه ویرایش اعمال میشود؛ نوار ابزار همیشه راستچین میماند
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.view.dom.setAttribute('dir', direction)
|
||||
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
|
||||
// تزئینهای دوزبانه (اندازه ۲ پیکسل کوچکتر متن لاتین) با جهت تازه بازمحاسبه میشوند
|
||||
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, direction))
|
||||
}, [editor, direction])
|
||||
|
||||
/**
|
||||
* ترکیب (نمونه ویرایشگر، شناسه محتوا) که آخرین بار بارگذاری شده است.
|
||||
* تغییرهای معمول سند (تایپ کاربر) نباید بدنه را از نو بارگذاری کنند.
|
||||
*/
|
||||
const loadedContentRef = useRef<{ editor: EditorInstance; key: string | number } | null>(null)
|
||||
|
||||
// بارگذاری بدنه با تغییر شناسه محتوا (رسیدن مقاله از سرور) یا ساختهشدن نمونه ویرایشگر
|
||||
useEffect(() => {
|
||||
if (!editor || contentKey === undefined) return
|
||||
const loaded = loadedContentRef.current
|
||||
if (loaded && loaded.editor === editor && loaded.key === contentKey) return
|
||||
const incoming = doc && Object.keys(doc).length ? doc : null
|
||||
if (!incoming) return
|
||||
loadedContentRef.current = { editor, key: contentKey }
|
||||
// emitUpdate=false تا بارگذاری، ذخیره خودکارِ بیمورد را فعال نکند
|
||||
editor.commands.setContent(incoming, { emitUpdate: false })
|
||||
}, [editor, contentKey, doc])
|
||||
|
||||
async function uploadAndInsert(file: File) {
|
||||
if (!editor) return
|
||||
setUploading(true)
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
||||
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
|
||||
.run()
|
||||
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run()
|
||||
})
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
|
||||
const openLinkModalForSelection = () => {
|
||||
const setLink = () => {
|
||||
if (!editor) return
|
||||
const attrs = editor.getAttributes('link') as {
|
||||
href?: string | null
|
||||
target?: string | null
|
||||
rel?: string | null
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().unsetLink().run()
|
||||
return
|
||||
}
|
||||
const editing = Boolean(attrs.href)
|
||||
setLinkModal({
|
||||
open: true,
|
||||
editing,
|
||||
mode: 'selection',
|
||||
range: null,
|
||||
initial: {
|
||||
href: attrs.href ?? '',
|
||||
newTab: editing ? attrs.target === '_blank' : true,
|
||||
nofollow: editing ? parseLinkRel(attrs.rel).nofollow : false,
|
||||
void Swal.fire({
|
||||
title: 'نشانی لینک',
|
||||
input: 'text',
|
||||
inputValue: 'https://',
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'ثبت',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
title: 'font-pinar font-bold text-ivory',
|
||||
input: 'font-pinar',
|
||||
confirmButton: 'bg-copper text-char 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',
|
||||
},
|
||||
}).then((result) => {
|
||||
if (!result.isConfirmed || !result.value) return
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
|
||||
})
|
||||
}
|
||||
|
||||
const applyLink = ({ href, newTab, nofollow }: LinkModalValue) => {
|
||||
if (!editor) return
|
||||
const linkAttrs = { href, target: newTab ? '_blank' : null, rel: buildLinkRel({ newTab, nofollow }) }
|
||||
const chain = editor.chain().focus()
|
||||
|
||||
if (linkModal.mode === 'insert') {
|
||||
// نشانی چسباندهشده: متن لینک تازه درج میشود
|
||||
chain.insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: linkAttrs }] }).run()
|
||||
} else if (linkModal.mode === 'range' && linkModal.range) {
|
||||
// نشانی تایپشده: همان محدوده به لینک تبدیل میشود
|
||||
chain.setTextSelection(linkModal.range).extendMarkRange('link').setLink(linkAttrs).run()
|
||||
} else {
|
||||
chain.extendMarkRange('link').setLink(linkAttrs).run()
|
||||
}
|
||||
|
||||
setLinkModal((state) => ({ ...state, open: false }))
|
||||
}
|
||||
|
||||
const removeLink = () => {
|
||||
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
setLinkModal((state) => ({ ...state, open: false }))
|
||||
}
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border border-ivory/15 bg-char" data-dir={direction}>
|
||||
{/* نوار ابزار: همیشه راستچین و در بالای ویرایشگر ثابت میماند */}
|
||||
<div
|
||||
dir="rtl"
|
||||
className="sticky top-14 z-20 flex flex-wrap items-center gap-1 border-b border-ivory/10 bg-char px-2 py-1.5"
|
||||
<div className="border border-ivory/15 bg-char">
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-ivory/10 px-2 py-1.5">
|
||||
<ToolButton
|
||||
label="بولد"
|
||||
active={editor.isActive('bold')}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<ToolButton
|
||||
label="پاراگراف"
|
||||
active={editor.isActive('paragraph')}
|
||||
onClick={() => applyBlockFormat(editor.view, 'paragraph')}
|
||||
>
|
||||
<PilcrowIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۱"
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
onClick={() => applyBlockFormat(editor.view, 1)}
|
||||
>
|
||||
<Heading1Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۲"
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
onClick={() => applyBlockFormat(editor.view, 2)}
|
||||
>
|
||||
<Heading2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر ۳"
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
onClick={() => applyBlockFormat(editor.view, 3)}
|
||||
>
|
||||
<Heading3Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<BoldIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="ایتالیک"
|
||||
active={editor.isActive('italic')}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<ItalicIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="زیرخط"
|
||||
active={editor.isActive('underline')}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<UnderlineIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="خطخورده"
|
||||
active={editor.isActive('strike')}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
>
|
||||
<StrikethroughIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
<Divider />
|
||||
|
||||
{/* جهت متن — فقط محتوای ویرایشگر را عوض میکند */}
|
||||
<ToolButton
|
||||
label="راستچین (فارسی)"
|
||||
active={direction === 'rtl'}
|
||||
onClick={() => setDirection('rtl')}
|
||||
>
|
||||
<AlignRightIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="چپچین (انگلیسی)"
|
||||
active={direction === 'ltr'}
|
||||
onClick={() => setDirection('ltr')}
|
||||
>
|
||||
<AlignLeftIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر میانی"
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
<Heading2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر کوچک"
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
<Heading3Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||
<BoldIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||
<ItalicIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
||||
<UnderlineIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="خطخورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||
<StrikethroughIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="لیست نقطهای"
|
||||
active={editor.isActive('bulletList')}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="لیست شمارهای"
|
||||
active={editor.isActive('orderedList')}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ListOrderedIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="نقلقول"
|
||||
active={editor.isActive('blockquote')}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
>
|
||||
<QuoteIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="لیست نقطهای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="لیست شمارهای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||
<ListOrderedIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="نقلقول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||
<QuoteIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="لینک" active={editor.isActive('link')} onClick={setLink}>
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="افزودن لینک" active={editor.isActive('link')} onClick={openLinkModalForSelection}>
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
||||
</ToolButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
||||
<Undo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
||||
<Redo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
</div>
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
data-editor-file-input
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) void uploadAndInsert(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
||||
<Undo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
||||
<Redo2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
</div>
|
||||
|
||||
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
||||
{linkModal.open && (
|
||||
<LinkModal
|
||||
initial={linkModal.initial}
|
||||
editing={linkModal.editing}
|
||||
onApply={applyLink}
|
||||
onRemove={removeLink}
|
||||
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
data-editor-file-input
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) void uploadAndInsert(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import Image from '@tiptap/extension-image'
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import { ImageNodeView } from './ImageNodeView'
|
||||
|
||||
/**
|
||||
* تصویر مقاله با کپشن.
|
||||
* خروجی HTML در سایت: <figure><img><figcaption>کپشن</figcaption></figure>
|
||||
* و در ویرایشگر با NodeView سفارشی (ورودی کپشن + دکمه حذف) نمایش داده میشود.
|
||||
*/
|
||||
export const ArticleImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
// ویژگیهای استاندارد تصویر (src/alt/title/width/height) از افزونه پایه
|
||||
...this.parent?.(),
|
||||
caption: {
|
||||
default: '',
|
||||
parseHTML: (element) =>
|
||||
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'figure',
|
||||
priority: 60,
|
||||
getAttrs: (element) => {
|
||||
if (!(element instanceof HTMLElement)) return false
|
||||
const img = element.querySelector('img')
|
||||
if (!img) return false
|
||||
return {
|
||||
src: img.getAttribute('src'),
|
||||
alt: img.getAttribute('alt'),
|
||||
title: img.getAttribute('title'),
|
||||
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||
}
|
||||
},
|
||||
},
|
||||
// تصاویر بدون figure (محتوای قدیمی یا چسباندهشده)
|
||||
{ tag: 'img[src]:not([src^="data:"])' },
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const { caption, ...imageAttributes } = HTMLAttributes
|
||||
const image: [string, Record<string, unknown>] = [
|
||||
'img',
|
||||
mergeAttributes(this.options.HTMLAttributes, imageAttributes),
|
||||
]
|
||||
|
||||
// کپشن خالی → عنصر figcaption اصلاً رندر نمیشود (فقط تصویر میماند)
|
||||
return caption
|
||||
? [
|
||||
'figure',
|
||||
{ class: 'article-figure' },
|
||||
image,
|
||||
['figcaption', { class: 'article-figure__caption' }, caption],
|
||||
]
|
||||
: ['figure', { class: 'article-figure' }, image]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageNodeView)
|
||||
},
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import { LATIN_CLASS, RTL_CLASS, findScriptRuns } from '../../utils/bidiText'
|
||||
|
||||
/** نام مارکهای قدیمی که خودشان اندازه/جهت متن را تعیین میکنند */
|
||||
const LEGACY_MARKS = new Set(['englishText', 'smallSize'])
|
||||
|
||||
type BidiState = { dir: 'rtl' | 'ltr' }
|
||||
|
||||
/** کلید پلاگین — جهت متن با متای همین کلید بهروز میشود */
|
||||
export const bidiPluginKey = new PluginKey<BidiState>('bidiTypography')
|
||||
|
||||
/**
|
||||
* تزئین دوزبانه متن ویرایشگر — اجرای لاتین (و در متن انگلیسی، اجرای فارسی) با یک
|
||||
* span جهتدار نشانهگذاری میشود تا ویرایشگر هم دقیقاً همان اندازه «۲ پیکسل کوچکتر»
|
||||
* خروجی سایت را نشان دهد. تزئینها سند را تغییر نمیدهند و ذخیره نمیشوند.
|
||||
*/
|
||||
export const BidiTypography = Extension.create({
|
||||
name: 'bidiTypography',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin<BidiState>({
|
||||
key: bidiPluginKey,
|
||||
state: {
|
||||
init: () => ({ dir: 'rtl' }),
|
||||
apply: (tr, value) => {
|
||||
const dir = tr.getMeta(bidiPluginKey) as BidiState['dir'] | undefined
|
||||
return dir && dir !== value.dir ? { dir } : value
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations: (state) =>
|
||||
buildDecorations(state, bidiPluginKey.getState(state)?.dir ?? 'rtl'),
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
function buildDecorations(state: EditorState, dir: 'rtl' | 'ltr'): DecorationSet {
|
||||
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
|
||||
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
|
||||
const decorations: Decoration[] = []
|
||||
const { doc, schema } = state
|
||||
|
||||
doc.descendants((node, pos, parent) => {
|
||||
if (node.type.name === 'codeBlock' || parent?.type.name === 'codeBlock') return false
|
||||
if (!node.isText || !node.text) return
|
||||
if (node.marks.some((mark) => mark.type === schema.marks.code || LEGACY_MARKS.has(mark.type.name))) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const run of findScriptRuns(node.text, dir)) {
|
||||
decorations.push(
|
||||
Decoration.inline(pos + run.from, pos + run.to, { class: className, dir: runDir }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||
|
||||
/** نام مارک متن انگلیسی — ویرایشگر و رندر استاتیک مقاله از همین نام استفاده میکنند */
|
||||
export const ENGLISH_TEXT_MARK = 'englishText'
|
||||
|
||||
/**
|
||||
* مارک «متن انگلیسی» — متن انتخابشده را داخل
|
||||
* <span dir="ltr" class="english-text"> میپیچد تا ۱۲ پیکسل و چپبهراست
|
||||
* نمایش داده شود، بدون اینکه جهت متن فارسی اطرافش بههم بخورد.
|
||||
*/
|
||||
export const EnglishText = Mark.create({
|
||||
name: ENGLISH_TEXT_MARK,
|
||||
inclusive: false,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span.english-text' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, { dir: 'ltr', class: 'english-text' }),
|
||||
0,
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -1,71 +1,23 @@
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||
import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
|
||||
import { useRef, useState } from 'react'
|
||||
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
import { useSmartTextField } from './useSmartTextField'
|
||||
|
||||
type ImageFieldProps = {
|
||||
label: string
|
||||
value: string
|
||||
onSave: (url: string) => Promise<boolean>
|
||||
/** متن جایگزین (Alt) */
|
||||
alt?: string
|
||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||
/** عنوان تصویر (Title) */
|
||||
title?: string
|
||||
onSaveTitle?: (title: string) => Promise<boolean>
|
||||
/**
|
||||
* پیشنهاد Alt وقتی کاربر چیزی ننوشته — مثل «{عنوان مقاله} - تصویر شاخص».
|
||||
* هرگز از URL یا نام فایل ساخته نمیشود.
|
||||
*/
|
||||
altFallback?: string
|
||||
/** پیشنهاد عنوان تصویر — پیشفرض «Photo 1» */
|
||||
titleFallback?: string
|
||||
}
|
||||
|
||||
const META_FIELD_CLASS =
|
||||
'mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||
|
||||
/**
|
||||
* انتخاب و آپلود تصویر به MinIO با پیشنمایش بزرگ.
|
||||
* ترتیب فیلدها: ۱) عنوان تصویر (پیشفرض «Photo 1») ۲) متن جایگزین (پیشفرض «{عنوان} - تصویر شاخص»).
|
||||
* هر دو «دستی یا پیشفرض» هستند و Alt هرگز از URL ساخته نمیشود.
|
||||
* پیشفرض خودکار با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمیگردد.
|
||||
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش استفاده میشود.
|
||||
* پس از آپلود موفق، URL نسبی /media/... ذخیره میشود.
|
||||
*/
|
||||
export function ImageField({
|
||||
label,
|
||||
value,
|
||||
onSave,
|
||||
alt,
|
||||
onSaveAlt,
|
||||
title,
|
||||
onSaveTitle,
|
||||
altFallback = '',
|
||||
titleFallback = '',
|
||||
}: ImageFieldProps) {
|
||||
export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
||||
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const titleId = useId()
|
||||
const altId = useId()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const showTitle = title !== undefined || !!onSaveTitle
|
||||
const showAlt = alt !== undefined || !!onSaveAlt
|
||||
|
||||
const titleField = useSmartTextField({
|
||||
value: title ?? '',
|
||||
fallback: titleFallback,
|
||||
hardFallback: DEFAULT_IMAGE_TITLE,
|
||||
onCommit: onSaveTitle,
|
||||
})
|
||||
const altField = useSmartTextField({
|
||||
value: alt ?? '',
|
||||
fallback: altFallback,
|
||||
hardFallback: label,
|
||||
onCommit: onSaveAlt,
|
||||
})
|
||||
|
||||
if (!isAuthenticated || !isEditMode) return null
|
||||
|
||||
const pick = async (file: File | undefined) => {
|
||||
@@ -76,9 +28,6 @@ export function ImageField({
|
||||
const { url } = await adminApi.upload(file)
|
||||
const ok = await onSave(url)
|
||||
if (!ok) throw new Error('ذخیره نشد')
|
||||
// پیشفرضهای Alt/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
||||
if (showTitle) await titleField.commit()
|
||||
if (showAlt) await altField.commit()
|
||||
})
|
||||
} finally {
|
||||
setUploading(false)
|
||||
@@ -88,92 +37,43 @@ export function ImageField({
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||
|
||||
{/* پیشنمایش بزرگ تصویر */}
|
||||
<div className="mt-2 overflow-hidden border border-ivory/15 bg-char">
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
{value ? (
|
||||
<img
|
||||
src={value}
|
||||
alt={altField.display || label}
|
||||
title={titleField.display || undefined}
|
||||
className="h-[27rem] w-full object-cover"
|
||||
alt={label}
|
||||
className="h-16 w-24 border border-ivory/15 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[27rem] w-full flex-col items-center justify-center gap-2 border border-dashed border-ivory/20 text-ivory/30">
|
||||
<ImageIcon className="h-7 w-7" aria-hidden="true" />
|
||||
<span className="text-[11px] font-light">تصویری انتخاب نشده است</span>
|
||||
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="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 ? (
|
||||
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(showTitle || showAlt) && (
|
||||
<div className="mt-4 grid max-w-md gap-4 border-t border-ivory/10 pt-4 sm:grid-cols-2">
|
||||
{showTitle && (
|
||||
<div>
|
||||
<label htmlFor={titleId} className="text-[11px] font-light text-ivory/50">
|
||||
عنوان تصویر (Title)
|
||||
</label>
|
||||
<input
|
||||
id={titleId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={titleField.display}
|
||||
onChange={(event) => titleField.onChange(event.target.value)}
|
||||
onFocus={titleField.onFocus}
|
||||
onBlur={titleField.onBlur}
|
||||
placeholder={titleFallback.trim() || DEFAULT_IMAGE_TITLE}
|
||||
className={META_FIELD_CLASS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAlt && (
|
||||
<div>
|
||||
<label htmlFor={altId} className="text-[11px] font-light text-ivory/50">
|
||||
متن جایگزین (Alt)
|
||||
</label>
|
||||
<input
|
||||
id={altId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={altField.display}
|
||||
onChange={(event) => altField.onChange(event.target.value)}
|
||||
onFocus={altField.onFocus}
|
||||
onBlur={altField.onBlur}
|
||||
placeholder={
|
||||
altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحهخوانها'
|
||||
}
|
||||
className={META_FIELD_CLASS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="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 ? (
|
||||
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
|
||||
</button>
|
||||
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void pick(event.target.files?.[0])
|
||||
event.target.value = ''
|
||||
onChange={(e) => {
|
||||
void pick(e.target.files?.[0])
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { NodeViewWrapper } from '@tiptap/react'
|
||||
import type { NodeViewProps } from '@tiptap/react'
|
||||
import { PlusIcon, XIcon } from 'lucide-react'
|
||||
|
||||
type ImageAttrs = {
|
||||
src: string
|
||||
alt: string | null
|
||||
title: string | null
|
||||
caption: string | null
|
||||
}
|
||||
|
||||
const DELETE_BUTTON_CLASS =
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center border border-red-400/50 bg-ink/85 text-red-300 backdrop-blur-sm transition-colors duration-150 hover:bg-red-500/20'
|
||||
|
||||
const INLINE_FIELD_CLASS =
|
||||
'w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||
|
||||
const ADD_CAPTION_CLASS =
|
||||
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
|
||||
|
||||
/**
|
||||
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
|
||||
* <figure><img><figcaption>کپشن</figcaption></figure>
|
||||
*
|
||||
* ویرایش کاملاً درونخطی است (بدون پاپاور/مودال):
|
||||
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره میشود.
|
||||
* - دکمه X کپشن را کامل حذف میکند: صفت کپشن به مقدار پیشفرض برمیگردد و عنصر
|
||||
* figcaption با ورودیاش از DOM ویرایشگر (و از خروجی ذخیرهشده) حذف میشود؛
|
||||
* فقط تصویر میماند و «افزودن کپشن» دوباره جعبه را باز میکند.
|
||||
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت میشود.
|
||||
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
||||
*/
|
||||
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
||||
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
||||
const hasCaption = Boolean((caption ?? '').trim())
|
||||
const [captionOpen, setCaptionOpen] = useState(true)
|
||||
const captionInputRef = useRef<HTMLInputElement>(null)
|
||||
const focusOnOpen = useRef(false)
|
||||
const showCaption = captionOpen || hasCaption
|
||||
|
||||
// فقط وقتی کاربر «افزودن کپشن» را میزند ورودی فوکوس میگیرد (نه هنگام بارگذاری مقاله)
|
||||
useEffect(() => {
|
||||
if (!focusOnOpen.current) return
|
||||
focusOnOpen.current = false
|
||||
captionInputRef.current?.focus()
|
||||
}, [captionOpen])
|
||||
|
||||
/** حذف کامل کپشن: عنصر figcaption و ورودیاش از DOM ویرایشگر بیرون میرود */
|
||||
const removeCaption = () => {
|
||||
updateAttributes({ caption: '' })
|
||||
setCaptionOpen(false)
|
||||
}
|
||||
|
||||
const addCaption = () => {
|
||||
focusOnOpen.current = true
|
||||
setCaptionOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
|
||||
data-drag-handle
|
||||
>
|
||||
<figure className="article-figure relative">
|
||||
<img src={src} alt={alt ?? ''} title={title ?? ''} draggable={false} />
|
||||
|
||||
{/* حذف کل تصویر */}
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => deleteNode()}
|
||||
aria-label="حذف تصویر"
|
||||
title="حذف تصویر"
|
||||
className={`absolute left-2 top-2 z-10 ${DELETE_BUTTON_CLASS}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{showCaption ? (
|
||||
<figcaption className="article-figure__caption" contentEditable={false}>
|
||||
<input
|
||||
ref={captionInputRef}
|
||||
type="text"
|
||||
value={caption ?? ''}
|
||||
onChange={(event) => updateAttributes({ caption: event.target.value })}
|
||||
placeholder="توضیح تصویر (کپشن)…"
|
||||
aria-label="کپشن تصویر"
|
||||
className={INLINE_FIELD_CLASS}
|
||||
/>
|
||||
{/* حذف کامل کپشن — تصویر دستنخورده میماند */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeCaption}
|
||||
aria-label="حذف کپشن"
|
||||
title="حذف کپشن (تصویر حذف نمیشود)"
|
||||
className={DELETE_BUTTON_CLASS}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</figcaption>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={addCaption}
|
||||
className={ADD_CAPTION_CLASS}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" aria-hidden="true" />
|
||||
افزودن کپشن
|
||||
</button>
|
||||
)}
|
||||
</figure>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import DatePicker from 'react-multi-date-picker'
|
||||
import type { DateObject } from 'react-multi-date-picker'
|
||||
import persian from 'react-date-object/calendars/persian'
|
||||
import persian_fa from 'react-date-object/locales/persian_fa'
|
||||
import { toISODate, toJalaliDateObject } from '../../utils/date'
|
||||
|
||||
type JalaliDatePickerProps = {
|
||||
/** مقدار ذخیرهشده — ISO میلادی (YYYY-MM-DD) */
|
||||
value: string
|
||||
/** تغییر مقدار — همیشه ISO میلادی برمیگرداند تا ذخیرهسازی یکدست بماند */
|
||||
onChange: (isoDate: string) => void
|
||||
id?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخابگر تاریخ شمسی (react-multi-date-picker) با تقویم و locale فارسی.
|
||||
* نمایش جلالی است اما مقدار ذخیرهشده ISO میلادی میماند.
|
||||
*/
|
||||
export function JalaliDatePicker({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
placeholder = 'انتخاب تاریخ',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: JalaliDatePickerProps) {
|
||||
return (
|
||||
<DatePicker
|
||||
id={id}
|
||||
value={toJalaliDateObject(value)}
|
||||
onChange={(selected) => {
|
||||
const picked = Array.isArray(selected) ? selected[0] : selected
|
||||
if (!picked) return
|
||||
onChange(toISODate(picked as DateObject))
|
||||
}}
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
format="YYYY/MM/DD"
|
||||
calendarPosition="bottom-right"
|
||||
className={`aramesh-datepicker ${className}`}
|
||||
containerClassName="w-full"
|
||||
inputClass="w-full cursor-pointer 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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
mobileLabels={{ OK: 'تأیید', CANCEL: 'انصراف' }}
|
||||
portal
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link2Icon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import { buildLinkRel, normalizeHref } from '../../utils/link'
|
||||
|
||||
export type LinkModalValue = {
|
||||
href: string
|
||||
newTab: boolean
|
||||
nofollow: boolean
|
||||
}
|
||||
|
||||
type LinkModalProps = {
|
||||
/** مقادیر اولیه — از لینک فعال زیر نشانگر یا خالی برای لینک تازه */
|
||||
initial: LinkModalValue
|
||||
/** ویرایش لینک موجود (دکمه «حذف لینک» نمایش داده میشود) */
|
||||
editing: boolean
|
||||
onApply: (value: LinkModalValue) => void
|
||||
onRemove: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FIELD_CLASS =
|
||||
'mt-2 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'
|
||||
|
||||
/** مدت انیمیشن ورود/خروج — با duration-300 کلاسها هماهنگ است */
|
||||
const TRANSITION_MS = 300
|
||||
|
||||
/**
|
||||
* مودال ثبت و ویرایش لینک — انیمیشن نرم fade و scale با کلاسهای Tailwind.
|
||||
* ورود با یک فریم تاخیر (تا کلاسهای بسته اعمال شوند) و خروج پیش از بستن مودال اجرا میشود.
|
||||
* جایگزین پنجره پیشفرض مرورگر برای لینک شده است.
|
||||
*/
|
||||
export function LinkModal({ initial, editing, onApply, onRemove, onClose }: LinkModalProps) {
|
||||
const [href, setHref] = useState(initial.href)
|
||||
const [newTab, setNewTab] = useState(initial.newTab)
|
||||
const [nofollow, setNofollow] = useState(initial.nofollow)
|
||||
const [error, setError] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
// انیمیشن ورود: ابتدا حالت بسته رندر میشود، سپس به حالت باز میرود
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => setVisible(true))
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [])
|
||||
|
||||
/** بستن نرم: اول انیمیشن خروج کامل میشود، بعد عملیات و unmount */
|
||||
const closeWith = (action?: () => void) => {
|
||||
if (closing) return
|
||||
setClosing(true)
|
||||
setVisible(false)
|
||||
window.setTimeout(() => {
|
||||
action?.()
|
||||
onClose()
|
||||
}, TRANSITION_MS)
|
||||
}
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const normalized = normalizeHref(href)
|
||||
if (!normalized) {
|
||||
setError('نشانی معتبر نیست — نمونه: https://example.com یا /contact')
|
||||
return
|
||||
}
|
||||
closeWith(() => onApply({ href: normalized, newTab, nofollow }))
|
||||
}
|
||||
|
||||
const relPreview = buildLinkRel({ newTab, nofollow })
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[97] flex items-center justify-center p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="ثبت لینک"
|
||||
>
|
||||
{/* پسزمینه تار — fade نرم */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-char/70 backdrop-blur-sm transition-opacity duration-300 ease-out ${
|
||||
visible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onClick={() => closeWith()}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* کارت مودال — fade + scale نرم */}
|
||||
<form
|
||||
onSubmit={submit}
|
||||
dir="rtl"
|
||||
className={`relative w-full max-w-md border border-ivory/10 bg-ink p-7 shadow-2xl transition-all duration-300 ease-out ${
|
||||
visible ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-95 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||
<Link2Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{editing ? 'ویرایش لینک' : 'افزودن لینک'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith()}
|
||||
aria-label="بستن"
|
||||
className="text-ivory/50 transition-colors hover:text-ivory"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* نشانی */}
|
||||
<label htmlFor="link-href" className="mt-6 block text-xs font-light text-ivory/60">
|
||||
نشانی پیوند (URL)
|
||||
</label>
|
||||
<input
|
||||
id="link-href"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
autoFocus
|
||||
value={href}
|
||||
onChange={(event) => {
|
||||
setHref(event.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className={FIELD_CLASS}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 border-r-2 border-red-400/70 pr-3 text-[11px] font-light leading-6 text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* باز شدن در تب جدید */}
|
||||
<label className="mt-5 flex cursor-pointer items-center gap-3 text-sm font-light text-ivory/80">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newTab}
|
||||
onChange={(event) => setNewTab(event.target.checked)}
|
||||
className="h-4 w-4 accent-copper"
|
||||
/>
|
||||
باز شدن در تب جدید
|
||||
<span className="text-[10px] font-light text-ivory/35" dir="ltr">target="_blank"</span>
|
||||
</label>
|
||||
|
||||
{/* نوع لینک از نظر سئو */}
|
||||
<fieldset className="mt-5">
|
||||
<legend className="text-xs font-light text-ivory/60">انتقال اعتبار سئو (rel)</legend>
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
{[
|
||||
{ key: 'dofollow', label: 'Dofollow', hint: 'اعتبار سئو به مقصد منتقل میشود', value: false },
|
||||
{ key: 'nofollow', label: 'Nofollow', hint: 'گوگل این لینک را دنبال نکند', value: true },
|
||||
].map((option) => (
|
||||
<label
|
||||
key={option.key}
|
||||
className={`flex cursor-pointer items-start gap-3 border px-4 py-3 transition-colors duration-150 ${
|
||||
nofollow === option.value
|
||||
? 'border-copper/60 bg-copper/10'
|
||||
: 'border-ivory/12 hover:border-ivory/25'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="link-rel"
|
||||
checked={nofollow === option.value}
|
||||
onChange={() => setNofollow(option.value)}
|
||||
className="mt-0.5 h-4 w-4 accent-copper"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-light text-ivory">{option.label}</span>
|
||||
<span className="text-[11px] font-light text-ivory/45">{option.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{relPreview && (
|
||||
<p className="mt-4 text-[10px] font-light text-ivory/35" dir="ltr">
|
||||
rel="{relPreview}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* دکمهها */}
|
||||
<div className="mt-7 flex items-center justify-between gap-3">
|
||||
{editing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith(onRemove)}
|
||||
className="flex items-center gap-1.5 border border-red-400/40 px-4 py-2.5 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
حذف لینک
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeWith()}
|
||||
className="border border-ivory/20 px-5 py-2.5 text-xs font-light text-ivory/70 transition-colors hover:border-ivory/40 hover:text-ivory"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-copper px-6 py-2.5 text-xs font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
{editing ? 'ذخیره لینک' : 'افزودن لینک'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Select from 'react-select'
|
||||
import CreatableSelect from 'react-select/creatable'
|
||||
import type { ClassNamesConfig, GroupBase, MultiValueRemoveProps } from 'react-select'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
export type LuxeOption = { label: string; value: string }
|
||||
|
||||
type LuxeClassNames<IsMulti extends boolean> = ClassNamesConfig<
|
||||
LuxeOption,
|
||||
IsMulti,
|
||||
GroupBase<LuxeOption>
|
||||
>
|
||||
|
||||
const CONTROL_CLASS_NAMES = (isFocused: boolean): string =>
|
||||
`!min-h-[42px] !rounded-none !border !bg-char !transition-colors ${
|
||||
isFocused
|
||||
? '!border-copper !shadow-[0_0_0_1px_rgba(213,135,76,0.35)]'
|
||||
: '!border-ivory/15 !shadow-none hover:!border-copper/50'
|
||||
}`
|
||||
|
||||
const OPTION_CLASS_NAMES = (isSelected: boolean, isFocused: boolean): string =>
|
||||
`!cursor-pointer !rounded-none !text-[13px] ${
|
||||
isSelected
|
||||
? '!bg-copper/25 !font-bold !text-copper'
|
||||
: isFocused
|
||||
? '!bg-copper/15 !text-ivory'
|
||||
: '!bg-transparent !text-ivory/85'
|
||||
}`
|
||||
|
||||
/**
|
||||
* کلاسهای Tailwind برای همه اسلاتهای react-select — تم تیره پروژه.
|
||||
* react-select برای هر اسلات تابع میخواهد، پس همه با تابع برگردانده میشوند.
|
||||
*/
|
||||
function makeClassNames<IsMulti extends boolean>(
|
||||
extras?: Partial<LuxeClassNames<IsMulti>>,
|
||||
): LuxeClassNames<IsMulti> {
|
||||
return {
|
||||
control: (state) => CONTROL_CLASS_NAMES(state.isFocused),
|
||||
option: (state) => OPTION_CLASS_NAMES(state.isSelected, state.isFocused),
|
||||
valueContainer: () => '!gap-1 !px-3 !py-0.5',
|
||||
placeholder: () => '!text-[13px] !text-ivory/35',
|
||||
singleValue: () => '!text-[13px] !text-ivory',
|
||||
input: () => '!text-[13px] !text-ivory',
|
||||
indicatorsContainer: () => '!text-ivory/40',
|
||||
dropdownIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||
clearIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||
indicatorSeparator: () => '!bg-ivory/15',
|
||||
menu: () => '!mt-1 !rounded-none !border !border-ivory/10 !bg-char !shadow-lift',
|
||||
menuList: () => '!p-1',
|
||||
noOptionsMessage: () => '!text-xs !text-ivory/45',
|
||||
loadingMessage: () => '!text-xs !text-ivory/45',
|
||||
multiValue: () => '!m-0.5 !rounded-full !border !border-copper/40 !bg-copper/15 !py-0.5 !pe-0 !ps-2',
|
||||
multiValueLabel: () => '!text-xs !text-copper',
|
||||
...extras,
|
||||
}
|
||||
}
|
||||
|
||||
/** دکمه ضربدر حذف برچسب انتخابشده — همیشه واضح و قابل کلیک */
|
||||
function TagRemoveButton({ innerProps }: MultiValueRemoveProps<LuxeOption, true>) {
|
||||
return (
|
||||
<div
|
||||
{...innerProps}
|
||||
role="button"
|
||||
aria-label="حذف برچسب"
|
||||
title="حذف برچسب"
|
||||
className="mx-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-copper transition-colors duration-150 hover:bg-copper hover:text-char"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const portalTarget = (): HTMLElement | undefined =>
|
||||
typeof document === 'undefined' ? undefined : document.body
|
||||
|
||||
const uniqueOptions = (values: string[]): LuxeOption[] =>
|
||||
[...new Set(values.filter(Boolean))].map((item) => ({ label: item, value: item }))
|
||||
|
||||
type LuxeSelectProps = {
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
isClearable?: boolean
|
||||
isDisabled?: boolean
|
||||
inputId?: string
|
||||
}
|
||||
|
||||
/** react-select تکی با تم تیره — گزینه فعلی حتی اگر در لیست نباشد حفظ میشود */
|
||||
export function LuxeSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder = 'انتخاب کنید…',
|
||||
isClearable = true,
|
||||
isDisabled = false,
|
||||
inputId,
|
||||
}: LuxeSelectProps) {
|
||||
const selectOptions = useMemo(
|
||||
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||
[options, value],
|
||||
)
|
||||
|
||||
return (
|
||||
<Select<LuxeOption, false>
|
||||
inputId={inputId}
|
||||
classNamePrefix="luxe"
|
||||
options={selectOptions}
|
||||
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||
onChange={(option) => onChange(option?.value ?? '')}
|
||||
placeholder={placeholder}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
classNames={makeClassNames<false>()}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type LuxeCreatableSelectProps = {
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
inputId?: string
|
||||
/** متن گزینه «ساخت مقدار جدید» در فهرست بازشو */
|
||||
createLabel?: (input: string) => string
|
||||
}
|
||||
|
||||
/** react-select تکی و قابل ساخت — کاربر با کلیک روی گزینه ساخت، مقدار تازه میسازد */
|
||||
export function LuxeCreatableSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder = 'انتخاب یا نوشتن مورد جدید…',
|
||||
inputId,
|
||||
createLabel = (input) => `افزودن «${input}»`,
|
||||
}: LuxeCreatableSelectProps) {
|
||||
const selectOptions = useMemo(
|
||||
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||
[options, value],
|
||||
)
|
||||
|
||||
return (
|
||||
<CreatableSelect<LuxeOption, false>
|
||||
inputId={inputId}
|
||||
classNamePrefix="luxe"
|
||||
options={selectOptions}
|
||||
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||
onChange={(option) => onChange(option?.value?.trim() ?? '')}
|
||||
placeholder={placeholder}
|
||||
formatCreateLabel={(input) => createLabel(input)}
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
classNames={makeClassNames<false>()}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
isClearable
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type CategoryOption = { label: string; value: string }
|
||||
|
||||
/** کلید نگهداری دستههای ساختهشده در مرورگر */
|
||||
const CREATED_CATEGORIES_KEY = 'aramesh_article_categories'
|
||||
|
||||
/** دستههایی که ادمین ساخته است — برای مقالههای بعدی هم در فهرست میمانند */
|
||||
function readCreatedCategories(): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CREATED_CATEGORIES_KEY)
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : []
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === 'string')
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function storeCreatedCategory(category: string): void {
|
||||
try {
|
||||
const current = readCreatedCategories()
|
||||
if (current.includes(category)) return
|
||||
window.localStorage.setItem(CREATED_CATEGORIES_KEY, JSON.stringify([...current, category]))
|
||||
} catch {
|
||||
/* حافظه مرورگر در دسترس نیست — بیصدا رد شو */
|
||||
}
|
||||
}
|
||||
|
||||
type CategorySelectProps = {
|
||||
/** مقدار انتخابشده — یک شیء {value,label} یا null */
|
||||
value: CategoryOption | null
|
||||
/** تغییر انتخاب — null یعنی فقط انتخاب پاک شده است (دکمه X) */
|
||||
onChange: (option: CategoryOption | null) => void
|
||||
/** دستههای پایه */
|
||||
options: string[]
|
||||
/** متن گزینه «ساخت دسته جدید» در فهرست بازشو */
|
||||
createLabel?: (input: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخابگر تکمقداری دستهبندی (react-select Creatable).
|
||||
* - isMulti=false و isClearable: دکمه X فقط انتخاب جاری را پاک میکند و گزینهها دستنخورده میمانند.
|
||||
* - ساخت دسته جدید: به انتهای فهرست گزینهها اضافه و برای مقاله جاری انتخاب میشود و
|
||||
* در همین مرورگر برای مقالههای بعدی نگه داشته میشود.
|
||||
*/
|
||||
export function CategorySelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
createLabel = (input) => `ایجاد دسته جدید: ${input}`,
|
||||
}: CategorySelectProps) {
|
||||
const [createdOptions, setCreatedOptions] = useState<string[]>(() => readCreatedCategories())
|
||||
|
||||
// گزینههای پایه + دستههای ساختهشده (همیشه در انتهای فهرست)
|
||||
const selectOptions = useMemo(
|
||||
() => uniqueOptions([...options, ...createdOptions.filter((item) => !options.includes(item))]),
|
||||
[options, createdOptions],
|
||||
)
|
||||
|
||||
/** ساخت دسته جدید: افزودن به انتهای فهرست + انتخاب خودکار برای مقاله جاری */
|
||||
const handleCreate = (input: string) => {
|
||||
const category = input.trim()
|
||||
if (!category) return
|
||||
setCreatedOptions((previous) =>
|
||||
previous.includes(category) ? previous : [...previous, category],
|
||||
)
|
||||
storeCreatedCategory(category)
|
||||
onChange({ label: category, value: category })
|
||||
}
|
||||
|
||||
return (
|
||||
<CreatableSelect<CategoryOption, false>
|
||||
classNamePrefix="luxe"
|
||||
isMulti={false}
|
||||
isClearable
|
||||
options={selectOptions}
|
||||
value={value}
|
||||
onChange={(option) => onChange(option ? { label: option.label, value: option.value } : null)}
|
||||
onCreateOption={handleCreate}
|
||||
formatCreateLabel={(input) => createLabel(input)}
|
||||
placeholder="انتخاب یا نوشتن دسته جدید…"
|
||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||
classNames={makeClassNames<false>()}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type LuxeTagsSelectProps = {
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
placeholder?: string
|
||||
}/** هشتگها با react-select قابل ساخت — هر برچسب دکمه ضربدر حذف مستقل دارد */
|
||||
export function LuxeTagsSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'هشتگ بنویسید و Enter بزنید…',
|
||||
}: LuxeTagsSelectProps) {
|
||||
const options = useMemo(() => uniqueOptions(value), [value])
|
||||
|
||||
/** پاکسازی و حذف تکراریها هنگام افزودن یا حذف برچسب */
|
||||
const applyTags = (next: readonly LuxeOption[]) => {
|
||||
const tags = next.map((option) => option.value.trim()).filter(Boolean)
|
||||
onChange([...new Set(tags)])
|
||||
}
|
||||
|
||||
return (
|
||||
<CreatableSelect<LuxeOption, true>
|
||||
classNamePrefix="luxe"
|
||||
isMulti
|
||||
options={options}
|
||||
value={options}
|
||||
onChange={applyTags}
|
||||
placeholder={placeholder}
|
||||
formatCreateLabel={(input) => `افزودن هشتگ «${input}»`}
|
||||
noOptionsMessage={() => 'هشتگی یافت نشد'}
|
||||
closeMenuOnSelect={false}
|
||||
components={{ MultiValueRemove: TagRemoveButton }}
|
||||
classNames={makeClassNames<true>({ multiValueRemove: () => '!bg-transparent' })}
|
||||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||
menuPosition="fixed"
|
||||
menuPortalTarget={portalTarget()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
||||
import type { ArticleBodyDoc } from '../../data/site'
|
||||
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
|
||||
|
||||
export type SeoInput = {
|
||||
title: string
|
||||
@@ -14,6 +13,78 @@ export type SeoInput = {
|
||||
type Status = 'good' | 'ok' | 'bad'
|
||||
type Check = { label: string; status: Status; hint: string }
|
||||
|
||||
// ---------- استخراج متن/ساختار از سند TipTap ----------
|
||||
|
||||
function docToText(body: SeoInput['body']): string {
|
||||
if (Array.isArray(body)) return body.join(' ')
|
||||
const out: string[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; text?: string; content?: unknown[] }
|
||||
if (n.type === 'text' && n.text) out.push(n.text)
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out.join(' ')
|
||||
}
|
||||
|
||||
function docHeadings(body: SeoInput['body']): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const out: string[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; text?: string; attrs?: { level?: number }; content?: unknown[] }
|
||||
if (n.type === 'heading' && n.attrs?.level === 2) {
|
||||
const texts: string[] = []
|
||||
const collect = (inner: unknown) => {
|
||||
if (!inner || typeof inner !== 'object') return
|
||||
const t = inner as { type?: string; text?: string; content?: unknown[] }
|
||||
if (t.type === 'text' && t.text) texts.push(t.text)
|
||||
for (const c of t.content ?? []) collect(c)
|
||||
}
|
||||
collect(n)
|
||||
out.push(texts.join(''))
|
||||
}
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out
|
||||
}
|
||||
|
||||
function docImages(body: SeoInput['body']): { src: string; alt: string }[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const out: { src: string; alt: string }[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; attrs?: { src?: string; alt?: string }; content?: unknown[] }
|
||||
if (n.type === 'image') out.push({ src: n.attrs?.src ?? '', alt: n.attrs?.alt ?? '' })
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out
|
||||
}
|
||||
|
||||
function docLinks(body: SeoInput['body']): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const out: string[] = []
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const n = node as { type?: string; marks?: { type?: string; attrs?: { href?: string } }[]; content?: unknown[] }
|
||||
for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) out.push(m.attrs.href)
|
||||
for (const c of n.content ?? []) walk(c)
|
||||
}
|
||||
walk(body)
|
||||
return out
|
||||
}
|
||||
|
||||
function firstParagraph(body: SeoInput['body']): string {
|
||||
if (Array.isArray(body)) return body[0] ?? ''
|
||||
const p = (body as { content?: { type?: string; content?: unknown[] }[] }).content?.find(
|
||||
(c) => c.type === 'paragraph',
|
||||
)
|
||||
return docToText({ type: 'doc', content: p ? [p] : [] } as never)
|
||||
}
|
||||
|
||||
// ---------- محاسبه بررسیها ----------
|
||||
|
||||
export function useSeoChecks(input: SeoInput): Check[] {
|
||||
@@ -46,7 +117,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
||||
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
|
||||
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
|
||||
)
|
||||
const h2s = docHeadings(input.body, 2)
|
||||
const h2s = docHeadings(input.body)
|
||||
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
|
||||
else
|
||||
checks.push(
|
||||
@@ -69,7 +140,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
||||
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
|
||||
|
||||
// تیتر H2
|
||||
const h2s = docHeadings(input.body, 2)
|
||||
const h2s = docHeadings(input.body)
|
||||
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
|
||||
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخشبندی کنید.' })
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { setBlockType, splitBlock } from '@tiptap/pm/commands'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import type { ResolvedPos } from '@tiptap/pm/model'
|
||||
|
||||
/** قالب بلوک جاری — پاراگراف یا یکی از سطوح تیتر */
|
||||
export type BlockFormat = 'paragraph' | 1 | 2 | 3
|
||||
|
||||
type LineBounds = {
|
||||
/** ابتدای بلوک جاری */
|
||||
start: number
|
||||
/** پایان بلوک جاری */
|
||||
end: number
|
||||
/** ابتدای خط جاری (پس از نزدیکترین شکست نرم قبل از نشانگر) */
|
||||
lineStart: number
|
||||
/** پایان خط جاری (نزدیکترین شکست نرم پس از نشانگر) */
|
||||
lineEnd: number
|
||||
/** موقعیت شکست نرم قبل از خط جاری — null یعنی خط، ابتدای بلوک است */
|
||||
before: number | null
|
||||
/** موقعیت شکست نرم بعد از خط جاری — null یعنی خط، انتهای بلوک است */
|
||||
after: number | null
|
||||
}
|
||||
|
||||
/** مرزهای خط جاری داخل بلوک جاری — خطوطی که با شکست نرم (<br>) از هم جدا شدهاند */
|
||||
function lineBounds($from: ResolvedPos): LineBounds {
|
||||
const start = $from.start()
|
||||
const end = $from.end()
|
||||
let lineStart = start
|
||||
let lineEnd = end
|
||||
let before: number | null = null
|
||||
let after: number | null = null
|
||||
|
||||
$from.parent.forEach((node, offset) => {
|
||||
if (node.type.name !== 'hardBreak') return
|
||||
const pos = start + offset
|
||||
if (pos < $from.pos) {
|
||||
lineStart = pos + 1
|
||||
before = pos
|
||||
} else if (after === null) {
|
||||
lineEnd = pos
|
||||
after = pos
|
||||
}
|
||||
})
|
||||
|
||||
return { start, end, lineStart, lineEnd, before, after }
|
||||
}
|
||||
|
||||
/**
|
||||
* جدا کردن خط جاری از خطوط چسبیده با شکست نرم (Shift+Enter یا متن چسباندهشده از Word).
|
||||
* بدون این جداسازی، عوض کردن قالب یک خط، قالب همه خطوط همان بلوک را با هم عوض میکند.
|
||||
* اگر گزینش از خط عبور کند (قالببندی چندخطیِ عمدی) دستنخورده میماند.
|
||||
*/
|
||||
function isolateCurrentLine(view: EditorView): boolean {
|
||||
const { state } = view
|
||||
const { selection } = state
|
||||
const bounds = lineBounds(selection.$from)
|
||||
|
||||
if (bounds.before === null && bounds.after === null) return false
|
||||
|
||||
const selectionInsideLine =
|
||||
selection.empty ||
|
||||
(selection.from >= bounds.lineStart && selection.to <= bounds.lineEnd)
|
||||
if (!selectionInsideLine) return false
|
||||
|
||||
const { tr } = state
|
||||
if (bounds.after !== null) {
|
||||
tr.delete(bounds.after, bounds.after + 1)
|
||||
tr.split(bounds.after)
|
||||
}
|
||||
if (bounds.before !== null) {
|
||||
tr.delete(bounds.before, bounds.before + 1)
|
||||
tr.split(bounds.before)
|
||||
}
|
||||
if (!tr.docChanged) return false
|
||||
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* قالببندی بلوک جاری (تیتر ۱/۲/۳ یا پاراگراف) — فقط همان خطی که نشانگر در آن است
|
||||
* و به خطوط بالا و پایین کاری ندارد. کلیک دوباره روی همان سطح تیتر، آن را به
|
||||
* پاراگراف برمیگرداند (رفتار toggle).
|
||||
*/
|
||||
export function applyBlockFormat(view: EditorView, format: BlockFormat): boolean {
|
||||
view.focus()
|
||||
isolateCurrentLine(view)
|
||||
|
||||
const { state } = view
|
||||
const paragraph = state.schema.nodes.paragraph
|
||||
const heading = state.schema.nodes.heading
|
||||
if (!paragraph || !heading) return false
|
||||
|
||||
if (format === 'paragraph') return setBlockType(paragraph)(state, view.dispatch, view)
|
||||
|
||||
const current = state.selection.$from.parent
|
||||
const sameHeading = current.type === heading && current.attrs.level === format
|
||||
return sameHeading
|
||||
? setBlockType(paragraph)(state, view.dispatch, view)
|
||||
: setBlockType(heading, { level: format })(state, view.dispatch, view)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter داخل تیتر: بلوک تازه پاراگراف میشود (نه تیتر تازه) تا قالب تیتر به خط
|
||||
* بعدی سرایت نکند و مرز بلوکها تمیز بماند.
|
||||
* این هندلر پیش از keymap پیشفرض اجرا میشود و فقط همین حالت را میگیرد.
|
||||
*/
|
||||
export function handleHeadingEnter(view: EditorView, event: KeyboardEvent): boolean {
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { state } = view
|
||||
const { selection } = state
|
||||
const paragraph = state.schema.nodes.paragraph
|
||||
if (!paragraph || !selection.empty || selection.$from.parent.type.name !== 'heading') return false
|
||||
|
||||
// خط تازه با splitBlock ساخته و بعد به پاراگراف تبدیل میشود
|
||||
if (!splitBlock(state, view.dispatch, view)) return false
|
||||
setBlockType(paragraph)(view.state, view.dispatch, view)
|
||||
return true
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type SmartTextFieldOptions = {
|
||||
/** مقدار ذخیرهشده در بیرون (منبع حقیقت) */
|
||||
value: string
|
||||
/** پیشنهاد خودکار — وقتی کاربر چیزی ننوشته، همین متن نمایش داده و ذخیره میشود */
|
||||
fallback?: string
|
||||
/** آخرین گزینه وقتی پیشنهادی هم وجود ندارد (مثل «Photo 1») */
|
||||
hardFallback?: string
|
||||
/** ذخیره مقدار نهایی — فقط وقتی نسبت به مقدار ذخیرهشده تغییر کرده باشد */
|
||||
onCommit?: (next: string) => void | Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* فیلد متنی با «پیشفرض هوشمند» برای Alt/Title تصویر:
|
||||
* - متن کاربر تا لحظه ذخیره فقط در حالت محلی میماند؛ در حین تایپ نه trim میشود و نه از
|
||||
* بیرون بازنویسی میشود، پس فاصله انتهای متن و جای نشانگر حفظ میشود.
|
||||
* - با فوکوس، پیشفرض خودکار پاک میشود تا کاربر بلافاصله تایپ کند.
|
||||
* - با بلور، اگر فیلد خالی بماند پیشفرض خودکار برمیگردد و مقدار نهایی ذخیره میشود.
|
||||
*/
|
||||
export function useSmartTextField({
|
||||
value,
|
||||
fallback = '',
|
||||
hardFallback = '',
|
||||
onCommit,
|
||||
}: SmartTextFieldOptions) {
|
||||
const [text, setText] = useState(value)
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
// تازهترین مقادیر در ref نگه داشته میشوند تا هندلرها به closure قدیمی وابسته نباشند
|
||||
const textRef = useRef(text)
|
||||
textRef.current = text
|
||||
const focusedRef = useRef(focused)
|
||||
focusedRef.current = focused
|
||||
const fallbackRef = useRef(fallback)
|
||||
fallbackRef.current = fallback
|
||||
const hardFallbackRef = useRef(hardFallback)
|
||||
hardFallbackRef.current = hardFallback
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
|
||||
// همگامسازی با مقدار ذخیرهشده از بیرون (بارگذاری مقاله، بهروزرسانی خوشبینانه)
|
||||
// اما هرگز در حین ویرایش کاربر — وگرنه نشانگر میپرد و فاصلهها میخورند
|
||||
useEffect(() => {
|
||||
if (focusedRef.current) return
|
||||
setText(value)
|
||||
}, [value])
|
||||
|
||||
/** مقدار نهایی فیلد: متن کاربر → پیشنهاد خودکار → پیشفرض ثابت */
|
||||
const resolve = useCallback((): string => {
|
||||
const current = textRef.current.trim()
|
||||
if (current) return current
|
||||
return fallbackRef.current.trim() || hardFallbackRef.current.trim()
|
||||
}, [])
|
||||
|
||||
const commit = useCallback(async (): Promise<void> => {
|
||||
const next = resolve()
|
||||
setText(next)
|
||||
if (onCommit && next !== valueRef.current.trim()) await onCommit(next)
|
||||
}, [onCommit, resolve])
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
setFocused(true)
|
||||
// پیشفرض خودکار متن واقعی نیست؛ با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند
|
||||
const current = textRef.current.trim()
|
||||
const auto = fallbackRef.current.trim() || hardFallbackRef.current.trim()
|
||||
if (!current || current === auto) setText('')
|
||||
}, [])
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
setFocused(false)
|
||||
void commit()
|
||||
}, [commit])
|
||||
|
||||
const onChange = useCallback((next: string) => {
|
||||
setText(next)
|
||||
}, [])
|
||||
|
||||
/** متن نمایشی — در نبود متن کاربر، پیشنهاد خودکار نشان داده میشود */
|
||||
const display = focused ? text : text !== '' ? text : fallback
|
||||
|
||||
return { display, focused, onFocus, onBlur, onChange, commit, resolve }
|
||||
}
|
||||
@@ -36,36 +36,33 @@ export function FeaturedProjects() {
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
|
||||
|
||||
{/* دکمههای فیلتر مینیمال و شیشهای (بدون کادر) */}
|
||||
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||
{FILTERS.map((filter, index) => {
|
||||
{/* دکمههای فیلتر کپسولی شیشهای مات */}
|
||||
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||
{FILTERS.map((filter) => {
|
||||
const isSelected = status === filter.key
|
||||
return (
|
||||
<React.Fragment key={filter.key}>
|
||||
{/* خط جداکننده بین دکمهها */}
|
||||
{index > 0 && (
|
||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => setStatus(filter.key)}
|
||||
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${
|
||||
isSelected
|
||||
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
|
||||
: 'bg-transparent text-ivory/60 hover:bg-white/5 hover:text-ivory'
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 transition-colors duration-300 ease-out">
|
||||
<InlineEdit
|
||||
value={filter.label}
|
||||
onSave={(v) => saveStatusLabel(filter.key, v)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => setStatus(filter.key)}
|
||||
className={`group relative flex items-center justify-center overflow-hidden rounded-full px-7 py-3 text-sm transition-all duration-300 ease-out active:scale-95 cursor-pointer select-none ${
|
||||
isSelected
|
||||
? 'border border-copper bg-copper font-bold text-char shadow-[0_0_25px_rgba(197,143,92,0.45),inset_0_1px_1px_rgba(255,255,255,0.4)]'
|
||||
: 'border border-white/15 bg-white/[0.04] font-light text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)] hover:border-copper/60 hover:bg-white/[0.08] hover:text-ivory hover:shadow-[0_0_20px_rgba(197,143,92,0.2)]'
|
||||
}`}
|
||||
>
|
||||
{/* خط بازتاب نوری لبه شیشه */}
|
||||
<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 className="relative z-10 transition-colors duration-300 ease-out">
|
||||
<InlineEdit
|
||||
value={filter.label}
|
||||
onSave={(v) => saveStatusLabel(filter.key, v)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -97,7 +94,7 @@ export function FeaturedProjects() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* دکمه مشاهده همه پروژهها */}
|
||||
{/* دکمه مشاهده همه پروژهها: کپسولی شیشهای با گوی آیکون متحرک */}
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
|
||||
@@ -40,45 +40,41 @@ export function Testimonials() {
|
||||
</motion.blockquote>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-12 flex items-center justify-center gap-5">
|
||||
<div className="mt-10 flex items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(1)}
|
||||
aria-label="نظر قبلی"
|
||||
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5 transition-transform duration-300 ease-lux group-hover:translate-x-0.5" />
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
||||
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex gap-2" role="tablist" aria-label="انتخاب نظر">
|
||||
{testimonials.map((t, i) =>
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`نظر ${t.name}`}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-1.5 transition-[width,background-color] duration-200 ease-lux ${
|
||||
i === index ? 'w-8 bg-copper' : 'w-3 bg-ivory/25'}`
|
||||
} />
|
||||
|
||||
<div className="flex items-center gap-2" role="tablist" aria-label="انتخاب نظر">
|
||||
{testimonials.map((t, i) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`نظر ${t.name}`}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-2 rounded-full transition-all duration-300 ease-lux ${
|
||||
i === index
|
||||
? 'w-8 bg-copper shadow-[0_0_12px_rgba(197,143,92,0.6)]'
|
||||
: 'w-2.5 bg-ivory/20 hover:bg-ivory/40'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(-1)}
|
||||
aria-label="نظر بعدی"
|
||||
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5 transition-transform duration-300 ease-lux group-hover:-translate-x-0.5" />
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
|
||||
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute right-[-5px] top-8 z-10 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
|
||||
className={`absolute right-[-5px] top-6 z-10 h-2.5 w-2.5 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
|
||||
active
|
||||
? 'bg-copper shadow-[0_0_15px_rgba(197,143,92,0.6),0_0_0_6px_rgba(213,135,76,0.15)]'
|
||||
: 'bg-ivory/20'
|
||||
? 'bg-copper shadow-[0_0_0_5px_rgba(213,135,76,0.22)]'
|
||||
: 'bg-ivory/25'
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -33,13 +33,10 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
transition={{ duration: 0.55, ease }}
|
||||
>
|
||||
<EditableItem resource="timeline" item={item}>
|
||||
<div className="group relative overflow-hidden rounded-2xl border border-ivory/10 bg-ink/40 p-6 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-1.5 hover:border-copper/50 hover:bg-ink/60 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] md:p-8">
|
||||
{/* لایه نوری ظریف که در هاور روشن میشود */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
||||
|
||||
<span className="relative z-10 text-sm font-medium tracking-widest text-copper">{item.year}</span>
|
||||
<h3 className="relative z-10 mt-3 text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-white">{item.title}</h3>
|
||||
<p className="relative z-10 mt-3 text-sm font-light leading-relaxed text-ivory/60 transition-colors duration-300 group-hover:text-ivory/80">{item.desc}</p>
|
||||
<div className="border border-ivory/10 bg-ink p-6 md:p-7">
|
||||
<span className="text-sm font-light text-copper">{item.year}</span>
|
||||
<h3 className="mt-3 text-lg font-bold text-ivory">{item.title}</h3>
|
||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/60">{item.desc}</p>
|
||||
</div>
|
||||
</EditableItem>
|
||||
</motion.div>
|
||||
@@ -69,14 +66,14 @@ export function Timeline() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={ref} className="relative mx-auto mt-20 max-w-4xl">
|
||||
<div ref={ref} className="relative mx-auto max-w-4xl">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-px bg-ivory/10 md:right-1/2"
|
||||
className="absolute inset-y-0 right-0 w-px bg-ivory/12 md:right-1/2"
|
||||
/>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-px origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2"
|
||||
className="absolute inset-y-0 right-0 w-px origin-top bg-copper md:right-1/2"
|
||||
style={{ scaleY }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* ثابتهای مشترک رابط کاربری — تنها منبع مقادیر پیشفرض (بدون متن پراکنده در کامپوننتها).
|
||||
*/
|
||||
|
||||
/** عنوان پیشفرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */
|
||||
export const DEFAULT_IMAGE_TITLE = 'Photo 1'
|
||||
|
||||
/** متن جایگزین پیشفرض تصویرهای داخل متن (وقتی نام فایل چیزی به دست نمیدهد) */
|
||||
export const DEFAULT_IMAGE_ALT = 'تصویر مقاله'
|
||||
|
||||
/** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار میگیرد */
|
||||
export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص'
|
||||
|
||||
/** سرعت مطالعه مبنای محاسبه زمان مطالعه — واژه در دقیقه */
|
||||
export const WORDS_PER_MINUTE = 200
|
||||
|
||||
/** برچسب زمان مطالعه در خروجی */
|
||||
export const READING_TIME_LABEL = 'دقیقه مطالعه'
|
||||
@@ -40,14 +40,4 @@ export type Article = {
|
||||
status: ArticleStatus;
|
||||
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
||||
seo_keyphrase: string;
|
||||
/** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */
|
||||
meta_description?: string;
|
||||
/** متن جایگزین تصویر شاخص (Alt) */
|
||||
image_alt?: string;
|
||||
/** عنوان تصویر شاخص (Title) */
|
||||
image_title?: string;
|
||||
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده میشود */
|
||||
author?: string;
|
||||
/** هشتگها/برچسبهای مقاله */
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* @import url() FONT IMPORTS MUST ALWAYS BE AT THE VERY TOP OF THIS FILE, ABOVE THE TAILWIND IMPORTS — DO NOT DELETE THIS COMMENT */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&display=swap');
|
||||
|
||||
/* CRITICAL: THE FOLLOWING TAILWIND IMPORTS MUST NEVER BE DELETED OR REORDERED — DO NOT DELETE THIS COMMENT */
|
||||
@import 'tailwindcss/base';
|
||||
@@ -9,54 +10,15 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-Light.woff2') format('woff2'),
|
||||
url('/fonts/pinar/Pinar-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-Regular.woff2') format('woff2'),
|
||||
url('/fonts/pinar/Pinar-Regular.ttf') format('truetype');
|
||||
src: url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar.woff2') format('woff2'),
|
||||
url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-Bold.woff2') format('woff2'),
|
||||
url('/fonts/pinar/Pinar-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-ExtraBold.woff2') format('woff2'),
|
||||
url('/fonts/pinar/Pinar-ExtraBold.ttf') format('truetype');
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-Black.woff2') format('woff2'),
|
||||
url('/fonts/pinar/Pinar-Black.ttf') format('truetype');
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Vazirmatn';
|
||||
src: url('/fonts/Vazirmatn-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Vazirmatn';
|
||||
src: url('/fonts/Vazirmatn-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Vazirmatn';
|
||||
src: url('/fonts/Vazirmatn-Bold.woff2') format('woff2');
|
||||
src: url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar-Bold.woff2') format('woff2'),
|
||||
url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar-Bold.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -83,7 +45,6 @@ body {
|
||||
.article-body {
|
||||
line-height: 2.4;
|
||||
word-wrap: break-word;
|
||||
font-size: 14px; /* متن اصلی ۱۴ تنظیم شد */
|
||||
}
|
||||
.article-body p {
|
||||
margin-top: 1.75rem;
|
||||
@@ -92,11 +53,6 @@ body {
|
||||
.article-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* ---------- تایپوگرافی مقاله ----------
|
||||
راستچین (فارسی): پایه ۱۴ / تیتر۱ ۱۸ / تیتر۲ ۱۶ / تیتر۳ ۱۴
|
||||
چپچین (انگلیسی): پایه ۱۲ / تیتر۱ ۱۶ / تیتر۲ ۱۴ / تیتر۳ ۱۲
|
||||
جهت با data-dir روی پوسته ویرایشگر یا مقاله تعیین میشود. */
|
||||
.article-body h1,
|
||||
.article-body h2,
|
||||
.article-body h3 {
|
||||
color: #f5f3ef;
|
||||
@@ -104,75 +60,15 @@ body {
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.9;
|
||||
/* فاصله از هدر چسبان هنگام پرش لنگرهای فهرست مطالب */
|
||||
scroll-margin-top: 7rem;
|
||||
}
|
||||
.article-body h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
.article-body h2 {
|
||||
font-size: 16px;
|
||||
font-size: 1.6rem;
|
||||
padding-right: 0.85rem;
|
||||
border-right: 3px solid #d5874c;
|
||||
}
|
||||
.article-body h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
[data-dir='rtl'] .article-body {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
[data-dir='ltr'] .article-body {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
[data-dir='ltr'] .article-body h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
[data-dir='ltr'] .article-body h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
[data-dir='ltr'] .article-body h3 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---------- تایپوگرافی دوزبانه (فارسی و لاتین در یک خط) ----------
|
||||
متن لاتین همیشه ۲ پیکسل کوچکتر از متن فارسیِ همان بلوک است:
|
||||
تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲.
|
||||
calc(1em - 2px) نسبت به اندازه همان بلوک حساب میشود، پس قاعده در همه سطحها درست
|
||||
میماند و خط پایه هم جابهجا نمیشود (line-height نسبی است).
|
||||
در مقاله انگلیسی (data-dir=ltr) پایه همان اندازه کوچکتر است و واژههای فارسی
|
||||
۲ پیکسل بزرگتر میشوند تا تفاضل همیشه ۲ پیکسل بماند. */
|
||||
.article-body .latin,
|
||||
.article-body .persian,
|
||||
.article-body .english-text {
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.article-body .latin,
|
||||
.article-body .english-text {
|
||||
font-size: calc(1em - 2px);
|
||||
}
|
||||
|
||||
.article-body .persian {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
[data-dir='ltr'] .article-body .latin,
|
||||
[data-dir='ltr'] .article-body .english-text {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
[data-dir='ltr'] .article-body .persian {
|
||||
font-size: calc(1em + 2px);
|
||||
}
|
||||
.article-body span[data-small="true"] {
|
||||
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
|
||||
}
|
||||
/* مارک «متن انگلیسی» ویرایشگر — چپبهراست و ۱۲ پیکسل بدون بههمزدن جهت متن فارسی */
|
||||
.english-text {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
/* اندازه از قواعد دوزبانه بالا میآید: ۲ پیکسل کوچکتر از متن فارسی همان بلوک */
|
||||
font-size: 1.25rem;
|
||||
color: #e8c9a8;
|
||||
}
|
||||
.article-body ul,
|
||||
.article-body ol {
|
||||
@@ -216,35 +112,6 @@ body {
|
||||
border: 1px solid rgba(245, 243, 239, 0.1);
|
||||
object-fit: cover;
|
||||
}
|
||||
/* ---------- تصویر کپشندار: <figure><img><figcaption> ---------- */
|
||||
.article-body figure {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.article-body figure img {
|
||||
margin-top: 0;
|
||||
}
|
||||
.article-body figcaption {
|
||||
margin-top: 0.7rem;
|
||||
color: rgba(245, 243, 239, 0.5);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.9;
|
||||
text-align: center;
|
||||
}
|
||||
/* ویرایشگر: کپشن ورودی و دکمه حذف زیر تصویر */
|
||||
[data-wysiwyg] .article-figure-node {
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
[data-wysiwyg] .article-figure__caption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
text-align: right;
|
||||
}
|
||||
[data-wysiwyg] .article-figure-node.is-selected .article-figure {
|
||||
outline: 1px dashed rgba(213, 135, 76, 0.8);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.article-body strong {
|
||||
color: #f5f3ef;
|
||||
font-weight: 700;
|
||||
@@ -272,78 +139,3 @@ body {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ---------- تقویم شمسی (react-multi-date-picker) — تم تیره با لهجه مسی ---------- */
|
||||
.aramesh-datepicker.rmdp-wrapper {
|
||||
width: max-content;
|
||||
border: 1px solid rgba(245, 243, 239, 0.12);
|
||||
border-radius: 0;
|
||||
background-color: #1e1a16;
|
||||
color: #f5f3ef;
|
||||
box-shadow: 0 24px 50px -18px rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
.aramesh-datepicker .rmdp-calendar {
|
||||
padding: 8px;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-header-values {
|
||||
color: #d5874c;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-arrow {
|
||||
border-color: #d5874c;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-arrow-container {
|
||||
box-shadow: none;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-arrow-container:hover {
|
||||
background-color: rgba(213, 135, 76, 0.2);
|
||||
box-shadow: none;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-week-day {
|
||||
color: rgba(245, 243, 239, 0.45);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day {
|
||||
color: rgba(245, 243, 239, 0.85);
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day span {
|
||||
border-radius: 0;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day:hover:not(.rmdp-disabled):not(.rmdp-selected) span {
|
||||
background-color: rgba(213, 135, 76, 0.16);
|
||||
color: #d5874c;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day.rmdp-selected span:not(.highlight) {
|
||||
background-color: #d5874c;
|
||||
color: #1e1a16;
|
||||
font-weight: 700;
|
||||
box-shadow: none;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day.rmdp-today span {
|
||||
background-color: rgba(213, 135, 76, 0.25);
|
||||
color: #f5f3ef;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day.rmdp-deactive {
|
||||
color: rgba(245, 243, 239, 0.22);
|
||||
}
|
||||
.aramesh-datepicker .rmdp-day.rmdp-disabled {
|
||||
color: rgba(245, 243, 239, 0.12);
|
||||
}
|
||||
.aramesh-datepicker .rmdp-month-picker,
|
||||
.aramesh-datepicker .rmdp-year-picker {
|
||||
background-color: #1e1a16;
|
||||
border-radius: 0;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-panel {
|
||||
background-color: #1e1a16;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-panel-body li {
|
||||
background-color: rgba(213, 135, 76, 0.15);
|
||||
color: #f5f3ef;
|
||||
box-shadow: none;
|
||||
}
|
||||
.aramesh-datepicker .rmdp-action-button {
|
||||
color: #d5874c;
|
||||
}
|
||||
@@ -1,40 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react';
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer';
|
||||
import { Reveal } from '../components/Reveal';
|
||||
import { ButtonLink } from '../components/Button';
|
||||
import { ArticleCard } from '../components/ArticleCard';
|
||||
import { ArticleMeta } from '../components/ArticleMeta';
|
||||
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
||||
import { Newsletter } from '../components/home/Newsletter';
|
||||
import { useSite } from '../api';
|
||||
import { applySeo } from '../seo';
|
||||
import { useEffect } from 'react';
|
||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||
import { docToText } from '../utils/tiptapDoc';
|
||||
import { detectDirection } from '../utils/direction';
|
||||
import { wrapScriptRuns } from '../utils/bidiText';
|
||||
import type { Article } from '../data/site';
|
||||
|
||||
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
||||
function renderBody(
|
||||
article: Article,
|
||||
dir: 'rtl' | 'ltr',
|
||||
): { html: string | null; toc: TocItem[]; legacy: string[] } {
|
||||
/** بدنه مقاله را به HTML تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
||||
function renderBody(article: Article): { html: string | null; legacy: string[] } {
|
||||
const body = article.body as unknown;
|
||||
if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
|
||||
if (Array.isArray(body)) return { html: null, legacy: body };
|
||||
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
|
||||
try {
|
||||
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never });
|
||||
// تیترها شناسه لنگر میگیرند و متن لاتین/فارسی برای قاعده «۲ پیکسل کوچکتر» بستهبندی میشود
|
||||
const { items, html: htmlWithIds } = extractToc(html, (root) => wrapScriptRuns(root, dir));
|
||||
return { html: htmlWithIds, toc: items, legacy: [] };
|
||||
return {
|
||||
html: renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }),
|
||||
legacy: [],
|
||||
};
|
||||
} catch {
|
||||
return { html: null, toc: [], legacy: [] };
|
||||
return { html: null, legacy: [] };
|
||||
}
|
||||
}
|
||||
return { html: null, toc: [], legacy: [] };
|
||||
return { html: null, legacy: [] };
|
||||
}
|
||||
|
||||
export function ArticleDetail() {
|
||||
@@ -46,9 +38,8 @@ export function ArticleDetail() {
|
||||
useEffect(() => {
|
||||
if (!article) return;
|
||||
applySeo({
|
||||
title: `${article.title} | ${brand.name}`,
|
||||
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
||||
description: article.meta_description || article.excerpt,
|
||||
title: `${article.title} | ${brand}`,
|
||||
description: article.excerpt,
|
||||
path: `/blog/${article.slug}`,
|
||||
image: article.image,
|
||||
type: 'article',
|
||||
@@ -56,10 +47,10 @@ export function ArticleDetail() {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article.title,
|
||||
description: article.meta_description || article.excerpt,
|
||||
description: article.excerpt,
|
||||
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
||||
inLanguage: 'fa',
|
||||
author: { '@type': 'Organization', name: article.author?.trim() || brand },
|
||||
author: { '@type': 'Organization', name: brand },
|
||||
publisher: { '@type': 'Organization', name: brand },
|
||||
mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href,
|
||||
},
|
||||
@@ -79,29 +70,13 @@ export function ArticleDetail() {
|
||||
|
||||
}
|
||||
|
||||
const dir = detectDirection(article.title, article.excerpt, docToText(article.body));
|
||||
const { html, toc, legacy } = renderBody(article, dir);
|
||||
const { html, legacy } = renderBody(article);
|
||||
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
|
||||
|
||||
/**
|
||||
* جهت غالب متن مقاله — بدنه (با data-dir، طبق قاعده CSS مقاله) و سمت فهرست مطالب
|
||||
* از همین پیروی میکنند: فارسی راستبهچپ (فهرست سمت راست)، انگلیسی چپبهراست (فهرست سمت چپ).
|
||||
*/
|
||||
const hasToc = toc.length > 0;
|
||||
// در چیدمان راستبهچپ ستون شماره ۱ سمت راست است؛ پس ستونها صریح شماره میخورند
|
||||
const gridColumns = hasToc ? 'lg:grid-cols-[17rem_minmax(0,1fr)]' : '';
|
||||
const bodyColumns = 'lg:col-start-2';
|
||||
const tocColumns = 'lg:col-start-1';
|
||||
|
||||
return (
|
||||
<>
|
||||
<section dir={dir} className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
|
||||
<img
|
||||
src={article.image}
|
||||
alt={article.image_alt || article.title}
|
||||
title={article.image_title || undefined}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
|
||||
<img src={article.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
@@ -113,58 +88,44 @@ export function ArticleDetail() {
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
</Link>
|
||||
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<Link to="/blog" className="transition-colors hover:text-copper">
|
||||
وبلاگ
|
||||
</Link>
|
||||
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<span className="text-copper">{article.category}</span>
|
||||
</nav>
|
||||
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
|
||||
|
||||
{/* نویسنده، تاریخ جلالی و زمان مطالعه */}
|
||||
<ArticleMeta article={article} className="mt-7" />
|
||||
<div className="mt-6 flex items-center gap-5 text-xs font-light text-ivory/60">
|
||||
<span>{article.date}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
{article.read}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article className="bg-ink py-20 lg:py-28">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<div
|
||||
dir={dir}
|
||||
data-dir={dir}
|
||||
className={`mx-auto grid max-w-5xl gap-12 lg:gap-14 ${gridColumns}`}
|
||||
>
|
||||
<div className={`min-w-0 lg:row-start-1 ${hasToc ? bodyColumns : ''}`}>
|
||||
<Reveal>
|
||||
<p className="border-s-2 border-copper ps-6 text-lg font-light leading-10 text-ivory/80">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<Reveal>
|
||||
<p className="border-r-2 border-copper pr-6 text-lg font-light leading-10 text-ivory/80">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
</Reveal>
|
||||
{html ? (
|
||||
<Reveal delay={0.05}>
|
||||
<div
|
||||
className="article-body mt-4 text-base font-light text-ivory/70"
|
||||
dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</Reveal>
|
||||
) : (
|
||||
legacy.map((paragraph, i) =>
|
||||
<Reveal key={i} delay={0.03 * i}>
|
||||
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
|
||||
</Reveal>
|
||||
{html ? (
|
||||
<Reveal delay={0.05}>
|
||||
<div
|
||||
className="article-body mt-4 text-base font-light text-ivory/70"
|
||||
dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</Reveal>
|
||||
) : (
|
||||
legacy.map((paragraph, i) =>
|
||||
<Reveal key={i} delay={0.03 * i}>
|
||||
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
|
||||
</Reveal>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* فهرست مطالب — در دسکتاپ کنار متن میچسبد و همراه مقاله پایین میآید */}
|
||||
{hasToc && (
|
||||
<aside
|
||||
dir={dir}
|
||||
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
|
||||
>
|
||||
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
@@ -7,9 +7,6 @@ import { ArticleCard } from '../components/ArticleCard'
|
||||
import { Newsletter } from '../components/home/Newsletter'
|
||||
import { useSite } from '../api'
|
||||
import { EditableItem } from '../admin/Editable'
|
||||
import { useAdmin } from '../admin/AdminContext'
|
||||
import { formatJalali } from '../utils/date'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
|
||||
function DraftBadge({ className = '' }: { className?: string }) {
|
||||
@@ -24,19 +21,6 @@ function DraftBadge({ className = '' }: { className?: string }) {
|
||||
|
||||
export function Blog() {
|
||||
const { articles } = useSite()
|
||||
const { isEditMode } = useAdmin()
|
||||
|
||||
// دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده میشود
|
||||
const writeButton = isEditMode ? (
|
||||
<Link
|
||||
to="/admin/write"
|
||||
data-admin-link
|
||||
className="inline-flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
نوشتن مقاله جدید
|
||||
</Link>
|
||||
) : null
|
||||
|
||||
// حالت خالی — وبلاگ بدون مقاله نباید کرش کند
|
||||
if (articles.length === 0) {
|
||||
@@ -49,7 +33,14 @@ export function Blog() {
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||
بهزودی نخستین نوشتههای ما اینجا منتشر میشود.
|
||||
</p>
|
||||
<div className="mt-8 flex justify-center">{writeButton}</div>
|
||||
<Link
|
||||
to="/admin/write"
|
||||
data-admin-link
|
||||
className="mt-8 inline-flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
نوشتن مقاله جدید
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<Newsletter />
|
||||
@@ -65,7 +56,16 @@ export function Blog() {
|
||||
|
||||
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<div className="mb-10 flex justify-start">{writeButton}</div>
|
||||
<div className="mb-10 flex justify-start">
|
||||
<Link
|
||||
to="/admin/write"
|
||||
data-admin-link
|
||||
className="flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
نوشتن مقاله جدید
|
||||
</Link>
|
||||
</div>
|
||||
<Reveal>
|
||||
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
|
||||
<Link
|
||||
@@ -93,10 +93,10 @@ export function Blog() {
|
||||
<p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p>
|
||||
<div className="mt-10 flex items-center justify-between border-t border-ivory/10 pt-8 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-4">
|
||||
<span>{formatJalali(featured.date)}</span>
|
||||
<span>{featured.date}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
{toPersianDigits(featured.read)}
|
||||
{featured.read}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-copper">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AddItemButton, EditableItem } from '../admin/Editable'
|
||||
|
||||
export function Investment() {
|
||||
const { investment_models } = useSite()
|
||||
const heading = useSectionHeading('investment_models')
|
||||
const heading = useSectionHeading('investment_models')
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -22,29 +22,26 @@ export function Investment() {
|
||||
{investment_models.map((model, i) => (
|
||||
<Reveal key={model.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="investment_models" item={model} className="h-full">
|
||||
<article className="group relative flex h-full flex-col overflow-hidden rounded-2xl border border-ivory/10 bg-char/40 p-10 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:bg-char/70 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] transform-gpu">
|
||||
{/* لایه نوری ظریف که در زمان هاور روشن میشود */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
||||
|
||||
<p className="relative z-10 text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p>
|
||||
<article className="group flex h-full flex-col border border-ivory/10 bg-char p-10 transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||
<p className="text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p>
|
||||
|
||||
<h3 className="relative z-10 mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||
{model.title}
|
||||
</h3>
|
||||
<h3 className="mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
||||
{model.title}
|
||||
</h3>
|
||||
|
||||
<p className="relative z-10 mt-5 text-sm font-light leading-9 text-ivory/65">{model.desc}</p>
|
||||
<p className="mt-5 text-sm font-light leading-9 text-ivory/65">{model.desc}</p>
|
||||
|
||||
<div className="relative z-10 mt-auto pt-10">
|
||||
<ul className="space-y-4 border-t border-ivory/10 pt-8 transition-colors duration-500 group-hover:border-copper/20">
|
||||
{model.points.map((point) => (
|
||||
<li key={point} className="flex items-start gap-3 text-sm font-light leading-8 text-ivory/70 transition-colors duration-300 group-hover:text-ivory">
|
||||
<span className="mt-3.5 h-1.5 w-1.5 shrink-0 rounded-full bg-copper/60 transition-all duration-300 ease-lux group-hover:scale-125 group-hover:bg-copper group-hover:shadow-[0_0_10px_rgba(197,143,92,0.8)]" aria-hidden="true" />
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
<div className="mt-auto pt-10">
|
||||
<ul className="space-y-4 border-t border-ivory/10 pt-8">
|
||||
{model.points.map((point) => (
|
||||
<li key={point} className="flex items-start gap-3 text-sm font-light leading-8 text-ivory/65">
|
||||
<span className="mt-3.5 h-1 w-1 shrink-0 rounded-full bg-copper transition-transform duration-300 ease-out group-hover:scale-[1.5]" aria-hidden="true" />
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
@@ -57,4 +54,4 @@ export function Investment() {
|
||||
<ContactSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function ProjectDetail() {
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
applySeo({
|
||||
title: `${project.title} | ${brand.name}`,
|
||||
title: `${project.title} | ${brand}`,
|
||||
description: project.summary,
|
||||
path: `/projects/${project.slug}`,
|
||||
image: project.image,
|
||||
|
||||
@@ -26,9 +26,8 @@ export function Projects() {
|
||||
const doneLabel = useStatusLabel('done');
|
||||
const progressLabel = useStatusLabel('progress');
|
||||
const FILTERS: {key: ProjectStatus;label: string;}[] = [
|
||||
{ key: 'done', label: doneLabel },
|
||||
{ key: 'progress', label: progressLabel }
|
||||
];
|
||||
{ key: 'done', label: doneLabel },
|
||||
{ key: 'progress', label: progressLabel }];
|
||||
const [status, setStatus] = useState<ProjectStatus>('done');
|
||||
const list = projects.filter((p) => p.status === status);
|
||||
|
||||
@@ -36,51 +35,47 @@ export function Projects() {
|
||||
<>
|
||||
<PageHero pageKey="projects" />
|
||||
|
||||
|
||||
<section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژهها">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
|
||||
{/* دکمههای فیلتر مینیمال و شیشهای (وسطچین و بدون کادر) */}
|
||||
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||
{FILTERS.map((filter, index) => {
|
||||
const isSelected = status === filter.key;
|
||||
return (
|
||||
<React.Fragment key={filter.key}>
|
||||
{/* خط جداکننده بین دکمهها */}
|
||||
{index > 0 && (
|
||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => setStatus(filter.key)}
|
||||
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${
|
||||
isSelected
|
||||
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
|
||||
: 'bg-transparent text-ivory/60 hover:bg-white/5 hover:text-ivory'
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 transition-colors duration-300 ease-out">
|
||||
<InlineEdit
|
||||
value={filter.label}
|
||||
onSave={(v) => saveStatusLabel(filter.key, v)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-wrap gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||
{FILTERS.map((filter) =>
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === filter.key}
|
||||
onClick={() => setStatus(filter.key)}
|
||||
className={`group relative overflow-hidden border px-7 py-3 text-sm transition-colors duration-300 ease-lux ${
|
||||
status === filter.key ?
|
||||
'border-copper bg-copper font-bold text-char' :
|
||||
'border-ivory/15 font-light text-ivory/65'}`
|
||||
}>
|
||||
|
||||
{status !== filter.key &&
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-lux group-hover:translate-y-0" />
|
||||
|
||||
}
|
||||
<span className="relative z-10 transition-colors duration-300 ease-lux group-hover:text-char">
|
||||
<InlineEdit
|
||||
value={filter.label}
|
||||
onSave={(v) => saveStatusLabel(filter.key, v)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||
{list.map((project, i) => (
|
||||
<Reveal key={project.id} delay={0.05 * i} className="h-full">
|
||||
{list.map((project, i) =>
|
||||
<Reveal key={project.id} delay={0.05 * i} className="h-full">
|
||||
<EditableItem resource="projects" item={project} className="h-full">
|
||||
<ProjectCard project={project} imageClass="h-80" />
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
)}
|
||||
<AddItemButton resource="projects" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,6 +83,6 @@ export function Projects() {
|
||||
|
||||
<Gallery />
|
||||
<ContactSection />
|
||||
</>
|
||||
);
|
||||
</>);
|
||||
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* دوزبانهسازی خودکار متن مقاله — قاعده: متن لاتین همیشه «۲ پیکسل کوچکتر» از متن
|
||||
* فارسیِ همان بلوک است (تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲) و در
|
||||
* مقاله انگلیسی برعکس، واژههای فارسی ۲ پیکسل بزرگتر میشوند.
|
||||
*
|
||||
* اجرای غیرغالب متن با یک span جهتدار جدا میشود تا نه جهت متن بههم بریزد
|
||||
* (unicode-bidi: isolate) و نه خط پایه جابهجا شود؛ اندازه در CSS با
|
||||
* calc(1em - 2px) نسبت به همان بلوک حساب میشود.
|
||||
*/
|
||||
|
||||
/** اجرای متن لاتین — حروف/ارقام لاتین بههمراه فاصله و نشانههای داخلی */
|
||||
const LATIN_PATTERN =
|
||||
"[A-Za-z0-9\\u00c0-\\u024f]+(?:[ \\t.,:;'\"()&/+%\\u2013\\u2014-]+[A-Za-z0-9\\u00c0-\\u024f]+)*"
|
||||
|
||||
/** اجرای متن فارسی/عربی — با نیمفاصله و ارقام فارسی */
|
||||
const RTL_PATTERN = '[\\u0590-\\u08ff\\u200c]+(?:[ \\t\\u200c]+[\\u0590-\\u08ff\\u200c]+)*'
|
||||
|
||||
/** کلاس span متن لاتین و متن فارسی — همینها در CSS اندازه ۲ پیکسل تفاضلی میگیرند */
|
||||
export const LATIN_CLASS = 'latin'
|
||||
export const RTL_CLASS = 'persian'
|
||||
|
||||
/** عناصری که متنشان دستنخورده میماند (کد، مارکهای قدیمی و اجرای بستهبندیشده) */
|
||||
const SKIP_SELECTOR = 'code, pre, kbd, samp, script, style, .english-text, [data-small], .latin, .persian'
|
||||
|
||||
/** همه اجراهای غیرغالب یک متن — با بازه نسبت به ابتدای همان متن */
|
||||
export function findScriptRuns(text: string, dir: 'rtl' | 'ltr'): { from: number; to: number }[] {
|
||||
const scan = new RegExp(dir === 'rtl' ? LATIN_PATTERN : RTL_PATTERN, 'g')
|
||||
const runs: { from: number; to: number }[] = []
|
||||
|
||||
for (const match of text.matchAll(scan)) {
|
||||
const from = match.index ?? 0
|
||||
runs.push({ from, to: from + match[0].length })
|
||||
}
|
||||
|
||||
return runs
|
||||
}
|
||||
|
||||
/**
|
||||
* بستهبندی اجرای غیرغالب همه گرههای متنی داخل یک ریشه HTML.
|
||||
* در مقاله فارسی اجرای لاتین و در مقاله انگلیسی اجرای فارسی جدا میشود.
|
||||
*/
|
||||
export function wrapScriptRuns(root: HTMLElement, dir: 'rtl' | 'ltr'): void {
|
||||
const document = root.ownerDocument
|
||||
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
|
||||
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
|
||||
|
||||
const texts: Text[] = []
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const text = node as Text
|
||||
if (!text.parentElement || text.parentElement.closest(SKIP_SELECTOR)) continue
|
||||
if (!text.data.trim()) continue
|
||||
if (findScriptRuns(text.data, dir).length) texts.push(text)
|
||||
}
|
||||
|
||||
for (const text of texts) {
|
||||
const value = text.data
|
||||
const fragment = document.createDocumentFragment()
|
||||
let cursor = 0
|
||||
|
||||
for (const run of findScriptRuns(value, dir)) {
|
||||
if (run.from > cursor) fragment.append(value.slice(cursor, run.from))
|
||||
const span = document.createElement('span')
|
||||
span.className = className
|
||||
span.setAttribute('dir', runDir)
|
||||
span.textContent = value.slice(run.from, run.to)
|
||||
fragment.append(span)
|
||||
cursor = run.to
|
||||
}
|
||||
|
||||
if (cursor < value.length) fragment.append(value.slice(cursor))
|
||||
text.replaceWith(fragment)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import DateObject from 'react-date-object'
|
||||
import gregorian from 'react-date-object/calendars/gregorian'
|
||||
import persian from 'react-date-object/calendars/persian'
|
||||
import persian_fa from 'react-date-object/locales/persian_fa'
|
||||
|
||||
/** قالب رسمی نمایش تاریخ جلالی — تقویم شمسی و اعداد فارسی */
|
||||
export const JALALI_DATE_FORMATTER = new Intl.DateTimeFormat('fa-IR', {
|
||||
dateStyle: 'medium',
|
||||
calendar: 'persian',
|
||||
})
|
||||
|
||||
/** ساعت به وقت جلالی (فارسی) */
|
||||
export const JALALI_TIME_FORMATTER = new Intl.DateTimeFormat('fa-IR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T\s].*)?$/
|
||||
|
||||
/**
|
||||
* نمایش هر مقدار تاریخ به شکل جلالی (مثل «۲۲ شهریور ۱۴۰۵»).
|
||||
* رشتههایی که تاریخ نیستند (مثل تاریخ شمسی دستنویس) دستنخورده برمیگردند.
|
||||
*/
|
||||
export function formatJalali(value?: string | number | Date | null, fallback = ''): string {
|
||||
if (value === null || value === undefined || value === '') return fallback
|
||||
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? fallback : JALALI_DATE_FORMATTER.format(value)
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
const fromNumber = new Date(value)
|
||||
return Number.isNaN(fromNumber.getTime()) ? fallback : JALALI_DATE_FORMATTER.format(fromNumber)
|
||||
}
|
||||
|
||||
const raw = String(value).trim()
|
||||
if (!raw) return fallback
|
||||
|
||||
if (ISO_DATE.test(raw)) {
|
||||
const parsed = new Date(raw)
|
||||
if (!Number.isNaN(parsed.getTime())) return JALALI_DATE_FORMATTER.format(parsed)
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
/** رشته ISO میلادی ذخیرهشده (YYYY-MM-DD) → DateObject شمسی برای انتخابگر تاریخ */
|
||||
export function toJalaliDateObject(value?: string | null): DateObject | null {
|
||||
const raw = (value ?? '').trim()
|
||||
if (!raw) return null
|
||||
|
||||
if (ISO_DATE.test(raw)) {
|
||||
const parsed = new DateObject({ date: raw })
|
||||
if (parsed.isValid) return parsed.convert(persian, persian_fa)
|
||||
}
|
||||
|
||||
// تاریخهای شمسی دستنویس مثل ۱۴۰۳/۰۵/۱۲
|
||||
const manual = new DateObject({ date: raw, calendar: persian, locale: persian_fa })
|
||||
return manual.isValid ? manual : null
|
||||
}
|
||||
|
||||
/** انتخاب انتخابگر تاریخ → رشته ISO میلادی برای ذخیره در دیتابیس */
|
||||
export function toISODate(value?: DateObject | Date | null): string {
|
||||
if (!value) return ''
|
||||
const asObject = value instanceof Date ? new DateObject({ date: value }) : value
|
||||
const gregorianDate = asObject.convert(gregorian)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${gregorianDate.year}-${pad(gregorianDate.month.number)}-${pad(gregorianDate.day)}`
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
|
||||
|
||||
/** تبدیل ارقام لاتین به ارقام فارسی — برای نمایش یکدست فارسی */
|
||||
export function toPersianDigits(value: string | number): string {
|
||||
return String(value).replace(/[0-9]/g, (digit) => PERSIAN_DIGITS[Number(digit)])
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* تشخیص جهت غالب متن — پایه چیدمان مقاله و فهرست مطالب.
|
||||
* سایت فارسی است؛ اما مقالهای که متنش لاتین است باید چپبهراست خوانده شود.
|
||||
*/
|
||||
|
||||
/** خطهای راستبهچپ: عبری، عربی و فارسی */
|
||||
const RTL_CHARS = /[\u0590-\u05ff\u0600-\u06ff\u0700-\u074f\ufb50-\ufdff\ufe70-\ufeff]/
|
||||
/** ارقام فارسی/عربی — در شمارش جهت بیطرفاند */
|
||||
const RTL_DIGITS = /[\u0660-\u0669\u06f0-\u06f9]/
|
||||
/** حروف لاتین (با علامتها) */
|
||||
const LTR_CHARS = /[A-Za-z\u00c0-\u024f]/
|
||||
|
||||
/**
|
||||
* جهت غالب نمونههای متن: فارسی/عربی → rtl و لاتین → ltr.
|
||||
* در حالت مساوی (مثلاً متن کوتاه یا فقط رقم) پیشفرض سایت، راستبهچپ، برمیگردد.
|
||||
*/
|
||||
export function detectDirection(...samples: (string | null | undefined)[]): 'rtl' | 'ltr' {
|
||||
let rtl = 0
|
||||
let ltr = 0
|
||||
|
||||
for (const sample of samples) {
|
||||
for (const char of sample ?? '') {
|
||||
if (RTL_DIGITS.test(char)) continue
|
||||
if (RTL_CHARS.test(char)) rtl += 1
|
||||
else if (LTR_CHARS.test(char)) ltr += 1
|
||||
}
|
||||
}
|
||||
|
||||
return rtl >= ltr ? 'rtl' : 'ltr'
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/** پروتکلها و الگوهای مجاز برای نشانی پیوند — ویرایشگر و مودال لینک هر دو از اینها استفاده میکنند */
|
||||
const ALLOWED_URI = /^(https?:\/\/|\/|#|mailto:|tel:)/i
|
||||
const BARE_DOMAIN = /^[\w-]+(\.[\w-]+)+([/?#].*)?$/i
|
||||
/** نشانی کامل بدون فاصله — برای تشخیص متن چسباندهشده یا تایپشده */
|
||||
const URL_LIKE = /^(https?:\/\/\S+|www\.\S+|[a-z0-9-]+(\.[a-z0-9-]+)+(\/\S*)?)$/i
|
||||
|
||||
/** آیا متن یک نشانی کامل است؟ (برای بازکردن خودکار مودال لینک) */
|
||||
export function isUrlLike(value: string): boolean {
|
||||
return URL_LIKE.test(value.trim())
|
||||
}
|
||||
|
||||
/** آیا نشانی برای درج در لینک مجاز است؟ */
|
||||
export function isAllowedHref(uri: string): boolean {
|
||||
return ALLOWED_URI.test(uri.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* نرمالسازی نشانی ورودی کاربر:
|
||||
* «example.com» → «https://example.com» و ورودی خالی/نامعتبر → null
|
||||
*/
|
||||
export function normalizeHref(raw: string): string | null {
|
||||
const value = raw.trim()
|
||||
if (!value) return null
|
||||
if (isAllowedHref(value)) return value
|
||||
if (BARE_DOMAIN.test(value)) return `https://${value}`
|
||||
return null
|
||||
}
|
||||
|
||||
export type LinkOptions = {
|
||||
/** باز شدن در تب جدید */
|
||||
newTab: boolean
|
||||
/** عدم انتقال اعتبار سئو (nofollow) */
|
||||
nofollow: boolean
|
||||
}
|
||||
|
||||
/** ساخت مقدار rel بر اساس گزینههای انتخابی کاربر */
|
||||
export function buildLinkRel({ newTab, nofollow }: LinkOptions): string | null {
|
||||
const tokens: string[] = []
|
||||
if (newTab) tokens.push('noopener', 'noreferrer')
|
||||
if (nofollow) tokens.push('nofollow')
|
||||
return tokens.length ? tokens.join(' ') : null
|
||||
}
|
||||
|
||||
/** خواندن گزینه nofollow از مقدار rel لینک موجود */
|
||||
export function parseLinkRel(rel?: string | null): LinkOptions {
|
||||
const tokens = (rel ?? '').split(/\s+/).filter(Boolean)
|
||||
return { newTab: tokens.includes('noopener') || tokens.includes('noreferrer'), nofollow: tokens.includes('nofollow') }
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { READING_TIME_LABEL, WORDS_PER_MINUTE } from '../data/constants'
|
||||
import { toPersianDigits } from './digits'
|
||||
import { docToText } from './tiptapDoc'
|
||||
|
||||
export { WORDS_PER_MINUTE, READING_TIME_LABEL }
|
||||
|
||||
/** شمارش واژههای متن سند (بدون شمارش نویسههای قالببندی) */
|
||||
export function countWords(body: unknown): number {
|
||||
return docToText(body)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
}
|
||||
|
||||
/** زمان مطالعه بر حسب دقیقه — سقف واژهها تقسیم بر سرعت مطالعه، حداقل یک دقیقه */
|
||||
export function readingTimeMinutes(body: unknown): number {
|
||||
return Math.max(1, Math.ceil(countWords(body) / WORDS_PER_MINUTE))
|
||||
}
|
||||
|
||||
/** زمان مطالعه آماده نمایش با ارقام فارسی — مثل «۵ دقیقه مطالعه» */
|
||||
export function formatReadingTime(body: unknown): string {
|
||||
return `${toPersianDigits(readingTimeMinutes(body))} ${READING_TIME_LABEL}`
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن برای فهرست مطالب.
|
||||
* حروف و اعداد فارسی/عربی و لاتین در شناسه میمانند (در URL بهصورت percent-encoded
|
||||
* نوشته میشوند و در همه مرورگرها کار میکنند)؛ فقط نشانهها و فاصلهها به خط تیره
|
||||
* تبدیل میشوند. نیمفاصله و نویسههای نامرئی جهت هم حذف میشوند تا تیترهای تکراری
|
||||
* شناسه یکتا بگیرند.
|
||||
*/
|
||||
export function slugifyFa(input: string): string {
|
||||
return (input ?? '')
|
||||
.toString()
|
||||
.normalize('NFC')
|
||||
// نیمفاصله، اتصالدهنده و نویسههای نامرئی جهت → حذف
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
|
||||
// یکسانسازی حروف عربی با معادل فارسی
|
||||
.replace(/[يى]/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(/[ۀة]/g, 'ه')
|
||||
.toLowerCase()
|
||||
// فقط حروف/اعداد یونیکد (فارسی، عربی، لاتین و...) و فاصله/خط تیره بماند
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
/** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی میگیرند (مثل about، about-2) */
|
||||
export function uniqueSlug(base: string, used: Map<string, number>): string {
|
||||
const key = base || 'section'
|
||||
const count = used.get(key) ?? 0
|
||||
used.set(key, count + 1)
|
||||
return count === 0 ? key : `${key}-${count + 1}`
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* کمکتابعهای مشترک سند TipTap — استخراج متن و ساختار از JSON مقاله.
|
||||
* هم چکلیست سئو و هم محاسبه زمان مطالعه از همینجا استفاده میکنند (DRY).
|
||||
*/
|
||||
|
||||
import type { ArticleBodyDoc } from '../data/site'
|
||||
|
||||
type DocNode = {
|
||||
type?: string
|
||||
text?: string
|
||||
attrs?: Record<string, unknown>
|
||||
marks?: { type?: string; attrs?: Record<string, unknown> }[]
|
||||
content?: unknown[]
|
||||
}
|
||||
|
||||
function walk(node: unknown, visit: (node: DocNode) => void): void {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const current = node as DocNode
|
||||
visit(current)
|
||||
for (const child of current.content ?? []) walk(child, visit)
|
||||
}
|
||||
|
||||
/** متن یک سند TipTap (یا آرایه پاراگرافهای قدیمی) */
|
||||
export function docToText(body: unknown): string {
|
||||
if (Array.isArray(body)) return body.join(' ')
|
||||
const parts: string[] = []
|
||||
walk(body, (node) => {
|
||||
if (node.type === 'text' && node.text) parts.push(node.text)
|
||||
})
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/** متن تیترهای یک سطح مشخص (پیشفرض H2) */
|
||||
export function docHeadings(body: unknown, level = 2): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const headings: string[] = []
|
||||
walk(body, (node) => {
|
||||
if (node.type !== 'heading' || node.attrs?.level !== level) return
|
||||
const texts: string[] = []
|
||||
walk(node, (inner) => {
|
||||
if (inner.type === 'text' && inner.text) texts.push(inner.text)
|
||||
})
|
||||
headings.push(texts.join(''))
|
||||
})
|
||||
return headings
|
||||
}
|
||||
|
||||
/** تصاویر سند با متن جایگزین */
|
||||
export function docImages(body: unknown): { src: string; alt: string }[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const images: { src: string; alt: string }[] = []
|
||||
walk(body, (node) => {
|
||||
if (node.type !== 'image') return
|
||||
images.push({ src: String(node.attrs?.src ?? ''), alt: String(node.attrs?.alt ?? '') })
|
||||
})
|
||||
return images
|
||||
}
|
||||
|
||||
/** نشانی همه پیوندهای سند */
|
||||
export function docLinks(body: unknown): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
const links: string[] = []
|
||||
walk(body, (node) => {
|
||||
for (const mark of node.marks ?? []) {
|
||||
if (mark.type === 'link' && mark.attrs?.href) links.push(String(mark.attrs.href))
|
||||
}
|
||||
})
|
||||
return links
|
||||
}
|
||||
|
||||
/** متن نخستین پاراگراف — برای بررسی کلمه کلیدی در ابتدای متن */
|
||||
export function firstParagraph(body: unknown): string {
|
||||
if (Array.isArray(body)) return body[0] ?? ''
|
||||
const content = (body as { content?: { type?: string }[] } | null)?.content
|
||||
const paragraph = content?.find((child) => child?.type === 'paragraph')
|
||||
return paragraph ? docToText({ type: 'doc', content: [paragraph] }) : ''
|
||||
}
|
||||
|
||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as ArticleBodyDoc
|
||||
|
||||
/**
|
||||
* تبدیل بدنه ذخیرهشده مقاله به سند TipTap برای بارگذاری در ویرایشگر.
|
||||
* ردیفهای قدیمی متن را بهصورت آرایه پاراگراف نگه داشتهاند و ممکن است رشته JSON هم
|
||||
* برگردد؛ هر دو شکل به سند تبدیل میشوند تا هنگام ویرایش، متن قبلی از دست نرود.
|
||||
*/
|
||||
export function toArticleBodyDoc(body: unknown): ArticleBodyDoc {
|
||||
if (Array.isArray(body)) {
|
||||
const paragraphs = body
|
||||
.map((paragraph) => String(paragraph ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.map((text) => ({ type: 'paragraph', content: [{ type: 'text', text }] }))
|
||||
return paragraphs.length ? { type: 'doc', content: paragraphs } : { ...EMPTY_DOC }
|
||||
}
|
||||
if (typeof body === 'string') {
|
||||
try {
|
||||
return toArticleBodyDoc(JSON.parse(body))
|
||||
} catch {
|
||||
return { ...EMPTY_DOC }
|
||||
}
|
||||
}
|
||||
if (body && typeof body === 'object') return body as ArticleBodyDoc
|
||||
return { ...EMPTY_DOC }
|
||||
}
|
||||
@@ -9,7 +9,6 @@ export default {
|
||||
ivory: '#F5F3EF',
|
||||
},
|
||||
fontFamily: {
|
||||
// فونت اصلی سایت Pinar است (سلفهاست در public/fonts/pinar — ببینید src/index.css)
|
||||
pinar: ['Pinar', 'Vazirmatn', 'Tahoma', 'sans-serif'],
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -5,12 +5,16 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174, // سرور توسعه فرانتاند روی این پورت اجرا میشود تا تداخل نکند
|
||||
strictPort: true, // جلوگیری از تغییر خودکار پورت
|
||||
proxy: {
|
||||
// در حالت توسعه، درخواستهای API و Media به Traefik (پورت ۵۱۷۳) فرستاده میشوند
|
||||
'/api': 'http://localhost:5173',
|
||||
'/media': 'http://localhost:5173',
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/media': {
|
||||
target: 'http://localhost:9000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/media/, '/aramesh-media'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,177 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""انتقال تصاویرِ CDN خارجی (مثل i.postimg.cc) به MinIO و اصلاح مسیر در دیتابیس.
|
||||
|
||||
مشکل: لوگو، هیرو، گالری و پسزمینه صفحات همگی به i.postimg.cc (در دسترس نیست از
|
||||
این ماشین) یا cdn.housedesign.ai اشاره میکنند — مثل فونت، هر بار از اینترنت دانلود میشوند.
|
||||
|
||||
راهحل: این اسکریپت (روی ماشینی که به اینترنتِ آن CDN در دسترس است) هر تصویر را
|
||||
یکبار میگیرد، در مینآیو آپلود میکند و مقدارِ فیلد دیتابیس را به /media/... تغییر
|
||||
میدهد. بار اجرا میکنید فقط تصویرهایی منتقل میشوند که هنوز مسیرشان /media نیست.
|
||||
|
||||
اجرا (از بکاند — نیاز به python-dotenv برای خواندن .env ندارد؛ تنظیمات از متغیر محیط):
|
||||
cd backend
|
||||
pip install requests
|
||||
export VITE_API_URL=http://localhost:5173 # یا آدرس واقعی
|
||||
python scripts/migrate-media.py
|
||||
python scripts/migrate-media.py --force # حتی مواردِ /media شده را هم دوباره بررسی کن
|
||||
|
||||
خروجی:
|
||||
✓ 4 تصویر منتقل شد. (هر ردیف تازهثبت شده)
|
||||
✗ 12 تصویر هنوز در منابع منبع نرسید (دانلود ناموفق) — گزارش در انتها.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
|
||||
API = os.environ.get("VITE_API_URL", "http://localhost:5173")
|
||||
USER = os.environ.get("ADMIN_USERNAME", "admin")
|
||||
PASS = os.environ.get("ADMIN_PASSWORD", "aramesh123")
|
||||
FORCE = "--force" in sys.argv
|
||||
|
||||
print(f"API بکاند: {API}")
|
||||
|
||||
|
||||
def login() -> str:
|
||||
r = requests.post(f"{API}/api/auth/login", json={"username": USER, "password": PASS}, timeout=20)
|
||||
r.raise_for_status()
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def snapshot(token: str) -> dict:
|
||||
r = requests.get(f"{API}/api/site", headers={"Authorization": f"Bearer {token}"}, timeout=20)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def upload(token: str, content: bytes, ext: str) -> str:
|
||||
"""آپلود بایتها با فرمتِ دیدِ هدر (بکاند فقط اینها را میپذیرد)."""
|
||||
mime = {
|
||||
"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
|
||||
"webp": "image/webp", "gif": "image/gif", "avif": "image/avif",
|
||||
}
|
||||
ext = ext if ext in mime else "png"
|
||||
filename = f"{uuid.uuid4().hex}.{ext}"
|
||||
r = requests.post(
|
||||
f"{API}/api/admin/upload",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
files={"file": (filename, BytesIO(content), mime[ext])},
|
||||
timeout=90,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["url"] # e.g. /media/2026/09/<hash>.png
|
||||
|
||||
|
||||
def patch(token: str, resource: str, item_id: int, field: str, value: str) -> None:
|
||||
r = requests.patch(
|
||||
f"{API}/api/admin/{resource}/{item_id}",
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
json={field: value},
|
||||
timeout=20,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
|
||||
def sniff(prefix: bytes) -> str:
|
||||
"""فرمت واقعی از هدر بایت — تا HTML/خطا به دیتابیس نرود و فرمت درست آپلود شود."""
|
||||
if prefix.startswith(b"\xff\xd8\xff"):
|
||||
return "jpg"
|
||||
if prefix.startswith(b"\x89PNG"):
|
||||
return "png"
|
||||
if prefix.startswith(b"GIF8"):
|
||||
return "gif"
|
||||
if prefix.startswith(b"RIFF") and b"WEBP" in prefix[:12]:
|
||||
return "webp"
|
||||
if prefix.startswith(b"\x00\x00\x00") and b"ftyp" in prefix[:16]:
|
||||
return "avif"
|
||||
return ""
|
||||
|
||||
|
||||
def pick_bytes(url: str) -> tuple[bytes | None, str]:
|
||||
"""دانلود؛ فقط تصویرِ واقعی → (بایت، فرمت). غیرتصویر/خطا → (None, '')."""
|
||||
try:
|
||||
r = requests.get(url, timeout=45, allow_redirects=True)
|
||||
if r.status_code != 200:
|
||||
return None, ""
|
||||
body = r.content
|
||||
ext = sniff(body[:16])
|
||||
if not ext:
|
||||
return None, "" # HTML یا خطا — نه تصویر
|
||||
return body, ext
|
||||
except requests.RequestException:
|
||||
return None, ""
|
||||
|
||||
|
||||
# ردیفها: (resource, item_id, field, current_url) — بهدست از /api/site
|
||||
def collect(site: dict) -> list[tuple[str, int, str, str]]:
|
||||
rows: list[tuple[str, int, str, str]] = []
|
||||
|
||||
b = site.get("brand") or {}
|
||||
if b.get("id"):
|
||||
for col in ("logo", "hero", "contact_bg"):
|
||||
if col in b and b[col].startswith("http"):
|
||||
rows.append(("brand", b["id"], col, b[col]))
|
||||
|
||||
for g in site.get("gallery") or []:
|
||||
if g.get("src", "").startswith("http"):
|
||||
rows.append(("gallery", g["id"], "src", g["src"]))
|
||||
|
||||
for s in site.get("section_headings") or []:
|
||||
if s.get("image", "").startswith("http"):
|
||||
rows.append(("section-headings", s["id"], "image", s["image"]))
|
||||
|
||||
for p in site.get("page_heroes") or []:
|
||||
if p.get("image", "").startswith("http"):
|
||||
rows.append(("page-heroes", p["id"], "image", p["image"]))
|
||||
|
||||
ba = site.get("before_after") or {}
|
||||
if ba.get("id"):
|
||||
for col in ("before", "after"):
|
||||
if ba[col].startswith("http"):
|
||||
rows.append(("before-after", ba["id"], col, ba[col]))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> None:
|
||||
token = login()
|
||||
site = snapshot(token)
|
||||
rows = collect(site)
|
||||
# collect فقط URLهایی را برمیگرداند که با http شروع میشوند (نسبی /media را نادیده میگیرد)
|
||||
if not rows:
|
||||
print("هیچ URL خارجی برای انتقال نیست — همه در MinIO هستند.")
|
||||
return
|
||||
|
||||
ok, failed = [], []
|
||||
for resource, item_id, field, url in rows:
|
||||
print(f" {resource}:{item_id}.{field}\n <- {url}")
|
||||
data, ext = pick_bytes(url)
|
||||
if data is None:
|
||||
failed.append((resource, item_id, field, url))
|
||||
print(" ✗ دانلود ناموفق (منبع پاسخگو نبود یا HTML/خطا داد)")
|
||||
continue
|
||||
new_url = upload(token, data, ext)
|
||||
print(f" ✓ -> {new_url}")
|
||||
try:
|
||||
patch(token, resource, item_id, field, new_url)
|
||||
except requests.RequestException as e:
|
||||
# آپلود شد ولی DB نگرفت — سخت: رجوع بعدی ماندنی
|
||||
print(f" ! آپلود شد ولی دیتابیس آپدیت نشد: {e}")
|
||||
failed.append((resource, item_id, field, url))
|
||||
continue
|
||||
ok.append((resource, item_id, field, new_url))
|
||||
|
||||
print(f"\n===== خلاصه: {len(ok)} تصویر منتقل شد =====")
|
||||
if failed:
|
||||
print("هنوز در دسترس نیست (بعداً دوباره اجرا کنید):")
|
||||
for resource, item_id, field, url in failed:
|
||||
print(f" {url} -> {resource}:{item_id}.{field}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1009 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 781 KiB |
|
Before Width: | Height: | Size: 781 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 781 KiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 283 KiB |