Author SHA1 Message Date
MMD 53673d0f9b Merge branch 'bilingual-en'
# Conflicts:
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/admin/entities.ts
#	frontend/src/components/ArticleCard.tsx
#	frontend/src/index.css
#	frontend/src/pages/ArticleDetail.tsx
2026-09-13 18:45:06 +03:30
MMD 9d55f521a0 Merge remote-tracking branch 'origin/dashboard/blog-changes' 2026-09-13 18:29:51 +03:30
MMD 35c03ef35e اصلاح عنوان تب صفحات جزئیات — brand به‌جای brand.name 2026-09-13 18:29:34 +03:30
fatemeh sane bb6c586d99 Update blog editor features 2026-09-13 10:39:25 +03:30
fatemeh sane 20c95e73b3 Merge branch 'site-v2' of https://git.bennu-api-bahrestan.ir/Behrad/aramesh-backend 2026-09-12 19:19:45 +03:30
21 changed files with 1245 additions and 191 deletions
+18 -3
View File
@@ -1,11 +1,25 @@
# Aramesh Construction Group — Frontend # Aramesh Construction Group — Frontend
A React + Vite + Tailwind CSS application. All text content and assets are fetched dynamically from the backend API (`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 ## Typography
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering. 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.
## CMS, SEO & Editor Features
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities:
* **Advanced SEO Management:** Dynamic `meta_description` fields (150-160 characters) and fully managed Image `Alt` texts (with auto-fallback to titles/filenames) ensure high search engine visibility.
* **Tags & Categorization:** Integrated `react-select` for modern category selection and creatable dynamic tags.
* **Article Metadata UI:** A beautifully designed metadata component displaying the author's avatar, name, and publication date right 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:**
* Specific block-level toggling for Headings (H1: 18px, H2: 16px, H3: 14px) and Paragraphs (14px).
* A custom feature to wrap English text blocks in LTR styling (`<span dir="ltr" class="english-text">` at 12px) for perfect bi-directional text alignment.
* **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 ## Development Setup
@@ -36,3 +50,4 @@ npm run build
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD). * `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/data/site.ts` — Shared TypeScript data models and interfaces.
+130 -5
View File
@@ -19,7 +19,10 @@
"lucide-react": "0.522.0", "lucide-react": "0.522.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
"react-select": "^5.10.2",
"slugify": "^1.6.9",
"sweetalert2": "^11.26.25" "sweetalert2": "^11.26.25"
}, },
"devDependencies": { "devDependencies": {
@@ -933,7 +936,6 @@
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"@floating-ui/utils": "^0.2.12" "@floating-ui/utils": "^0.2.12"
} }
@@ -943,7 +945,6 @@
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.8.0", "@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12" "@floating-ui/utils": "^0.2.12"
@@ -953,8 +954,7 @@
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
@@ -2020,6 +2020,15 @@
"@types/react": "^18.0.0" "@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": { "node_modules/@types/semver": {
"version": "7.8.0", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
@@ -2763,6 +2772,16 @@
"node": ">=6.0.0" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.407", "version": "1.5.407",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
@@ -3733,6 +3752,12 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "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": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3860,7 +3885,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -4238,6 +4262,17 @@
"node": ">= 0.8.0" "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": { "node_modules/prosemirror-changeset": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
@@ -4420,6 +4455,12 @@
"node": ">=0.10.0" "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": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -4433,12 +4474,36 @@
"react": "^18.3.1" "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-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4481,6 +4546,43 @@
"react-dom": ">=16.8" "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4693,6 +4795,15 @@
"node": ">=8" "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": { "node_modules/source-map": {
"version": "0.5.7", "version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -5055,6 +5166,20 @@
"punycode": "^2.1.0" "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": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+3
View File
@@ -21,7 +21,10 @@
"lucide-react": "0.522.0", "lucide-react": "0.522.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
"react-select": "^5.10.2",
"slugify": "^1.6.9",
"sweetalert2": "^11.26.25" "sweetalert2": "^11.26.25"
}, },
"devDependencies": { "devDependencies": {
+126 -49
View File
@@ -8,28 +8,21 @@ import { adminApi } from './adminClient'
import { ArticleEditor } from '../components/admin/ArticleEditor' import { ArticleEditor } from '../components/admin/ArticleEditor'
import { SeoChecklist } from '../components/admin/SeoChecklist' import { SeoChecklist } from '../components/admin/SeoChecklist'
import { ImageField } from '../components/admin/ImageField' import { ImageField } from '../components/admin/ImageField'
import type { ArticleBodyDoc } from '../data/site' import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import { ARTICLE_CATEGORIES } from './entities'
import type { Article, ArticleBodyDoc } from '../data/site'
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
type Draft = { /** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
id: number type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
slug: string
title: string /** داده‌های قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
title_en: string function normalizeTags(value: unknown): string[] {
date: string if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
date_en: string if (typeof value === 'string') return value.split(',').map((t) => t.trim()).filter(Boolean)
category: string return []
category_en: string
image: string
read: string
read_en: string
excerpt: string
excerpt_en: string
body: ArticleBodyDoc
body_en: ArticleBodyDoc
status: 'draft' | 'published'
seo_keyphrase: string
} }
const STATUS_TEXT: Record<string, string> = { const STATUS_TEXT: Record<string, string> = {
@@ -57,11 +50,15 @@ export function ArticleWritePage() {
slug: `article-${Date.now()}`, slug: `article-${Date.now()}`,
title: '', title: '',
title_en: '', title_en: '',
date: new Date().toLocaleDateString('fa-IR'), date: new Date().toISOString().split('T')[0],
date_en: '', date_en: '',
category: 'یادداشت', category: ARTICLE_CATEGORIES[0],
category_en: '', category_en: '',
tags: [],
image: '', image: '',
image_alt: '',
author: '',
meta_description: '',
read: '۵ دقیقه', read: '۵ دقیقه',
read_en: '', read_en: '',
excerpt: '', excerpt: '',
@@ -105,6 +102,7 @@ export function ArticleWritePage() {
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
setDraft({ setDraft({
...(article as unknown as Draft), ...(article as unknown as Draft),
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc, body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
body_en: body_en:
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody) rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
@@ -207,6 +205,7 @@ export function ArticleWritePage() {
queueSave({ [name]: e.target.value }), queueSave({ [name]: e.target.value }),
}) })
const metaLength = (draft.meta_description ?? '').trim().length
// در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند // در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
@@ -312,15 +311,21 @@ export function ArticleWritePage() {
/> />
<div className="mt-4 h-px w-28 bg-copper" /> <div className="mt-4 h-px w-28 bg-copper" />
{/* تصویر شاخص */} {/* تصویر شاخص + متن جایگزین */}
<div className="mt-8"> <div className="mt-8">
<ImageField <ImageField
label="تصویر شاخص مقاله" label="تصویر شاخص مقاله"
value={draft.image} value={draft.image}
alt={draft.image_alt ?? ''}
altFallback={draft.title}
onSave={async (url) => { onSave={async (url) => {
queueSave({ image: url }) queueSave({ image: url })
return true return true
}} }}
onSaveAlt={async (alt) => {
queueSave({ image_alt: alt })
return true
}}
/> />
</div> </div>
@@ -328,23 +333,59 @@ export function ArticleWritePage() {
<div className="mt-8"> <div className="mt-8">
<ArticleEditor <ArticleEditor
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>} doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
onChange={(json) => queueSave({ [suf('body')]: json })} onChange={(json) => {
// محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن — به زبان جاری
const textContent = JSON.stringify(json)
const wordCount = textContent.split(/\s+/).length
if (editLang === 'en') {
const minutes = Math.max(1, Math.ceil(wordCount / 200))
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
} else {
const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه'
queueSave({ body: json, read: calculatedReadTime })
}
}}
/> />
</div> </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 <SeoChecklist
input={{ input={{
title: draft.title, title: draft.title,
slug: draft.slug, slug: draft.slug,
excerpt: draft.excerpt, excerpt: draft.meta_description || draft.excerpt,
body: draft.body, body: draft.body,
keyphrase: draft.seo_keyphrase, keyphrase: draft.seo_keyphrase,
}} }}
/> />
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله"> <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 className="mt-5 grid gap-5">
<div> <div>
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمی‌شود)</label> <label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمی‌شود)</label>
@@ -356,38 +397,74 @@ 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" 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>
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label> <div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<textarea <div>
rows={2} <label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
{...textField(suf('excerpt'))} <textarea
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : undefined} rows={2}
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" {...textField(suf('excerpt'))}
/> placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارت‌های وبلاگ…'}
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"
/>
</div>
<div>
<label htmlFor="article-author" 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"
/>
</div>
</div> </div>
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div> <div>
<label className="text-xs font-light text-ivory/50">دسته</label> <label className="text-xs font-light text-ivory/50">دسته‌بندی مقاله (Category)</label>
<input <div className="mt-2">
type="text" <LuxeSelect
{...textField(suf('category'))} value={draft.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" options={ARTICLE_CATEGORIES}
/> onChange={(category) => queueSave({ category })}
isClearable={false}
/>
</div>
</div> </div>
<div> <div>
<label className="text-xs font-light text-ivory/50">تاریخ</label> <label className="text-xs font-light text-ivory/50">هشتگ‌ها / برچسب‌ها</label>
<input <div className="mt-2">
type="text" <LuxeTagsSelect
{...textField(suf('date'))} value={normalizeTags(draft.tags)}
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" 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>
<div> <div>
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label> <label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
<input <input
type="text" type="text"
{...textField(suf('read'))} disabled={editLang !== 'en'}
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" {...(editLang === 'en' ? textField('read_en') : { value: String(draft.read ?? '') })}
className={`mt-2 w-full text-sm font-light ${
editLang === 'en'
? 'border border-ivory/15 bg-char px-3 py-2 text-ivory focus:border-copper focus:outline-none'
: 'cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-ivory/50'
}`}
/> />
</div> </div>
<div> <div>
+117 -18
View File
@@ -8,6 +8,8 @@ import { adminApi } from './adminClient'
import { editableFields } from './entities' import { editableFields } from './entities'
import type { EntityConfig, FieldConfig } from './entities' import type { EntityConfig, FieldConfig } from './entities'
import { ImageField } from '../components/admin/ImageField' import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import { ArticleEditor } from '../components/admin/ArticleEditor' import { ArticleEditor } from '../components/admin/ArticleEditor'
type EntityDrawerProps = { type EntityDrawerProps = {
@@ -117,22 +119,34 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
<div className="flex-1 overflow-y-auto px-6 py-6"> <div className="flex-1 overflow-y-auto px-6 py-6">
<div className="grid gap-6"> <div className="grid gap-6">
{fields.map((field) => ( {fields.map((field) => {
<FieldControl // فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
key={field.name} const altKey =
field={field} field.type === 'image'
value={item[field.name]} ? field.name === 'src' && 'alt' in item
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined} ? 'alt'
onSave={(value) => saveField(field.name, value)} : `${field.name}_alt`
registerFlush={ : ''
field.type === 'wysiwyg' return (
? (fn) => { <FieldControl
flushRef.current = fn key={field.name}
} field={field}
: undefined value={item[field.name]}
} placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
/> altValue={altKey ? String(item[altKey] ?? '') : undefined}
))} altFallback={String(item[config.titleKey] ?? '')}
onSave={(value) => saveField(field.name, value)}
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
registerFlush={
field.type === 'wysiwyg'
? (fn) => {
flushRef.current = fn
}
: undefined
}
/>
)
})}
</div> </div>
</div> </div>
@@ -157,15 +171,22 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
function FieldControl({ function FieldControl({
field, field,
value, value,
altValue,
altFallback,
placeholder, placeholder,
onSave, onSave,
onSaveAlt,
registerFlush, registerFlush,
}: { }: {
field: FieldConfig field: FieldConfig
value: unknown value: unknown
/** مقدار Alt تصویر — فقط برای فیلدهای image */
altValue?: string
altFallback?: string
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */ /** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */
placeholder?: unknown placeholder?: unknown
onSave: (value: unknown) => Promise<boolean> onSave: (value: unknown) => Promise<boolean>
onSaveAlt?: (alt: string) => Promise<boolean>
registerFlush?: (fn: () => void) => void registerFlush?: (fn: () => void) => void
}) { }) {
const [text, setText] = useState<string>(String(value ?? '')) const [text, setText] = useState<string>(String(value ?? ''))
@@ -177,7 +198,53 @@ function FieldControl({
} }
if (field.type === 'image') { if (field.type === 'image') {
return <ImageField label={field.label} value={String(value ?? '')} onSave={(url) => onSave(url)} /> return (
<ImageField
label={field.label}
value={String(value ?? '')}
alt={altValue}
altFallback={altFallback}
onSave={(url) => onSave(url)}
onSaveAlt={onSaveAlt}
/>
)
}
if (field.type === 'select') {
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">
<LuxeSelect
value={String(value ?? '')}
options={field.options ?? []}
onChange={(next) => {
setText(next)
void onSave(next)
}}
/>
</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>
)
} }
if (field.type === 'wysiwyg') { if (field.type === 'wysiwyg') {
@@ -193,7 +260,11 @@ function FieldControl({
} }
if (field.type === 'list') { if (field.type === 'list') {
return <ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را می‌بیند' : undefined} /> return field.ui === 'tags' ? (
<TagsField field={field} value={value} onSave={onSave} />
) : (
<ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را می‌بیند' : undefined} />
)
} }
if (field.type === 'textarea') { if (field.type === 'textarea') {
@@ -230,6 +301,34 @@ 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({ function ListField({
field, field,
+45 -17
View File
@@ -1,40 +1,55 @@
import type { SiteData } from '../api' import type { SiteData } from '../api'
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
export type FieldConfig = { export type FieldConfig = {
name: string name: string
label: string label: string
type: FieldType type: FieldType
hint?: string hint?: string
/** فقط برای type === 'select' — گزینه‌های ثابت react-select */
options?: string[]
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
ui?: 'lines' | 'tags'
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */ /** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */
shared?: boolean shared?: boolean
} }
export type EntityConfig = { export type EntityConfig = {
/** مسیر منبع در پنل مدیریت، مثل services یا why-us */
resource: string resource: string
/** کلید همان منبع در پاسخ GET /api/site */
dataKey: string dataKey: string
title: string title: string
/** مجموعه یا رکورد یگانه */
kind: 'collection' | 'singleton' kind: 'collection' | 'singleton'
/** فیلدی که عنوان آیتم را نشان می‌دهد */
titleKey: string titleKey: string
fields: FieldConfig[] fields: FieldConfig[]
/** مقادیر پیش‌فرض برای «افزودن» */
defaults: Record<string, unknown> defaults: Record<string, unknown>
} }
const F = (name: string, label: string, type: FieldType = 'text', hint?: string, shared = false): FieldConfig => ({ const F = (
name: string,
label: string,
type: FieldType = 'text',
hint?: string,
sharedOrExtra?: boolean | Partial<FieldConfig>,
): FieldConfig => ({
name, name,
label, label,
type, type,
hint, hint,
shared, ...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null
? sharedOrExtra
: { shared: !!sharedOrExtra }),
}) })
/** پیکربندی همه منابع قابل ویرایش — هم مجموعه‌ها و هم رکوردهای یگانه */ /** دسته‌بندی‌های استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده می‌شوند */
export const ARTICLE_CATEGORIES = [
'معماری و ساخت',
'سرمایه‌گذاری ملکی',
'طراحی داخلی',
'اخبار و اطلاعیه‌ها',
'یادداشت مدیریت',
]
export const ENTITIES: Record<string, EntityConfig> = { export const ENTITIES: Record<string, EntityConfig> = {
services: { services: {
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection', resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
@@ -60,14 +75,30 @@ export const ENTITIES: Record<string, EntityConfig> = {
fields: [ fields: [
F('title', 'عنوان'), F('title', 'عنوان'),
F('body', 'متن مقاله', 'wysiwyg'), F('body', 'متن مقاله', 'wysiwyg'),
F('excerpt', 'چکیده', 'textarea'), F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود', true),
F('image', 'تصویر شاخص', 'image', undefined, true), F('image', 'تصویر شاخص', 'image', undefined, true),
F('category', 'دسته'), F('category', 'دسته‌بندی (Category)', 'select', undefined, { options: ARTICLE_CATEGORIES, shared: true }),
F('date', 'تاریخ'), F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
F('read', 'زمان مطالعه'), F('author', 'نویسنده', 'text', undefined, true),
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
F('read', 'زمان مطالعه (دقیقه)'),
F('slug', 'نشانی (slug)', 'text', undefined, true), F('slug', 'نشانی (slug)', 'text', undefined, true),
], ],
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: { type: 'doc', content: [{ type: 'paragraph' }] } }, defaults: {
title: 'مقاله جدید',
slug: `article-${Date.now()}`,
date: new Date().toISOString().split('T')[0],
category: ARTICLE_CATEGORIES[0],
tags: ['مدیریت پروژه', 'ساخت‌وساز'],
image: '',
author: '',
meta_description: '',
read: '۵ دقیقه',
excerpt: '',
seo_keyphrase: '',
body: { type: 'doc', content: [{ type: 'paragraph' }] }
},
}, },
testimonials: { testimonials: {
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection', resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
@@ -135,7 +166,6 @@ export const ENTITIES: Record<string, EntityConfig> = {
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)], fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
defaults: { label: 'آیتم جدید', href: '/' }, defaults: { label: 'آیتم جدید', href: '/' },
}, },
seo_meta: { seo_meta: {
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection', resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
titleKey: 'page_key', titleKey: 'page_key',
@@ -146,8 +176,6 @@ export const ENTITIES: Record<string, EntityConfig> = {
], ],
defaults: { page_key: '', title: '', description: '' }, defaults: { page_key: '', title: '', description: '' },
}, },
// ---------- رکوردهای یگانه ----------
brand: { brand: {
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton', resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
titleKey: 'name', titleKey: 'name',
+2 -1
View File
@@ -2,6 +2,7 @@ import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react' import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site' import type { Article } from '../data/site'
import { formatJalali } from '../utils/date'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
export function ArticleCard({ article }: { article: Article }) { export function ArticleCard({ article }: { article: Article }) {
@@ -35,7 +36,7 @@ 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"> <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 className="flex items-center gap-3">
<span>{article.date}</span> <span>{formatJalali(article.date)}</span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5 text-copper/80" /> <ClockIcon className="h-3.5 w-3.5 text-copper/80" />
{article.read} {article.read}
+53
View File
@@ -0,0 +1,53 @@
import { CalendarIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
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" />
{article.read}
</span>
)}
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
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 ویرایشگر — تیترهای H2 و H3 شناسه لنگر می‌گیرند
* (با slugify و پشتیبانی فارسی) و HTML برگشتی همان است که باید رندر شود.
*/
export function extractToc(html: string): { 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((el) => {
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
if (!text) return
const id = uniqueSlug(slugifyFa(text), used)
el.setAttribute('id', id)
items.push({ id, text, level: el.tagName === 'H3' ? 3 : 2 })
})
return { items, html: doc.body.innerHTML }
}
type TableOfContentsProps = {
items: TocItem[]
title?: string
className?: string
}
/** فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */
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-r border-ivory/10 pr-3">
{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-right text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'pr-4 text-[13px]' : ''
} ${
isActive
? 'font-normal text-copper'
: 'text-ivory/60 hover:text-copper'
}`}
>
{item.text}
</button>
</li>
)
})}
</ul>
</nav>
)
}
+79 -50
View File
@@ -3,15 +3,19 @@ import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image' import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
import { Mark, mergeAttributes } from '@tiptap/core'
import { import {
BoldIcon, BoldIcon,
ItalicIcon, ItalicIcon,
Heading1Icon,
Heading2Icon, Heading2Icon,
Heading3Icon, Heading3Icon,
ImagePlusIcon, ImagePlusIcon,
LanguagesIcon,
Link2Icon, Link2Icon,
ListIcon, ListIcon,
ListOrderedIcon, ListOrderedIcon,
PilcrowIcon,
QuoteIcon, QuoteIcon,
Redo2Icon, Redo2Icon,
StrikethroughIcon, StrikethroughIcon,
@@ -22,12 +26,20 @@ import {
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import { useAdmin } from '../../admin/AdminContext' import { useAdmin } from '../../admin/AdminContext'
import { adminApi } from '../../admin/adminClient' import { adminApi } from '../../admin/adminClient'
import { ENGLISH_TEXT_MARK, EnglishText } from './EnglishText'
const SmallSizeMark = Mark.create({
name: 'smallSize',
parseHTML() { return [{ tag: 'span[data-small]' }] },
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
}
})
/** اکستنشن‌های مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
export const ARTICLE_EXTENSIONS = [ export const ARTICLE_EXTENSIONS = [
StarterKit.configure({ StarterKit.configure({
link: false, link: false,
heading: { levels: [2, 3] }, heading: { levels: [1, 2, 3] },
}), }),
Image.configure({ allowBase64: false }), Image.configure({ allowBase64: false }),
Link.configure({ Link.configure({
@@ -35,6 +47,8 @@ export const ARTICLE_EXTENSIONS = [
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' }, HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri), isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
}), }),
SmallSizeMark,
EnglishText,
] ]
type ArticleEditorProps = { type ArticleEditorProps = {
@@ -73,10 +87,6 @@ function ToolButton({
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" /> const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
/**
* ویرایشگر WYSIWYG مقاله (TipTap) — راست‌به‌چپ، با آپلود مستقیم تصویر به MinIO
* (انتخاب فایل، چسباندن یا کشیدن تصویر داخل متن).
*/
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) { export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
const { runSave } = useAdmin() const { runSave } = useAdmin()
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
@@ -118,9 +128,14 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
async function uploadAndInsert(file: File) { async function uploadAndInsert(file: File) {
if (!editor) return if (!editor) return
setUploading(true) setUploading(true)
// استفاده از ریگکس یونیکد اصلاح‌شده برای نام فایل و Alt
const originalName = file.name.replace(/\.[^.]+$/, '')
const cleanAlt = originalName.replace(/[^\w\u0600-\u06FF\s-]/g, '-').replace(/\s+/g, '-').toLowerCase()
await runSave(async () => { await runSave(async () => {
const { url } = await adminApi.upload(file) const { url } = await adminApi.upload(file)
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run() editor.chain().focus().setImage({ src: url, alt: cleanAlt }).run()
}) })
setUploading(false) setUploading(false)
} }
@@ -158,47 +173,32 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
return ( return (
<div className="border border-ivory/15 bg-char"> <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"> <div className="flex flex-wrap items-center gap-1 border-b border-ivory/10 px-2 py-1.5">
{/* قالب‌بندی بلوک — پاراگراف و تیترها با سایز مشخص */}
<ToolButton <ToolButton
label="بولد" label="پاراگراف (۱۴ پیکسل)"
active={editor.isActive('bold')} active={editor.isActive('paragraph')}
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().setParagraph().run()}
> >
<BoldIcon className="h-4 w-4" /> <PilcrowIcon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
label="ایتالیک" label="تیتر ۱ (۱۸ پیکسل)"
active={editor.isActive('italic')} active={editor.isActive('heading', { level: 1 })}
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
> >
<ItalicIcon className="h-4 w-4" /> <Heading1Icon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
label="زیرخط" 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 />
<ToolButton
label="تیتر میانی"
active={editor.isActive('heading', { level: 2 })} active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
> >
<Heading2Icon className="h-4 w-4" /> <Heading2Icon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
label="تیتر کوچک" label="تیتر ۳ (۱۴ پیکسل)"
active={editor.isActive('heading', { level: 3 })} active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
> >
@@ -207,25 +207,54 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
<Divider /> <Divider />
<ToolButton <select
label="لیست نقطه‌ای" className="h-8 bg-ink border border-ivory/15 text-ivory/90 text-xs px-2 outline-none focus:border-copper cursor-pointer transition-colors"
active={editor.isActive('bulletList')} value={editor.isActive('smallSize') ? '12' : '14'}
onClick={() => editor.chain().focus().toggleBulletList().run()} onChange={(e) => {
if (e.target.value === '12') {
editor.chain().focus().setMark('smallSize').run()
} else {
editor.chain().focus().unsetMark('smallSize').run()
}
}}
> >
<option value="14">سایز ۱۴ (فارسی)</option>
<option value="12">سایز ۱۲ (انگلیسی)</option>
</select>
{/* متن انگلیسی — span با dir=ltr و سایز ۱۲ پیکسل */}
<ToolButton
label="متن انگلیسی (۱۲ پیکسل، چپ‌به‌راست)"
active={editor.isActive(ENGLISH_TEXT_MARK)}
onClick={() => editor.chain().focus().toggleMark(ENGLISH_TEXT_MARK).run()}
>
<LanguagesIcon className="h-4 w-4" />
</ToolButton>
<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>
<Divider />
<ToolButton label="لیست نقطه‌ای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
<ListIcon className="h-4 w-4" /> <ListIcon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton label="لیست شماره‌ای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
label="لیست شماره‌ای"
active={editor.isActive('orderedList')}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrderedIcon className="h-4 w-4" /> <ListOrderedIcon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton label="نقل‌قول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
label="نقل‌قول"
active={editor.isActive('blockquote')}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<QuoteIcon className="h-4 w-4" /> <QuoteIcon className="h-4 w-4" />
</ToolButton> </ToolButton>
@@ -264,4 +293,4 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
/> />
</div> </div>
) )
} }
@@ -0,0 +1,26 @@
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,
]
},
})
+62 -4
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react' import { useEffect, useId, useRef, useState } from 'react'
import { ImagePlusIcon, Loader2Icon } from 'lucide-react' import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { adminApi } from '../../admin/adminClient' import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext' import { useAdmin } from '../../admin/AdminContext'
@@ -7,33 +7,69 @@ type ImageFieldProps = {
label: string label: string
value: string value: string
onSave: (url: string) => Promise<boolean> onSave: (url: string) => Promise<boolean>
/** متن جایگزین (Alt) — با ارسال این prop ورودی Alt نمایش داده می‌شود */
alt?: string
/** ذخیره متن جایگزین — بدون آن، Alt فقط برای پیش‌نمایش و پیشنهاد استفاده می‌شود */
onSaveAlt?: (alt: string) => Promise<boolean>
/** پیشنهاد Alt وقتی خالی است (مثل عنوان مقاله یا نام موجودیت) */
altFallback?: string
} }
/** /**
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش استفاده می‌شود. * انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش و صفحه نوشتن مقاله.
* پس از آپلود موفق، URL نسبی /media/... ذخیره می‌شود. * پس از آپلود موفق، URL نسبی /media/... ذخیره می‌شود.
* اگر Alt خالی باشد، پیش از فراخوانی API از عنوان موجودیت یا نام فایل ساخته می‌شود.
*/ */
export function ImageField({ label, value, onSave }: ImageFieldProps) { export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback = '' }: ImageFieldProps) {
const { runSave, isAuthenticated, isEditMode } = useAdmin() const { runSave, isAuthenticated, isEditMode } = useAdmin()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const altInputId = useId()
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [altText, setAltText] = useState(alt ?? '')
const showAlt = alt !== undefined || !!onSaveAlt
// همگام‌سازی با مقدار ذخیره‌شده (پس از به‌روزرسانی خوش‌بینانه در درخت داده)
useEffect(() => {
setAltText(alt ?? '')
}, [alt])
if (!isAuthenticated || !isEditMode) return null if (!isAuthenticated || !isEditMode) return null
/** متن جایگزین نهایی: ورودی کاربر → عنوان موجودیت → نام فایل */
const resolveAlt = (fileName: string): string => {
const typed = altText.trim()
if (typed) return typed
const fromEntity = altFallback.trim()
if (fromEntity) return fromEntity
return fileName.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || label
}
const pick = async (file: File | undefined) => { const pick = async (file: File | undefined) => {
if (!file) return if (!file) return
const generatedAlt = resolveAlt(file.name)
setUploading(true) setUploading(true)
try { try {
await runSave(async () => { await runSave(async () => {
const { url } = await adminApi.upload(file) const { url } = await adminApi.upload(file)
const ok = await onSave(url) const ok = await onSave(url)
if (!ok) throw new Error('ذخیره نشد') if (!ok) throw new Error('ذخیره نشد')
// Alt ساخته‌شده هم ذخیره می‌شود تا تصویر بدون متن جایگزین نماند
if (showAlt && generatedAlt !== (alt ?? '').trim()) {
setAltText(generatedAlt)
if (onSaveAlt) await onSaveAlt(generatedAlt)
}
}) })
} finally { } finally {
setUploading(false) setUploading(false)
} }
} }
const commitAlt = () => {
const next = altText.trim()
if (!onSaveAlt || next === (alt ?? '').trim()) return
void onSaveAlt(next)
}
return ( return (
<div> <div>
<p className="text-xs font-bold text-ivory/80">{label}</p> <p className="text-xs font-bold text-ivory/80">{label}</p>
@@ -41,7 +77,7 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
{value ? ( {value ? (
<img <img
src={value} src={value}
alt={label} alt={altText || label}
className="h-16 w-24 border border-ivory/15 object-cover" className="h-16 w-24 border border-ivory/15 object-cover"
/> />
) : ( ) : (
@@ -66,6 +102,28 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>} {value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
</div> </div>
</div> </div>
{showAlt && (
<div className="mt-3">
<div className="flex items-center justify-between gap-3">
<label htmlFor={altInputId} className="text-[11px] font-light text-ivory/50">
متن جایگزین (Alt)
</label>
<span className="text-[10px] font-light text-ivory/30">برای سئو و صفحه‌خوان‌ها</span>
</div>
<input
id={altInputId}
type="text"
dir="auto"
value={altText}
onChange={(e) => setAltText(e.target.value)}
onBlur={commitAlt}
placeholder={altFallback ? `پیش‌فرض: ${altFallback}` : 'توضیح کوتاه تصویر…'}
className="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"
/>
</div>
)}
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
@@ -0,0 +1,52 @@
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
/>
)
}
@@ -0,0 +1,161 @@
import { useMemo } from 'react'
import Select from 'react-select'
import CreatableSelect from 'react-select/creatable'
import type { StylesConfig } from 'react-select'
export type LuxeOption = { label: string; value: string }
const COPPER = '#d5874c'
const IVORY = '#F5F3EF'
const SURFACE = '#1e1a16'
const BORDER = 'rgba(245,243,239,0.15)'
/** استایل تیره و لوکس react-select هماهنگ با پالت سایت (مسی روی زغالی) */
function makeStyles<IsMulti extends boolean>(): StylesConfig<LuxeOption, IsMulti> {
return {
control: (base, state) => ({
...base,
minHeight: 42,
backgroundColor: SURFACE,
borderColor: state.isFocused ? COPPER : BORDER,
borderRadius: 0,
boxShadow: state.isFocused ? '0 0 0 1px rgba(213,135,76,0.35)' : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
':hover': { borderColor: state.isFocused ? COPPER : 'rgba(213,135,76,0.5)' },
}),
valueContainer: (base) => ({ ...base, padding: '2px 12px' }),
placeholder: (base) => ({ ...base, color: 'rgba(245,243,239,0.35)', fontSize: 13 }),
singleValue: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
input: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
menu: (base) => ({
...base,
backgroundColor: SURFACE,
border: `1px solid ${BORDER}`,
borderRadius: 0,
boxShadow: '0 24px 50px -18px rgba(0,0,0,0.75)',
overflow: 'hidden',
zIndex: 60,
}),
menuList: (base) => ({ ...base, padding: 4 }),
menuPortal: (base) => ({ ...base, zIndex: 200 }),
option: (base, state) => ({
...base,
backgroundColor: state.isSelected
? 'rgba(213,135,76,0.25)'
: state.isFocused
? 'rgba(213,135,76,0.12)'
: 'transparent',
color: state.isSelected ? COPPER : 'rgba(245,243,239,0.85)',
fontSize: 13,
fontWeight: state.isSelected ? 700 : 400,
cursor: 'pointer',
borderRadius: 0,
':active': { backgroundColor: 'rgba(213,135,76,0.3)' },
}),
noOptionsMessage: (base) => ({ ...base, color: 'rgba(245,243,239,0.45)', fontSize: 12 }),
indicatorSeparator: (base) => ({ ...base, backgroundColor: BORDER }),
dropdownIndicator: (base, state) => ({
...base,
color: state.isFocused ? COPPER : 'rgba(245,243,239,0.4)',
':hover': { color: COPPER },
}),
clearIndicator: (base) => ({
...base,
color: 'rgba(245,243,239,0.4)',
':hover': { color: COPPER },
}),
multiValue: (base) => ({
...base,
backgroundColor: 'rgba(213,135,76,0.15)',
border: '1px solid rgba(213,135,76,0.4)',
borderRadius: 999,
paddingInline: 2,
}),
multiValueLabel: (base) => ({ ...base, color: COPPER, fontSize: 12 }),
multiValueRemove: (base) => ({
...base,
color: COPPER,
borderRadius: 999,
':hover': { backgroundColor: COPPER, color: SURFACE },
}),
}
}
const portalTarget = (): HTMLElement | undefined =>
typeof document === 'undefined' ? undefined : document.body
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(() => [...new Set([...options, ...(value ? [value] : [])])].map(
(item) => ({ label: item, value: item }),
), [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={() => 'موردی یافت نشد'}
styles={makeStyles<false>()}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
)
}
type LuxeTagsSelectProps = {
value: string[]
onChange: (value: string[]) => void
placeholder?: string
}
/** هشتگ‌ها با react-select قابل ساخت — هر تایپ جدید با Enter به فهرست اضافه می‌شود */
export function LuxeTagsSelect({
value,
onChange,
placeholder = 'هشتگ بنویسید و Enter بزنید…',
}: LuxeTagsSelectProps) {
const options = useMemo(
() => [...new Set(value.filter(Boolean))].map((item) => ({ label: item, value: item })),
[value],
)
return (
<CreatableSelect<LuxeOption, true>
classNamePrefix="luxe"
isMulti
options={options}
value={options}
onChange={(next) => onChange(next.map((option) => option.value))}
placeholder={placeholder}
formatCreateLabel={(input) => `افزودن «${input}»`}
noOptionsMessage={() => 'هشتگی یافت نشد'}
styles={makeStyles<true>()}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
)
}
+8
View File
@@ -55,4 +55,12 @@ export type Article = {
status: ArticleStatus; status: ArticleStatus;
/** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */ /** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */
seo_keyphrase: string; seo_keyphrase: string;
/** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */
meta_description?: string;
/** متن جایگزین تصویر شاخص (Alt) */
image_alt?: string;
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده می‌شود */
author?: string;
/** هشتگ‌ها/برچسب‌های مقاله */
tags?: string[];
}; };
+93 -3
View File
@@ -101,6 +101,7 @@ html[lang='en'] .article-body h3 {
.article-body { .article-body {
line-height: 2.4; line-height: 2.4;
word-wrap: break-word; word-wrap: break-word;
font-size: 14px; /* متن اصلی ۱۴ تنظیم شد */
} }
.article-body p { .article-body p {
margin-top: 1.75rem; margin-top: 1.75rem;
@@ -109,6 +110,7 @@ html[lang='en'] .article-body h3 {
.article-body > :first-child { .article-body > :first-child {
margin-top: 0; margin-top: 0;
} }
.article-body h1,
.article-body h2, .article-body h2,
.article-body h3 { .article-body h3 {
color: #f5f3ef; color: #f5f3ef;
@@ -116,15 +118,28 @@ html[lang='en'] .article-body h3 {
margin-top: 3rem; margin-top: 3rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
line-height: 1.9; line-height: 1.9;
/* فاصله از هدر چسبان هنگام پرش لنگرهای فهرست مطالب */
scroll-margin-top: 7rem;
}
.article-body h1 {
font-size: 18px; /* تیتر ۱ سایز ۱۸ */
} }
.article-body h2 { .article-body h2 {
font-size: 1.6rem; font-size: 16px; /* تیتر ۲ سایز ۱۶ */
padding-inline-start: 0.85rem; padding-inline-start: 0.85rem;
border-inline-start: 3px solid #d5874c; border-inline-start: 3px solid #d5874c;
} }
.article-body h3 { .article-body h3 {
font-size: 1.25rem; font-size: 14px; /* تیتر ۳ سایز ۱۴ */
color: #e8c9a8; }
.article-body span[data-small="true"] {
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
}
/* مارک «متن انگلیسی» ویرایشگر — چپ‌به‌راست و ۱۲ پیکسل بدون به‌هم‌زدن جهت متن فارسی */
.english-text {
direction: ltr;
unicode-bidi: isolate;
font-size: 12px;
} }
.article-body ul, .article-body ul,
.article-body ol { .article-body ol {
@@ -195,3 +210,78 @@ html[lang='en'] .article-body h3 {
background: transparent; background: transparent;
padding: 0; 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;
}
+54 -40
View File
@@ -1,10 +1,12 @@
import React from 'react'; import React from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react'; import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react';
import { renderToHTMLString } from '@tiptap/static-renderer'; import { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal'; import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button'; import { ButtonLink } from '../components/Button';
import { ArticleCard } from '../components/ArticleCard'; 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 { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api'; import { useSite } from '../api';
import { useLang } from '../i18n/lang'; import { useLang } from '../i18n/lang';
@@ -13,21 +15,21 @@ import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
import type { Article } from '../data/site'; import type { Article } from '../data/site';
/** بدنه مقاله را به HTML تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */ /** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */
function renderBody(article: Article): { html: string | null; legacy: string[] } { function renderBody(article: Article): { html: string | null; toc: TocItem[]; legacy: string[] } {
const body = article.body as unknown; const body = article.body as unknown;
if (Array.isArray(body)) return { html: null, legacy: body }; if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') { if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
try { try {
return { const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never });
html: renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }), // تیترهای H2/H3 شناسه لنگر می‌گیرند تا فهرست مطالب به آن‌ها بپرد
legacy: [], const { items, html: htmlWithIds } = extractToc(html);
}; return { html: htmlWithIds, toc: items, legacy: [] };
} catch { } catch {
return { html: null, legacy: [] }; return { html: null, toc: [], legacy: [] };
} }
} }
return { html: null, legacy: [] }; return { html: null, toc: [], legacy: [] };
} }
export function ArticleDetail() { export function ArticleDetail() {
@@ -41,7 +43,8 @@ export function ArticleDetail() {
if (!article) return; if (!article) return;
applySeo({ applySeo({
title: `${article.title} | ${brand.name}`, title: `${article.title} | ${brand.name}`,
description: article.excerpt, // توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
description: article.meta_description || article.excerpt,
path: href(`/blog/${article.slug}`), path: href(`/blog/${article.slug}`),
image: article.image, image: article.image,
type: 'article', type: 'article',
@@ -50,10 +53,10 @@ export function ArticleDetail() {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Article', '@type': 'Article',
headline: article.title, headline: article.title,
description: article.excerpt, description: article.meta_description || article.excerpt,
image: article.image ? new URL(article.image, window.location.origin).href : undefined, image: article.image ? new URL(article.image, window.location.origin).href : undefined,
inLanguage: lang, inLanguage: lang,
author: { '@type': 'Organization', name: brand }, author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
publisher: { '@type': 'Organization', name: brand }, publisher: { '@type': 'Organization', name: brand },
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href, mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
}, },
@@ -73,13 +76,17 @@ export function ArticleDetail() {
} }
const { html, legacy } = renderBody(article); const { html, toc, legacy } = renderBody(article);
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3); const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
return ( return (
<> <>
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden"> <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" /> <img
src={article.image}
alt={article.image_alt || article.title}
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 bg-ink/75" aria-hidden="true" />
<div <div
className="absolute inset-0" className="absolute inset-0"
@@ -99,36 +106,43 @@ export function ArticleDetail() {
<span className="text-copper">{article.category}</span> <span className="text-copper">{article.category}</span>
</nav> </nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1> <h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
<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"> <ArticleMeta article={article} className="mt-7" />
<ClockIcon className="h-3.5 w-3.5" />
{article.read}
</span>
</div>
</div> </div>
</section> </section>
<article className="bg-ink py-20 lg:py-28"> <article className="bg-ink py-20 lg:py-28">
<div className="mx-auto max-w-3xl px-6"> <div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal> <div className="mx-auto grid max-w-5xl gap-12 lg:grid-cols-[minmax(0,1fr)_17rem] lg:gap-14">
<p className="border-copper ltr:border-l-2 ltr:pl-6 rtl:border-r-2 rtl:pr-6 text-lg font-light leading-10 text-ivory/80"> <div className="min-w-0">
{article.excerpt} <Reveal>
</p> <p className="border-copper ltr:border-l-2 ltr:pl-6 rtl:border-r-2 rtl:pr-6 text-lg font-light leading-10 text-ivory/80">
</Reveal> {article.excerpt}
{html ? ( </p>
<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> </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>
{/* فهرست مطالب — در دسکتاپ کنار متن چسبان می‌ماند */}
{toc.length > 0 && (
<aside className="order-first lg:order-none">
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
</aside>
)}
</div>
</div> </div>
</article> </article>
+2 -1
View File
@@ -9,6 +9,7 @@ import { useSite } from '../api'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { EditableItem } from '../admin/Editable' import { EditableItem } from '../admin/Editable'
import { useAdmin } from '../admin/AdminContext' import { useAdmin } from '../admin/AdminContext'
import { formatJalali } from '../utils/date'
import { PlusIcon } from 'lucide-react' import { PlusIcon } from 'lucide-react'
function DraftBadge({ className = '' }: { className?: string }) { function DraftBadge({ className = '' }: { className?: string }) {
@@ -94,7 +95,7 @@ export function Blog() {
<p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p> <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"> <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 className="flex items-center gap-4">
<span>{featured.date}</span> <span>{formatJalali(featured.date)}</span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" /> <ClockIcon className="h-3.5 w-3.5" />
{featured.read} {featured.read}
+69
View File
@@ -0,0 +1,69 @@
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)}`
}
+37
View File
@@ -0,0 +1,37 @@
import slugify from 'slugify'
/**
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن.
* slugify حروف فارسی و عربی را به معادل لاتین ترانویسی می‌کند تا لنگرهای
* فهرست مطالب در همه مرورگرها ASCII، پایدار و قابل اشتراک‌گذاری بمانند.
*/
export function slugifyFa(input: string): string {
const normalized = (input ?? '')
.toString()
.normalize('NFC')
// نیم‌فاصله، اتصال‌دهنده و نویسه‌های نامرئی جهت → فاصله
.replace(/[\u200b-\u200f\u202a-\u202e]/g, ' ')
// یکسان‌سازی حروف عربی با معادل فارسی
.replace(/[يى]/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/[ۀة]/g, 'ه')
.trim()
const slug = slugify(normalized, {
lower: true,
trim: true,
replacement: '-',
// فقط حروف/اعداد/فاصله/خط تیره بماند — بقیه نشانه‌ها حذف شوند
remove: /[^\w\s-]/g,
})
return slug.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
View File
@@ -5,6 +5,7 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",