Update blog editor features
This commit is contained in:
+18
-3
@@ -1,11 +1,25 @@
|
||||
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
@@ -36,3 +50,4 @@ npm run build
|
||||
* `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.
|
||||
|
||||
|
||||
|
||||
Generated
+130
-5
@@ -19,7 +19,10 @@
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"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": {
|
||||
@@ -933,7 +936,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -943,7 +945,6 @@
|
||||
"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"
|
||||
@@ -953,8 +954,7 @@
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
@@ -2020,6 +2020,15 @@
|
||||
"@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",
|
||||
@@ -2763,6 +2772,16 @@
|
||||
"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",
|
||||
@@ -3733,6 +3752,12 @@
|
||||
"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",
|
||||
@@ -3860,7 +3885,6 @@
|
||||
"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"
|
||||
@@ -4238,6 +4262,17 @@
|
||||
"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",
|
||||
@@ -4420,6 +4455,12 @@
|
||||
"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",
|
||||
@@ -4433,12 +4474,36 @@
|
||||
"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": {
|
||||
"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",
|
||||
@@ -4481,6 +4546,43 @@
|
||||
"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",
|
||||
@@ -4693,6 +4795,15 @@
|
||||
"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",
|
||||
@@ -5055,6 +5166,20 @@
|
||||
"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,7 +21,10 @@
|
||||
"lucide-react": "0.522.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"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": {
|
||||
|
||||
@@ -8,22 +8,21 @@ import { adminApi } from './adminClient'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
||||
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
|
||||
|
||||
type Draft = {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
date: string
|
||||
category: string
|
||||
image: string
|
||||
read: string
|
||||
excerpt: string
|
||||
body: ArticleBodyDoc
|
||||
status: 'draft' | 'published'
|
||||
seo_keyphrase: string
|
||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته */
|
||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc }
|
||||
|
||||
/** دادههای قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
|
||||
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 []
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
@@ -50,9 +49,13 @@ export function ArticleWritePage() {
|
||||
id: 0,
|
||||
slug: `article-${Date.now()}`,
|
||||
title: '',
|
||||
date: new Date().toLocaleDateString('fa-IR'),
|
||||
category: 'یادداشت',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
tags: [],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
body: EMPTY_DOC,
|
||||
@@ -92,6 +95,7 @@ export function ArticleWritePage() {
|
||||
if (!article || createdRef.current) return
|
||||
setDraft({
|
||||
...article,
|
||||
tags: normalizeTags(article.tags),
|
||||
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
|
||||
})
|
||||
setCreatedId(article.id)
|
||||
@@ -190,6 +194,8 @@ export function ArticleWritePage() {
|
||||
queueSave({ [name]: e.target.value }),
|
||||
})
|
||||
|
||||
const metaLength = (draft.meta_description ?? '').trim().length
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||||
{/* نوار بالا */}
|
||||
@@ -280,15 +286,21 @@ export function ArticleWritePage() {
|
||||
/>
|
||||
<div className="mt-4 h-px w-28 bg-copper" />
|
||||
|
||||
{/* تصویر شاخص */}
|
||||
{/* تصویر شاخص + متن جایگزین */}
|
||||
<div className="mt-8">
|
||||
<ImageField
|
||||
label="تصویر شاخص مقاله"
|
||||
value={draft.image}
|
||||
alt={draft.image_alt ?? ''}
|
||||
altFallback={draft.title}
|
||||
onSave={async (url) => {
|
||||
queueSave({ image: url })
|
||||
return true
|
||||
}}
|
||||
onSaveAlt={async (alt) => {
|
||||
queueSave({ image_alt: alt })
|
||||
return true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -296,23 +308,54 @@ export function ArticleWritePage() {
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
doc={draft.body as unknown as Record<string, unknown>}
|
||||
onChange={(json) => queueSave({ body: json })}
|
||||
onChange={(json) => {
|
||||
// محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن
|
||||
const textContent = JSON.stringify(json)
|
||||
const wordCount = textContent.split(/\s+/).length
|
||||
const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه'
|
||||
queueSave({ body: json, read: calculatedReadTime })
|
||||
}}
|
||||
/>
|
||||
</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.excerpt,
|
||||
excerpt: draft.meta_description || 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>
|
||||
@@ -324,37 +367,70 @@ 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>
|
||||
<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 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')}
|
||||
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"
|
||||
/>
|
||||
</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 className="grid grid-cols-2 gap-5 lg:grid-cols-4">
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<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"
|
||||
/>
|
||||
<label className="text-xs font-light text-ivory/50">دستهبندی مقاله (Category)</label>
|
||||
<div className="mt-2">
|
||||
<LuxeSelect
|
||||
value={draft.category}
|
||||
options={ARTICLE_CATEGORIES}
|
||||
onChange={(category) => queueSave({ category })}
|
||||
isClearable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">تاریخ</label>
|
||||
<input
|
||||
type="text"
|
||||
{...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"
|
||||
/>
|
||||
<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 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 cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-sm font-light text-ivory/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useAdmin, useFieldSaver } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import type { EntityConfig, FieldConfig } from './entities'
|
||||
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'
|
||||
|
||||
type EntityDrawerProps = {
|
||||
@@ -111,21 +113,33 @@ 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) => (
|
||||
<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
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{config.fields.map((field) => {
|
||||
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
||||
const altKey =
|
||||
field.type === 'image'
|
||||
? field.name === 'src' && 'alt' in item
|
||||
? 'alt'
|
||||
: `${field.name}_alt`
|
||||
: ''
|
||||
return (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
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>
|
||||
|
||||
@@ -150,12 +164,19 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
altValue,
|
||||
altFallback,
|
||||
onSave,
|
||||
onSaveAlt,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
/** مقدار Alt تصویر — فقط برای فیلدهای image */
|
||||
altValue?: string
|
||||
altFallback?: string
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [text, setText] = useState<string>(String(value ?? ''))
|
||||
@@ -167,7 +188,53 @@ function FieldControl({
|
||||
}
|
||||
|
||||
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') {
|
||||
@@ -175,7 +242,11 @@ function FieldControl({
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
return <ListField field={field} value={value} onSave={onSave} />
|
||||
return field.ui === 'tags' ? (
|
||||
<TagsField field={field} value={value} onSave={onSave} />
|
||||
) : (
|
||||
<ListField field={field} value={value} onSave={onSave} />
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
@@ -210,6 +281,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({
|
||||
field,
|
||||
|
||||
@@ -1,37 +1,51 @@
|
||||
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 = {
|
||||
name: string
|
||||
label: string
|
||||
type: FieldType
|
||||
hint?: string
|
||||
/** فقط برای type === 'select' — گزینههای ثابت react-select */
|
||||
options?: string[]
|
||||
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
||||
ui?: 'lines' | 'tags'
|
||||
}
|
||||
|
||||
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): FieldConfig => ({
|
||||
const F = (
|
||||
name: string,
|
||||
label: string,
|
||||
type: FieldType = 'text',
|
||||
hint?: string,
|
||||
extra?: Partial<FieldConfig>,
|
||||
): 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',
|
||||
@@ -57,14 +71,31 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
fields: [
|
||||
F('title', 'عنوان'),
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده', 'textarea'),
|
||||
F('excerpt', 'چکیده (روی کارت مقاله)'),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود'),
|
||||
F('image', 'تصویر شاخص', 'image'),
|
||||
F('category', 'دسته'),
|
||||
F('date', 'تاریخ'),
|
||||
F('read', 'زمان مطالعه'),
|
||||
F('category', 'دستهبندی (Category)', 'select', undefined, { options: ARTICLE_CATEGORIES }),
|
||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags' }),
|
||||
F('author', 'نویسنده'),
|
||||
F('date', 'تاریخ انتشار (شمسی)', 'date'),
|
||||
F('read', 'زمان مطالعه (دقیقه)'),
|
||||
F('slug', 'نشانی (slug)'),
|
||||
],
|
||||
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: '',
|
||||
image_alt: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
seo_keyphrase: '',
|
||||
body: { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||
},
|
||||
},
|
||||
testimonials: {
|
||||
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
|
||||
@@ -132,7 +163,6 @@ 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',
|
||||
@@ -143,8 +173,6 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
],
|
||||
defaults: { page_key: '', title: '', description: '' },
|
||||
},
|
||||
|
||||
// ---------- رکوردهای یگانه ----------
|
||||
brand: {
|
||||
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||
titleKey: 'name',
|
||||
@@ -197,7 +225,6 @@ 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,6 +2,7 @@ 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'
|
||||
|
||||
export function ArticleCard({ article }: { article: Article }) {
|
||||
return (
|
||||
@@ -33,7 +34,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">
|
||||
<span className="flex items-center gap-3">
|
||||
<span>{article.date}</span>
|
||||
<span>{formatJalali(article.date)}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||
{article.read}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3,15 +3,19 @@ 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 {
|
||||
BoldIcon,
|
||||
ItalicIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ImagePlusIcon,
|
||||
LanguagesIcon,
|
||||
Link2Icon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
PilcrowIcon,
|
||||
QuoteIcon,
|
||||
Redo2Icon,
|
||||
StrikethroughIcon,
|
||||
@@ -22,12 +26,20 @@ import {
|
||||
import Swal from 'sweetalert2'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
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 = [
|
||||
StarterKit.configure({
|
||||
link: false,
|
||||
heading: { levels: [2, 3] },
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Image.configure({ allowBase64: false }),
|
||||
Link.configure({
|
||||
@@ -35,6 +47,8 @@ export const ARTICLE_EXTENSIONS = [
|
||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
|
||||
}),
|
||||
SmallSizeMark,
|
||||
EnglishText,
|
||||
]
|
||||
|
||||
type ArticleEditorProps = {
|
||||
@@ -73,10 +87,6 @@ function ToolButton({
|
||||
|
||||
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) {
|
||||
const { runSave } = useAdmin()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
@@ -118,9 +128,14 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
async function uploadAndInsert(file: File) {
|
||||
if (!editor) return
|
||||
setUploading(true)
|
||||
|
||||
// استفاده از ریگکس یونیکد اصلاحشده برای نام فایل و Alt
|
||||
const originalName = file.name.replace(/\.[^.]+$/, '')
|
||||
const cleanAlt = originalName.replace(/[^\w\u0600-\u06FF\s-]/g, '-').replace(/\s+/g, '-').toLowerCase()
|
||||
|
||||
await runSave(async () => {
|
||||
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)
|
||||
}
|
||||
@@ -158,47 +173,32 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
|
||||
return (
|
||||
<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
|
||||
label="بولد"
|
||||
active={editor.isActive('bold')}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
label="پاراگراف (۱۴ پیکسل)"
|
||||
active={editor.isActive('paragraph')}
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
>
|
||||
<BoldIcon className="h-4 w-4" />
|
||||
<PilcrowIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="ایتالیک"
|
||||
active={editor.isActive('italic')}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
label="تیتر ۱ (۱۸ پیکسل)"
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
>
|
||||
<ItalicIcon className="h-4 w-4" />
|
||||
<Heading1Icon 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="تیتر میانی"
|
||||
label="تیتر ۲ (۱۶ پیکسل)"
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
<Heading2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="تیتر کوچک"
|
||||
label="تیتر ۳ (۱۴ پیکسل)"
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
@@ -207,25 +207,54 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
|
||||
<Divider />
|
||||
|
||||
<ToolButton
|
||||
label="لیست نقطهای"
|
||||
active={editor.isActive('bulletList')}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
<select
|
||||
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"
|
||||
value={editor.isActive('smallSize') ? '12' : '14'}
|
||||
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" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="لیست شمارهای"
|
||||
active={editor.isActive('orderedList')}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<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()}
|
||||
>
|
||||
<ToolButton label="نقلقول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||
<QuoteIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
@@ -264,4 +293,4 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
||||
/>
|
||||
</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,
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
@@ -7,33 +7,69 @@ type ImageFieldProps = {
|
||||
label: string
|
||||
value: string
|
||||
onSave: (url: string) => Promise<boolean>
|
||||
/** متن جایگزین (Alt) — با ارسال این prop ورودی Alt نمایش داده میشود */
|
||||
alt?: string
|
||||
/** ذخیره متن جایگزین — بدون آن، Alt فقط برای پیشنمایش و پیشنهاد استفاده میشود */
|
||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||
/** پیشنهاد Alt وقتی خالی است (مثل عنوان مقاله یا نام موجودیت) */
|
||||
altFallback?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش استفاده میشود.
|
||||
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش و صفحه نوشتن مقاله.
|
||||
* پس از آپلود موفق، 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 inputRef = useRef<HTMLInputElement>(null)
|
||||
const altInputId = useId()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [altText, setAltText] = useState(alt ?? '')
|
||||
const showAlt = alt !== undefined || !!onSaveAlt
|
||||
|
||||
// همگامسازی با مقدار ذخیرهشده (پس از بهروزرسانی خوشبینانه در درخت داده)
|
||||
useEffect(() => {
|
||||
setAltText(alt ?? '')
|
||||
}, [alt])
|
||||
|
||||
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) => {
|
||||
if (!file) return
|
||||
const generatedAlt = resolveAlt(file.name)
|
||||
setUploading(true)
|
||||
try {
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
const ok = await onSave(url)
|
||||
if (!ok) throw new Error('ذخیره نشد')
|
||||
// Alt ساختهشده هم ذخیره میشود تا تصویر بدون متن جایگزین نماند
|
||||
if (showAlt && generatedAlt !== (alt ?? '').trim()) {
|
||||
setAltText(generatedAlt)
|
||||
if (onSaveAlt) await onSaveAlt(generatedAlt)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const commitAlt = () => {
|
||||
const next = altText.trim()
|
||||
if (!onSaveAlt || next === (alt ?? '').trim()) return
|
||||
void onSaveAlt(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||
@@ -41,7 +77,7 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
||||
{value ? (
|
||||
<img
|
||||
src={value}
|
||||
alt={label}
|
||||
alt={altText || label}
|
||||
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>}
|
||||
</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
|
||||
ref={inputRef}
|
||||
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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -40,4 +40,12 @@ export type Article = {
|
||||
status: ArticleStatus;
|
||||
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
||||
seo_keyphrase: string;
|
||||
/** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */
|
||||
meta_description?: string;
|
||||
/** متن جایگزین تصویر شاخص (Alt) */
|
||||
image_alt?: string;
|
||||
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده میشود */
|
||||
author?: string;
|
||||
/** هشتگها/برچسبهای مقاله */
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
+93
-7
@@ -7,8 +7,6 @@
|
||||
|
||||
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
|
||||
|
||||
/* فونت سایت سلفهاست است (public/fonts) — هیچ وابستگی خارجی وجود ندارد.
|
||||
Pinar فونت اصلی است (۵ وزن) و Vazirmatn بهعنوان جایگزین پشتیبان میماند. */
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-Light.woff2') format('woff2'),
|
||||
@@ -85,6 +83,7 @@ body {
|
||||
.article-body {
|
||||
line-height: 2.4;
|
||||
word-wrap: break-word;
|
||||
font-size: 14px; /* متن اصلی ۱۴ تنظیم شد */
|
||||
}
|
||||
.article-body p {
|
||||
margin-top: 1.75rem;
|
||||
@@ -93,6 +92,7 @@ body {
|
||||
.article-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.article-body h1,
|
||||
.article-body h2,
|
||||
.article-body h3 {
|
||||
color: #f5f3ef;
|
||||
@@ -100,15 +100,26 @@ 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: 1.6rem;
|
||||
padding-right: 0.85rem;
|
||||
border-right: 3px solid #d5874c;
|
||||
font-size: 16px; /* تیتر ۲ سایز ۱۶ */
|
||||
}
|
||||
.article-body h3 {
|
||||
font-size: 1.25rem;
|
||||
color: #e8c9a8;
|
||||
font-size: 14px; /* تیتر ۳ سایز ۱۴ */
|
||||
}
|
||||
.article-body span[data-small="true"] {
|
||||
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
|
||||
}
|
||||
/* مارک «متن انگلیسی» ویرایشگر — چپبهراست و ۱۲ پیکسل بدون بههمزدن جهت متن فارسی */
|
||||
.english-text {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
font-size: 12px;
|
||||
}
|
||||
.article-body ul,
|
||||
.article-body ol {
|
||||
@@ -179,3 +190,78 @@ 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,10 +1,12 @@
|
||||
import React from 'react';
|
||||
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 { 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';
|
||||
@@ -12,21 +14,21 @@ import { useEffect } from 'react';
|
||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||
import type { Article } from '../data/site';
|
||||
|
||||
/** بدنه مقاله را به HTML تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
||||
function renderBody(article: Article): { html: string | null; legacy: string[] } {
|
||||
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
||||
function renderBody(article: Article): { html: string | null; toc: TocItem[]; legacy: string[] } {
|
||||
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') {
|
||||
try {
|
||||
return {
|
||||
html: renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }),
|
||||
legacy: [],
|
||||
};
|
||||
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never });
|
||||
// تیترهای H2/H3 شناسه لنگر میگیرند تا فهرست مطالب به آنها بپرد
|
||||
const { items, html: htmlWithIds } = extractToc(html);
|
||||
return { html: htmlWithIds, toc: items, legacy: [] };
|
||||
} catch {
|
||||
return { html: null, legacy: [] };
|
||||
return { html: null, toc: [], legacy: [] };
|
||||
}
|
||||
}
|
||||
return { html: null, legacy: [] };
|
||||
return { html: null, toc: [], legacy: [] };
|
||||
}
|
||||
|
||||
export function ArticleDetail() {
|
||||
@@ -39,7 +41,8 @@ export function ArticleDetail() {
|
||||
if (!article) return;
|
||||
applySeo({
|
||||
title: `${article.title} | ${brand}`,
|
||||
description: article.excerpt,
|
||||
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
||||
description: article.meta_description || article.excerpt,
|
||||
path: `/blog/${article.slug}`,
|
||||
image: article.image,
|
||||
type: 'article',
|
||||
@@ -47,10 +50,10 @@ export function ArticleDetail() {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article.title,
|
||||
description: article.excerpt,
|
||||
description: article.meta_description || article.excerpt,
|
||||
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
||||
inLanguage: 'fa',
|
||||
author: { '@type': 'Organization', name: brand },
|
||||
author: { '@type': 'Organization', name: article.author?.trim() || brand },
|
||||
publisher: { '@type': 'Organization', name: brand },
|
||||
mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href,
|
||||
},
|
||||
@@ -70,13 +73,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);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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"
|
||||
@@ -96,36 +103,43 @@ export function ArticleDetail() {
|
||||
<span className="text-copper">{article.category}</span>
|
||||
</nav>
|
||||
<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">
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
{article.read}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* نویسنده، تاریخ جلالی و زمان مطالعه */}
|
||||
<ArticleMeta article={article} className="mt-7" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article className="bg-ink py-20 lg:py-28">
|
||||
<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>
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<div className="mx-auto grid max-w-5xl gap-12 lg:grid-cols-[minmax(0,1fr)_17rem] lg:gap-14">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* فهرست مطالب — در دسکتاپ کنار متن چسبان میماند */}
|
||||
{toc.length > 0 && (
|
||||
<aside className="order-first lg:order-none">
|
||||
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { PlusIcon } from 'lucide-react'
|
||||
|
||||
function DraftBadge({ className = '' }: { className?: string }) {
|
||||
@@ -91,7 +92,7 @@ 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>{featured.date}</span>
|
||||
<span>{formatJalali(featured.date)}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
{featured.read}
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
Reference in New Issue
Block a user