Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a0defc3d4 | ||
|
|
049291c109 | ||
|
|
35c03ef35e | ||
|
|
bb6c586d99 | ||
|
|
20c95e73b3 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
|
|||||||
|
// تست موقت رندر استاتیک و ابزارها — پس از اجرا حذف میشود
|
||||||
|
import { renderToHTMLString } from '@tiptap/static-renderer'
|
||||||
|
import { ARTICLE_EXTENSIONS } from './src/components/admin/ArticleEditor'
|
||||||
|
import { formatReadingTime, readingTimeMinutes } from './src/utils/readingTime'
|
||||||
|
import { buildLinkRel, normalizeHref, parseLinkRel } from './src/utils/link'
|
||||||
|
import { docHeadings, docImages, docToText } from './src/utils/tiptapDoc'
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{ type: 'paragraph', content: [{ type: 'text', text: 'متن نمونه مقاله برای تست رندر' }] },
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
attrs: { src: '/media/a.jpg', alt: 'تصویر تست', title: 'Photo 1', caption: 'کپشن تصویر تست' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'لینک خارجی',
|
||||||
|
marks: [{ type: 'link', attrs: { href: 'https://example.com', target: '_blank', rel: 'noopener noreferrer nofollow' } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'تیتر دوم' }] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: doc as never })
|
||||||
|
|
||||||
|
console.log('--- rendered html ---')
|
||||||
|
console.log(html)
|
||||||
|
console.log('--- checks ---')
|
||||||
|
console.log('has figure:', html.includes('<figure'))
|
||||||
|
console.log('has figcaption:', html.includes('<figcaption') && html.includes('کپشن تصویر تست'))
|
||||||
|
console.log('has img src:', html.includes('src="/media/a.jpg"'))
|
||||||
|
console.log('has alt:', html.includes('alt="تصویر تست"'))
|
||||||
|
console.log('has title:', html.includes('title="Photo 1"'))
|
||||||
|
console.log('link rel/target:', html.includes('rel="noopener noreferrer nofollow"'), html.includes('target="_blank"'))
|
||||||
|
console.log('--- text utils ---')
|
||||||
|
console.log('text:', docToText(doc))
|
||||||
|
console.log('h2:', docHeadings(doc, 2), '| images:', docImages(doc).length)
|
||||||
|
console.log('reading time:', formatReadingTime(doc), '| minutes:', readingTimeMinutes(doc))
|
||||||
|
console.log('--- link helpers ---')
|
||||||
|
console.log('normalize:', normalizeHref('example.com'), '|', normalizeHref('/contact'), '|', normalizeHref('javascript:alert(1)'))
|
||||||
|
console.log('rel:', buildLinkRel({ newTab: true, nofollow: true }), '|', buildLinkRel({ newTab: false, nofollow: false }), '|', parseLinkRel('noopener noreferrer').newTab)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
lib: { entry: '.tmp-render-test.ts', formats: ['cjs'], fileName: () => 'tmp-render-test.cjs' },
|
||||||
|
outDir: '.tmp-render-out',
|
||||||
|
minify: false,
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
+29
-5
@@ -1,11 +1,36 @@
|
|||||||
|
|
||||||
|
|
||||||
# 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 & Smart Bidi Text
|
||||||
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering.
|
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
|
||||||
|
|
||||||
|
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
|
||||||
|
|
||||||
|
## Recent UI/UX Improvements
|
||||||
|
* **Tiptap Editor Fixes**:
|
||||||
|
* Resolved the initialization bug where the editor body remained empty during edit mode.
|
||||||
|
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
|
||||||
|
* **Dynamic TOC & Layout**:
|
||||||
|
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
|
||||||
|
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
|
||||||
|
|
||||||
|
## CMS, SEO & Editor Features
|
||||||
|
|
||||||
|
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
|
||||||
|
|
||||||
|
* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting.
|
||||||
|
* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema.
|
||||||
|
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
|
||||||
|
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
|
||||||
|
* **Custom Tiptap WYSIWYG Editor:**
|
||||||
|
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
|
||||||
|
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls.
|
||||||
|
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
|
||||||
|
* **Full Jalali (Shamsi) Calendar Integration:**
|
||||||
|
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
|
||||||
|
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
@@ -35,4 +60,3 @@ npm run build
|
|||||||
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
|
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
|
||||||
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
|
* `src/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.
|
||||||
|
|
||||||
|
|||||||
Generated
+147
-5
@@ -19,7 +19,11 @@
|
|||||||
"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-hook-form": "^7.88.0",
|
||||||
|
"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 +937,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 +946,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 +955,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 +2021,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 +2773,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 +3753,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 +3886,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 +4263,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 +4456,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 +4475,52 @@
|
|||||||
"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-hook-form": {
|
||||||
|
"version": "7.88.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz",
|
||||||
|
"integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-hook-form"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"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 +4563,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 +4812,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 +5183,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",
|
||||||
|
|||||||
@@ -21,7 +21,11 @@
|
|||||||
"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-hook-form": "^7.88.0",
|
||||||
|
"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": {
|
||||||
|
|||||||
@@ -97,8 +97,10 @@ function AppChrome() {
|
|||||||
function AppShell() {
|
function AppShell() {
|
||||||
const { data, error, reload } = useSiteData();
|
const { data, error, reload } = useSiteData();
|
||||||
|
|
||||||
|
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
||||||
|
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
||||||
return (
|
return (
|
||||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
|
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
||||||
<Preloader />
|
<Preloader />
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Controller, useController, useForm } from 'react-hook-form'
|
||||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
@@ -8,22 +9,49 @@ 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 { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||||
|
import type { CategoryOption } from '../components/admin/LuxeSelect'
|
||||||
|
import { ARTICLE_CATEGORIES } from './entities'
|
||||||
|
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||||
|
import { formatReadingTime } from '../utils/readingTime'
|
||||||
|
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||||
|
import type { Article, ArticleBodyDoc } from '../data/site'
|
||||||
|
|
||||||
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 }
|
||||||
slug: string
|
|
||||||
|
/**
|
||||||
|
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت میشوند:
|
||||||
|
* عنوان (مبنای Alt پویا)، دستهبندی و متادیتای تصویر شاخص.
|
||||||
|
* `category` در فرم یک شیء تکمقداری {value,label} یا null است
|
||||||
|
* که هنگام ذخیره به رشته (ستون category در بکاند) نگاشت میشود.
|
||||||
|
*/
|
||||||
|
type ArticleMetaForm = {
|
||||||
title: string
|
title: string
|
||||||
date: string
|
category: CategoryOption | null
|
||||||
category: string
|
image_alt: string
|
||||||
image: string
|
image_title: string
|
||||||
read: string
|
}
|
||||||
excerpt: string
|
|
||||||
body: ArticleBodyDoc
|
/** مقدار رشتهای ذخیرهشده → گزینه react-select (یا null وقتی خالی است) */
|
||||||
status: 'draft' | 'published'
|
function toCategoryOption(value: string): CategoryOption | null {
|
||||||
seo_keyphrase: string
|
const category = (value ?? '').trim()
|
||||||
|
return category ? { label: category, value: category } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** گزینه react-select → مقدار رشتهای ذخیرهسازی در بکاند */
|
||||||
|
function categoryValue(option: CategoryOption | null): string {
|
||||||
|
return option?.value.trim() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** دادههای قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
|
||||||
|
function normalizeTags(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
|
||||||
|
if (typeof value === 'string') return value.split(',').map((t) => t.trim()).filter(Boolean)
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_TEXT: Record<string, string> = {
|
const STATUS_TEXT: Record<string, string> = {
|
||||||
@@ -50,16 +78,45 @@ export function ArticleWritePage() {
|
|||||||
id: 0,
|
id: 0,
|
||||||
slug: `article-${Date.now()}`,
|
slug: `article-${Date.now()}`,
|
||||||
title: '',
|
title: '',
|
||||||
date: new Date().toLocaleDateString('fa-IR'),
|
date: new Date().toISOString().split('T')[0],
|
||||||
category: 'یادداشت',
|
category: ARTICLE_CATEGORIES[0],
|
||||||
|
tags: [],
|
||||||
image: '',
|
image: '',
|
||||||
|
image_alt: '',
|
||||||
|
image_title: '',
|
||||||
|
author: '',
|
||||||
|
meta_description: '',
|
||||||
read: '۵ دقیقه',
|
read: '۵ دقیقه',
|
||||||
excerpt: '',
|
excerpt: '',
|
||||||
body: EMPTY_DOC,
|
body: EMPTY_DOC,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
seo_keyphrase: '',
|
seo_keyphrase: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فرم متادیتا با React Hook Form — منبع نمایش فیلدها.
|
||||||
|
* هر تغییر، هم فرم و هم draft (مبنای ذخیرهسازی خودکار) را بهروز میکند.
|
||||||
|
*/
|
||||||
|
const { control, watch, reset } = useForm<ArticleMetaForm>({
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
category: toCategoryOption(ARTICLE_CATEGORIES[0]),
|
||||||
|
image_alt: '',
|
||||||
|
image_title: '',
|
||||||
|
},
|
||||||
|
mode: 'onChange',
|
||||||
|
})
|
||||||
|
/** عنوان زنده مقاله — مبنای متن جایگزین (Alt) تصویر شاخص */
|
||||||
|
const watchedTitle = watch('title')
|
||||||
|
const { field: imageAltField } = useController({ name: 'image_alt', control })
|
||||||
|
const { field: imageTitleField } = useController({ name: 'image_title', control })
|
||||||
|
|
||||||
const [createdId, setCreatedId] = useState<number | null>(null)
|
const [createdId, setCreatedId] = useState<number | null>(null)
|
||||||
|
/**
|
||||||
|
* شناسه محتوای بارگذاریشده — با رسیدن مقاله از سرور عوض میشود تا
|
||||||
|
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
|
||||||
|
*/
|
||||||
|
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const draftRef = useRef(draft)
|
const draftRef = useRef(draft)
|
||||||
draftRef.current = draft
|
draftRef.current = draft
|
||||||
@@ -92,9 +149,20 @@ export function ArticleWritePage() {
|
|||||||
if (!article || createdRef.current) return
|
if (!article || createdRef.current) return
|
||||||
setDraft({
|
setDraft({
|
||||||
...article,
|
...article,
|
||||||
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
|
tags: normalizeTags(article.tags),
|
||||||
|
// بدنه ذخیرهشده (سند جدید یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||||
|
body: toArticleBodyDoc(article.body),
|
||||||
})
|
})
|
||||||
setCreatedId(article.id)
|
setCreatedId(article.id)
|
||||||
|
// بدنه ویرایشگر با محتوای بارگذاریشده همگام میشود
|
||||||
|
setLoadedContentKey(article.id)
|
||||||
|
// فرم متادیتا هم با مقادیر مقاله بارگذاریشده همگام میشود
|
||||||
|
reset({
|
||||||
|
title: article.title ?? '',
|
||||||
|
category: toCategoryOption(article.category ?? ''),
|
||||||
|
image_alt: article.image_alt ?? '',
|
||||||
|
image_title: article.image_title ?? '',
|
||||||
|
})
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [article?.id])
|
}, [article?.id])
|
||||||
|
|
||||||
@@ -190,6 +258,13 @@ export function ArticleWritePage() {
|
|||||||
queueSave({ [name]: e.target.value }),
|
queueSave({ [name]: e.target.value }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const metaLength = (draft.meta_description ?? '').trim().length
|
||||||
|
|
||||||
|
// متن جایگزین تصویر شاخص — با watch('title') بهصورت زنده ساخته میشود
|
||||||
|
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' - ')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||||||
{/* نوار بالا */}
|
{/* نوار بالا */}
|
||||||
@@ -270,49 +345,101 @@ export function ArticleWritePage() {
|
|||||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* عنوان */}
|
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند */}
|
||||||
|
<Controller
|
||||||
|
name="title"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
{...textField('title')}
|
{...field}
|
||||||
|
onChange={(event) => {
|
||||||
|
field.onChange(event.target.value)
|
||||||
|
queueSave({ title: event.target.value })
|
||||||
|
}}
|
||||||
placeholder="عنوان مقاله…"
|
placeholder="عنوان مقاله…"
|
||||||
aria-label="عنوان مقاله"
|
aria-label="عنوان مقاله"
|
||||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<div className="mt-4 h-px w-28 bg-copper" />
|
<div className="mt-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={imageAltField.value}
|
||||||
|
altFallback={imageAltFallback}
|
||||||
|
title={imageTitleField.value}
|
||||||
onSave={async (url) => {
|
onSave={async (url) => {
|
||||||
queueSave({ image: url })
|
queueSave({ image: url })
|
||||||
return true
|
return true
|
||||||
}}
|
}}
|
||||||
|
onSaveAlt={async (alt) => {
|
||||||
|
imageAltField.onChange(alt)
|
||||||
|
queueSave({ image_alt: alt })
|
||||||
|
return true
|
||||||
|
}}
|
||||||
|
onSaveTitle={async (imageTitle) => {
|
||||||
|
imageTitleField.onChange(imageTitle)
|
||||||
|
queueSave({ image_title: imageTitle })
|
||||||
|
return true
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* متن */}
|
{/* متن */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<ArticleEditor
|
<ArticleEditor
|
||||||
|
contentKey={loadedContentKey}
|
||||||
doc={draft.body as unknown as Record<string, unknown>}
|
doc={draft.body as unknown as Record<string, unknown>}
|
||||||
onChange={(json) => queueSave({ body: json })}
|
onChange={(json) => {
|
||||||
|
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود (۲۰۰ واژه در دقیقه)
|
||||||
|
queueSave({ body: json, read: formatReadingTime(json) })
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
@@ -324,37 +451,79 @@ 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 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">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
{...textField('excerpt')}
|
{...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"
|
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 className="grid grid-cols-2 gap-5 lg:grid-cols-4">
|
|
||||||
<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"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-light text-ivory/50">تاریخ</label>
|
<label htmlFor="article-author" className="text-xs font-light text-ivory/50">نویسنده</label>
|
||||||
<input
|
<input
|
||||||
|
id="article-author"
|
||||||
type="text"
|
type="text"
|
||||||
{...textField('date')}
|
{...textField('author')}
|
||||||
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"
|
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-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>
|
||||||
|
<div className="mt-2">
|
||||||
|
{/* تکانتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
|
||||||
|
<Controller
|
||||||
|
name="category"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<CategorySelect
|
||||||
|
value={field.value}
|
||||||
|
options={ARTICLE_CATEGORIES}
|
||||||
|
onChange={(option) => {
|
||||||
|
field.onChange(option)
|
||||||
|
queueSave({ category: categoryValue(option) })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-light text-ivory/50">هشتگها / برچسبها</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<LuxeTagsSelect
|
||||||
|
value={normalizeTags(draft.tags)}
|
||||||
|
onChange={(tags) => queueSave({ tags })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="article-date" className="text-xs font-light text-ivory/50">تاریخ انتشار (شمسی)</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<JalaliDatePicker
|
||||||
|
id="article-date"
|
||||||
|
value={draft.date ?? ''}
|
||||||
|
onChange={(isoDate) => queueSave({ date: isoDate })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
disabled
|
||||||
{...textField('read')}
|
{...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>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import Swal from 'sweetalert2'
|
|||||||
import { useSiteData } from '../api'
|
import { useSiteData } from '../api'
|
||||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||||
import { adminApi } from './adminClient'
|
import { adminApi } from './adminClient'
|
||||||
|
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||||
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 { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||||
|
|
||||||
type EntityDrawerProps = {
|
type EntityDrawerProps = {
|
||||||
@@ -111,12 +114,35 @@ 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">
|
||||||
{config.fields.map((field) => (
|
{config.fields.map((field) => {
|
||||||
|
const isImage = field.type === 'image'
|
||||||
|
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
||||||
|
const altKey = isImage
|
||||||
|
? field.name === 'src' && 'alt' in item
|
||||||
|
? 'alt'
|
||||||
|
: `${field.name}_alt`
|
||||||
|
: ''
|
||||||
|
const imageTitleKey = isImage ? `${field.name}_title` : ''
|
||||||
|
// پیشنهاد Alt از عنوان رکورد ساخته میشود (هرگز از URL)
|
||||||
|
const imageAltFallback = [
|
||||||
|
String(item[config.titleKey] ?? '').trim(),
|
||||||
|
field.altFallbackSuffix,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' - ')
|
||||||
|
return (
|
||||||
<FieldControl
|
<FieldControl
|
||||||
key={field.name}
|
key={field.name}
|
||||||
field={field}
|
field={field}
|
||||||
value={item[field.name]}
|
value={item[field.name]}
|
||||||
|
altValue={altKey ? String(item[altKey] ?? '') : undefined}
|
||||||
|
altFallback={imageAltFallback}
|
||||||
|
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
|
||||||
onSave={(value) => saveField(field.name, value)}
|
onSave={(value) => saveField(field.name, value)}
|
||||||
|
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
|
||||||
|
onSaveImageTitle={
|
||||||
|
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
|
||||||
|
}
|
||||||
registerFlush={
|
registerFlush={
|
||||||
field.type === 'wysiwyg'
|
field.type === 'wysiwyg'
|
||||||
? (fn) => {
|
? (fn) => {
|
||||||
@@ -125,7 +151,8 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -150,16 +177,33 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
|||||||
function FieldControl({
|
function FieldControl({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
|
altValue,
|
||||||
|
altFallback,
|
||||||
|
imageTitleValue,
|
||||||
onSave,
|
onSave,
|
||||||
|
onSaveAlt,
|
||||||
|
onSaveImageTitle,
|
||||||
registerFlush,
|
registerFlush,
|
||||||
}: {
|
}: {
|
||||||
field: FieldConfig
|
field: FieldConfig
|
||||||
value: unknown
|
value: unknown
|
||||||
|
/** مقدار Alt تصویر — فقط برای فیلدهای image */
|
||||||
|
altValue?: string
|
||||||
|
altFallback?: string
|
||||||
|
/** مقدار Title تصویر — فقط برای فیلدهای image */
|
||||||
|
imageTitleValue?: string
|
||||||
onSave: (value: unknown) => Promise<boolean>
|
onSave: (value: unknown) => Promise<boolean>
|
||||||
|
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||||
|
onSaveImageTitle?: (title: 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 ?? ''))
|
||||||
|
|
||||||
|
// همگامسازی مقدار محلی با دادهای که از بیرون میآید (بارگذاری/بهروزرسانی خوشبینانه)
|
||||||
|
useEffect(() => {
|
||||||
|
setText(String(value ?? ''))
|
||||||
|
}, [value])
|
||||||
|
|
||||||
const commitText = async () => {
|
const commitText = async () => {
|
||||||
const next = field.type === 'number' ? Number(text) || 0 : text
|
const next = field.type === 'number' ? Number(text) || 0 : text
|
||||||
if (String(value ?? '') === String(next)) return
|
if (String(value ?? '') === String(next)) return
|
||||||
@@ -167,7 +211,61 @@ 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}
|
||||||
|
title={imageTitleValue}
|
||||||
|
onSave={(url) => onSave(url)}
|
||||||
|
onSaveAlt={onSaveAlt}
|
||||||
|
onSaveTitle={onSaveImageTitle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'select') {
|
||||||
|
// مقدار نمایشی از وضعیت محلی میآید تا افزودن/حذف گزینه فوراً دیده شود
|
||||||
|
const selectProps = {
|
||||||
|
value: text,
|
||||||
|
options: field.options ?? [],
|
||||||
|
onChange: (next: string) => {
|
||||||
|
setText(next)
|
||||||
|
void onSave(next)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||||
|
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||||
|
<div className="mt-2">
|
||||||
|
{field.creatable ? (
|
||||||
|
<LuxeCreatableSelect {...selectProps} />
|
||||||
|
) : (
|
||||||
|
<LuxeSelect {...selectProps} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'date') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||||
|
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||||
|
<div className="mt-2">
|
||||||
|
<JalaliDatePicker
|
||||||
|
value={String(value ?? '')}
|
||||||
|
onChange={(isoDate) => {
|
||||||
|
setText(isoDate)
|
||||||
|
void onSave(isoDate)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === 'wysiwyg') {
|
if (field.type === 'wysiwyg') {
|
||||||
@@ -175,7 +273,11 @@ function FieldControl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === 'list') {
|
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') {
|
if (field.type === 'textarea') {
|
||||||
@@ -210,6 +312,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,
|
||||||
@@ -284,10 +414,9 @@ function WysiwygField({
|
|||||||
onSave: (value: unknown) => Promise<boolean>
|
onSave: (value: unknown) => Promise<boolean>
|
||||||
registerFlush?: (fn: () => void) => void
|
registerFlush?: (fn: () => void) => void
|
||||||
}) {
|
}) {
|
||||||
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
|
const [doc, setDoc] = useState<Record<string, unknown>>(
|
||||||
value && typeof value === 'object' && !Array.isArray(value)
|
// بدنه ذخیرهشده (سند جدید، رشته JSON یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||||
? (value as Record<string, unknown>)
|
() => toArticleBodyDoc(value),
|
||||||
: { type: 'doc', content: [{ type: 'paragraph' }] },
|
|
||||||
)
|
)
|
||||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const pending = useRef<Record<string, unknown> | null>(null)
|
const pending = useRef<Record<string, unknown> | null>(null)
|
||||||
|
|||||||
@@ -1,37 +1,56 @@
|
|||||||
import type { SiteData } from '../api'
|
import type { SiteData } from '../api'
|
||||||
|
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||||
|
|
||||||
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[]
|
||||||
|
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
|
||||||
|
creatable?: boolean
|
||||||
|
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
||||||
|
ui?: 'lines' | 'tags'
|
||||||
|
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
|
||||||
|
altFallbackSuffix?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
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): FieldConfig => ({
|
const F = (
|
||||||
|
name: string,
|
||||||
|
label: string,
|
||||||
|
type: FieldType = 'text',
|
||||||
|
hint?: string,
|
||||||
|
extra?: Partial<FieldConfig>,
|
||||||
|
): FieldConfig => ({
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
type,
|
type,
|
||||||
hint,
|
hint,
|
||||||
|
...extra,
|
||||||
})
|
})
|
||||||
|
|
||||||
/** پیکربندی همه منابع قابل ویرایش — هم مجموعهها و هم رکوردهای یگانه */
|
/** دستهبندیهای استاندارد مقاله — با 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',
|
||||||
@@ -57,14 +76,32 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
fields: [
|
fields: [
|
||||||
F('title', 'عنوان'),
|
F('title', 'عنوان'),
|
||||||
F('body', 'متن مقاله', 'wysiwyg'),
|
F('body', 'متن مقاله', 'wysiwyg'),
|
||||||
F('excerpt', 'چکیده', 'textarea'),
|
F('excerpt', 'چکیده (روی کارت مقاله)'),
|
||||||
F('image', 'تصویر شاخص', 'image'),
|
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود'),
|
||||||
F('category', 'دسته'),
|
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX }),
|
||||||
F('date', 'تاریخ'),
|
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true }),
|
||||||
F('read', 'زمان مطالعه'),
|
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags' }),
|
||||||
|
F('author', 'نویسنده'),
|
||||||
|
F('date', 'تاریخ انتشار (شمسی)', 'date'),
|
||||||
|
F('read', 'زمان مطالعه (دقیقه)'),
|
||||||
F('slug', 'نشانی (slug)'),
|
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: '',
|
||||||
|
image_title: '',
|
||||||
|
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',
|
||||||
@@ -132,7 +169,6 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
|
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
|
||||||
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',
|
||||||
@@ -143,8 +179,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',
|
||||||
@@ -197,7 +231,6 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */
|
|
||||||
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
|
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
|
return data[config.dataKey as keyof SiteData] as never
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@ 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 { toPersianDigits } from '../utils/digits'
|
||||||
|
|
||||||
export function ArticleCard({ article }: { article: Article }) {
|
export function ArticleCard({ article }: { article: Article }) {
|
||||||
return (
|
return (
|
||||||
@@ -33,10 +35,10 @@ export function ArticleCard({ article }: { article: Article }) {
|
|||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
<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}
|
{toPersianDigits(article.read)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { CalendarIcon, ClockIcon } from 'lucide-react'
|
||||||
|
import type { Article } from '../data/site'
|
||||||
|
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
|
||||||
|
import { toPersianDigits } from '../utils/digits'
|
||||||
|
|
||||||
|
type ArticleMetaProps = {
|
||||||
|
article: Article
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سربرگ نویسنده/تاریخ مقاله — زیر تیتر اصلی نمایش داده میشود:
|
||||||
|
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
|
||||||
|
*/
|
||||||
|
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
|
||||||
|
const rawDate = (article.date ?? '').trim()
|
||||||
|
const author = (article.author ?? '').trim() || 'گروه ساختمانی آرامش'
|
||||||
|
const initial = author.charAt(0)
|
||||||
|
|
||||||
|
// تاریخ ذخیرهشده (میلادی) به شمسی تبدیل میشود؛ در نبود تاریخ، امروز با ساعت نمایش داده میشود
|
||||||
|
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
|
||||||
|
const dateLabel = formatJalali(rawDate, todayLabel)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
|
||||||
|
{/* آواتار و نام نویسنده */}
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-lg font-bold text-copper shadow-[0_0_20px_rgba(213,135,76,0.25),inset_0_1px_1px_rgba(255,255,255,0.2)]">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span className="text-sm font-bold text-ivory">{author}</span>
|
||||||
|
<span className="text-[11px] font-light text-ivory/45">نویسنده</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="hidden h-9 w-px bg-ivory/10 sm:block" aria-hidden="true" />
|
||||||
|
|
||||||
|
{/* تاریخ جلالی */}
|
||||||
|
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||||
|
<CalendarIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||||
|
{dateLabel}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* زمان مطالعه — با ارقام فارسی */}
|
||||||
|
{article.read && (
|
||||||
|
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||||
|
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||||
|
{toPersianDigits(article.read)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { MouseEvent } from 'react'
|
||||||
|
import { ListIcon } from 'lucide-react'
|
||||||
|
import { slugifyFa, uniqueSlug } from '../utils/slug'
|
||||||
|
|
||||||
|
export type TocItem = { id: string; text: string; level: 2 | 3 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* متن یک تیتر از HTML — همه گرههای تودرتو پیمایش میشوند و <br> به فاصله تبدیل
|
||||||
|
* میشود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
|
||||||
|
*/
|
||||||
|
function headingText(element: Element): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
element.childNodes.forEach((node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
|
||||||
|
else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const child = node as Element
|
||||||
|
parts.push(child.tagName === 'BR' ? ' ' : headingText(child))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return parts.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* استخراج فهرست مطالب از HTML ویرایشگر — همه تیترهای H2 و H3 در هر عمقی از سند
|
||||||
|
* (حتی داخل نقلقول) شناسه لنگر میگیرند؛ متن فارسی در شناسه حفظ میشود و تیترهای
|
||||||
|
* تکراری پسوند عددی میگیرند. HTML برگشتی همان است که باید رندر شود.
|
||||||
|
*/
|
||||||
|
export function extractToc(
|
||||||
|
html: string,
|
||||||
|
transform?: (root: HTMLElement) => void,
|
||||||
|
): { items: TocItem[]; html: string } {
|
||||||
|
if (!html || typeof window === 'undefined') return { items: [], html }
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
const used = new Map<string, number>()
|
||||||
|
const items: TocItem[] = []
|
||||||
|
|
||||||
|
doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
|
||||||
|
const text = headingText(element).replace(/\s+/g, ' ').trim()
|
||||||
|
if (!text) return
|
||||||
|
// تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی میگیرد تا لنگرش بینام نماند
|
||||||
|
const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
|
||||||
|
element.setAttribute('id', id)
|
||||||
|
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// تبدیل اختیاری (بستهبندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا میشود
|
||||||
|
transform?.(doc.body)
|
||||||
|
|
||||||
|
return { items, html: doc.body.innerHTML }
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableOfContentsProps = {
|
||||||
|
items: TocItem[]
|
||||||
|
title?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فهرست مطالب کلیکپذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
|
||||||
|
* همه اندازهها و ترازبندیها منطقیاند (start/end) تا در چیدمان راستبهچپ و
|
||||||
|
* چپبهراست — بر اساس جهت مقاله — درست بنشیند.
|
||||||
|
*/
|
||||||
|
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
|
||||||
|
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveId(items[0]?.id ?? '')
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!items.length || typeof IntersectionObserver === 'undefined') return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((entry) => entry.isIntersecting)
|
||||||
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||||
|
const first = visible[0]?.target.id
|
||||||
|
if (first) setActiveId(first)
|
||||||
|
},
|
||||||
|
{ rootMargin: '-15% 0px -70% 0px', threshold: [0, 1] },
|
||||||
|
)
|
||||||
|
items.forEach((item) => {
|
||||||
|
const el = document.getElementById(item.id)
|
||||||
|
if (el) observer.observe(el)
|
||||||
|
})
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
const go = (event: MouseEvent<HTMLButtonElement>, id: string) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const el = document.getElementById(id)
|
||||||
|
if (!el) return
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
window.history.replaceState(null, '', `#${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="فهرست مطالب"
|
||||||
|
className={`rounded-2xl border border-white/10 bg-char/60 p-6 backdrop-blur-xl shadow-[0_20px_50px_rgba(0,0,0,0.35),inset_0_1px_1px_rgba(255,255,255,0.08)] ${className}`}
|
||||||
|
>
|
||||||
|
<p className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||||
|
<ListIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
|
||||||
|
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود میشود تا بریده نشود */}
|
||||||
|
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive = item.id === activeId
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => go(event, item.id)}
|
||||||
|
aria-current={isActive ? 'location' : undefined}
|
||||||
|
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
|
||||||
|
item.level === 3 ? 'ps-4 text-[13px]' : ''
|
||||||
|
} ${
|
||||||
|
isActive
|
||||||
|
? 'font-normal text-copper'
|
||||||
|
: 'text-ivory/60 hover:text-copper'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,47 +1,107 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { EditorContent, useEditor } from '@tiptap/react'
|
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 Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
|
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||||
import {
|
import {
|
||||||
|
AlignLeftIcon,
|
||||||
|
AlignRightIcon,
|
||||||
BoldIcon,
|
BoldIcon,
|
||||||
ItalicIcon,
|
Heading1Icon,
|
||||||
Heading2Icon,
|
Heading2Icon,
|
||||||
Heading3Icon,
|
Heading3Icon,
|
||||||
ImagePlusIcon,
|
ImagePlusIcon,
|
||||||
|
ItalicIcon,
|
||||||
Link2Icon,
|
Link2Icon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
ListOrderedIcon,
|
ListOrderedIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
PilcrowIcon,
|
||||||
QuoteIcon,
|
QuoteIcon,
|
||||||
Redo2Icon,
|
Redo2Icon,
|
||||||
StrikethroughIcon,
|
StrikethroughIcon,
|
||||||
UnderlineIcon,
|
UnderlineIcon,
|
||||||
Undo2Icon,
|
Undo2Icon,
|
||||||
Loader2Icon,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
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 { DEFAULT_IMAGE_ALT } from '../../data/constants'
|
||||||
|
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
|
||||||
|
import { ArticleImage } from './ArticleImage'
|
||||||
|
import { BidiTypography, bidiPluginKey } from './BidiTypography'
|
||||||
|
import { EnglishText } from './EnglishText'
|
||||||
|
import { LinkModal } from './LinkModal'
|
||||||
|
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
|
||||||
|
import type { LinkModalValue } from './LinkModal'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مارکهای قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آنها از نوار ابزار برداشته شده و
|
||||||
|
* فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شدهاند.
|
||||||
|
*/
|
||||||
|
const LegacySmallSizeMark = Mark.create({
|
||||||
|
name: 'smallSize',
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'span[data-small]' }]
|
||||||
|
},
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
/** اکستنشنهای مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
|
/** اکستنشنهای مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
|
||||||
export const ARTICLE_EXTENSIONS = [
|
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 }),
|
ArticleImage.configure({ allowBase64: false }),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||||
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
|
isAllowedUri: (uri: string) => isAllowedHref(uri),
|
||||||
}),
|
}),
|
||||||
|
LegacySmallSizeMark,
|
||||||
|
EnglishText,
|
||||||
|
BidiTypography,
|
||||||
]
|
]
|
||||||
|
|
||||||
type ArticleEditorProps = {
|
type ArticleEditorProps = {
|
||||||
doc: Record<string, unknown>
|
doc: Record<string, unknown>
|
||||||
onChange: (json: Record<string, unknown>) => void
|
onChange: (json: Record<string, unknown>) => void
|
||||||
|
/**
|
||||||
|
* شناسه محتوا — با هر تغییر آن، بدنه ویرایشگر از `doc` جاری دوباره بارگذاری میشود
|
||||||
|
* (مثلاً وقتی مقاله از سرور میرسد). بدون این prop محتوا فقط یکبار مقداردهی میشود.
|
||||||
|
*/
|
||||||
|
contentKey?: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمیگرداند */
|
||||||
|
type EditorInstance = NonNullable<ReturnType<typeof useEditor>>
|
||||||
|
|
||||||
|
type TextDirection = 'rtl' | 'ltr'
|
||||||
|
|
||||||
|
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپشده */
|
||||||
|
type LinkApplyMode = 'selection' | 'insert' | 'range'
|
||||||
|
|
||||||
|
type LinkModalState = {
|
||||||
|
open: boolean
|
||||||
|
editing: boolean
|
||||||
|
mode: LinkApplyMode
|
||||||
|
range: { from: number; to: number } | null
|
||||||
|
initial: LinkModalValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSED_LINK_MODAL: LinkModalState = {
|
||||||
|
open: false,
|
||||||
|
editing: false,
|
||||||
|
mode: 'selection',
|
||||||
|
range: null,
|
||||||
|
initial: { href: '', newTab: true, nofollow: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
||||||
|
const URL_LOOKBACK = 300
|
||||||
|
|
||||||
function ToolButton({
|
function ToolButton({
|
||||||
onClick,
|
onClick,
|
||||||
active = false,
|
active = false,
|
||||||
@@ -58,6 +118,7 @@ function ToolButton({
|
|||||||
type="button"
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-pressed={active}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
|
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
|
||||||
@@ -74,13 +135,15 @@ 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
|
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشندار، مودال لینک سئو-محور،
|
||||||
* (انتخاب فایل، چسباندن یا کشیدن تصویر داخل متن).
|
* کلید راستچین/چپچین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
|
||||||
*/
|
*/
|
||||||
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
|
||||||
const { runSave } = useAdmin()
|
const { runSave } = useAdmin()
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [direction, setDirection] = useState<TextDirection>('rtl')
|
||||||
|
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: ARTICLE_EXTENSIONS,
|
extensions: ARTICLE_EXTENSIONS,
|
||||||
@@ -89,16 +152,30 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
|||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
dir: 'rtl',
|
dir: 'rtl',
|
||||||
|
lang: 'fa',
|
||||||
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
||||||
'data-wysiwyg': 'true',
|
'data-wysiwyg': 'true',
|
||||||
},
|
},
|
||||||
handlePaste: (_event_ignored, event) => {
|
handlePaste: (_view, event, _slice) => {
|
||||||
const file = event.clipboardData?.files?.[0]
|
const file = event.clipboardData?.files?.[0]
|
||||||
if (file && file.type.startsWith('image/')) {
|
if (file && file.type.startsWith('image/')) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void uploadAndInsert(file)
|
void uploadAndInsert(file)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// چسباندن یک نشانی → مودال لینک بلافاصله باز میشود
|
||||||
|
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
|
||||||
|
if (isUrlLike(text)) {
|
||||||
|
event.preventDefault()
|
||||||
|
setLinkModal({
|
||||||
|
open: true,
|
||||||
|
editing: false,
|
||||||
|
mode: 'insert',
|
||||||
|
range: null,
|
||||||
|
initial: { href: normalizeHref(text) ?? text, newTab: true, nofollow: false },
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
handleDrop: (_view, event, _slice, moved) => {
|
handleDrop: (_view, event, _slice, moved) => {
|
||||||
@@ -111,127 +188,203 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
|
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
||||||
|
handleTextInput: (view, from, to, text) => {
|
||||||
|
if (text !== ' ' || from !== to) return false
|
||||||
|
const start = Math.max(0, from - URL_LOOKBACK)
|
||||||
|
const before = view.state.doc.textBetween(start, from, ' ', ' ').trimEnd()
|
||||||
|
const lastToken = before.slice(before.lastIndexOf(' ') + 1)
|
||||||
|
if (!isUrlLike(lastToken)) return false
|
||||||
|
setLinkModal({
|
||||||
|
open: true,
|
||||||
|
editing: false,
|
||||||
|
mode: 'range',
|
||||||
|
range: { from: from - lastToken.length, to: from },
|
||||||
|
initial: { href: normalizeHref(lastToken) ?? lastToken, newTab: true, nofollow: false },
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
// Enter داخل تیتر، بلوک تازه را پاراگراف میکند تا قالب تیتر سرایت نکند
|
||||||
|
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
|
||||||
},
|
},
|
||||||
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// جهت متن فقط روی ناحیه ویرایش اعمال میشود؛ نوار ابزار همیشه راستچین میماند
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
editor.view.dom.setAttribute('dir', direction)
|
||||||
|
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
|
||||||
|
// تزئینهای دوزبانه (اندازه ۲ پیکسل کوچکتر متن لاتین) با جهت تازه بازمحاسبه میشوند
|
||||||
|
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, direction))
|
||||||
|
}, [editor, direction])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ترکیب (نمونه ویرایشگر، شناسه محتوا) که آخرین بار بارگذاری شده است.
|
||||||
|
* تغییرهای معمول سند (تایپ کاربر) نباید بدنه را از نو بارگذاری کنند.
|
||||||
|
*/
|
||||||
|
const loadedContentRef = useRef<{ editor: EditorInstance; key: string | number } | null>(null)
|
||||||
|
|
||||||
|
// بارگذاری بدنه با تغییر شناسه محتوا (رسیدن مقاله از سرور) یا ساختهشدن نمونه ویرایشگر
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || contentKey === undefined) return
|
||||||
|
const loaded = loadedContentRef.current
|
||||||
|
if (loaded && loaded.editor === editor && loaded.key === contentKey) return
|
||||||
|
const incoming = doc && Object.keys(doc).length ? doc : null
|
||||||
|
if (!incoming) return
|
||||||
|
loadedContentRef.current = { editor, key: contentKey }
|
||||||
|
// emitUpdate=false تا بارگذاری، ذخیره خودکارِ بیمورد را فعال نکند
|
||||||
|
editor.commands.setContent(incoming, { emitUpdate: false })
|
||||||
|
}, [editor, contentKey, doc])
|
||||||
|
|
||||||
async function uploadAndInsert(file: File) {
|
async function uploadAndInsert(file: File) {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
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()
|
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
||||||
|
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
|
||||||
|
.run()
|
||||||
})
|
})
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setLink = () => {
|
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
|
||||||
|
const openLinkModalForSelection = () => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
if (editor.isActive('link')) {
|
const attrs = editor.getAttributes('link') as {
|
||||||
editor.chain().focus().unsetLink().run()
|
href?: string | null
|
||||||
return
|
target?: string | null
|
||||||
|
rel?: string | null
|
||||||
}
|
}
|
||||||
void Swal.fire({
|
const editing = Boolean(attrs.href)
|
||||||
title: 'نشانی لینک',
|
setLinkModal({
|
||||||
input: 'text',
|
open: true,
|
||||||
inputValue: 'https://',
|
editing,
|
||||||
background: '#1e1a16',
|
mode: 'selection',
|
||||||
color: '#F5F3EF',
|
range: null,
|
||||||
showCancelButton: true,
|
initial: {
|
||||||
confirmButtonText: 'ثبت',
|
href: attrs.href ?? '',
|
||||||
cancelButtonText: 'انصراف',
|
newTab: editing ? attrs.target === '_blank' : true,
|
||||||
buttonsStyling: false,
|
nofollow: editing ? parseLinkRel(attrs.rel).nofollow : false,
|
||||||
customClass: {
|
|
||||||
popup: 'font-pinar border border-ivory/10',
|
|
||||||
title: 'font-pinar font-bold text-ivory',
|
|
||||||
input: 'font-pinar',
|
|
||||||
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5 mx-2',
|
|
||||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
|
||||||
},
|
},
|
||||||
}).then((result) => {
|
|
||||||
if (!result.isConfirmed || !result.value) return
|
|
||||||
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyLink = ({ href, newTab, nofollow }: LinkModalValue) => {
|
||||||
|
if (!editor) return
|
||||||
|
const linkAttrs = { href, target: newTab ? '_blank' : null, rel: buildLinkRel({ newTab, nofollow }) }
|
||||||
|
const chain = editor.chain().focus()
|
||||||
|
|
||||||
|
if (linkModal.mode === 'insert') {
|
||||||
|
// نشانی چسباندهشده: متن لینک تازه درج میشود
|
||||||
|
chain.insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: linkAttrs }] }).run()
|
||||||
|
} else if (linkModal.mode === 'range' && linkModal.range) {
|
||||||
|
// نشانی تایپشده: همان محدوده به لینک تبدیل میشود
|
||||||
|
chain.setTextSelection(linkModal.range).extendMarkRange('link').setLink(linkAttrs).run()
|
||||||
|
} else {
|
||||||
|
chain.extendMarkRange('link').setLink(linkAttrs).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
setLinkModal((state) => ({ ...state, open: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeLink = () => {
|
||||||
|
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
|
setLinkModal((state) => ({ ...state, open: false }))
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor) return null
|
if (!editor) return null
|
||||||
|
|
||||||
return (
|
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="border border-ivory/15 bg-char" data-dir={direction}>
|
||||||
<ToolButton
|
{/* نوار ابزار: همیشه راستچین و در بالای ویرایشگر ثابت میماند */}
|
||||||
label="بولد"
|
<div
|
||||||
active={editor.isActive('bold')}
|
dir="rtl"
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
className="sticky top-14 z-20 flex flex-wrap items-center gap-1 border-b border-ivory/10 bg-char px-2 py-1.5"
|
||||||
>
|
>
|
||||||
<BoldIcon className="h-4 w-4" />
|
<ToolButton
|
||||||
|
label="پاراگراف"
|
||||||
|
active={editor.isActive('paragraph')}
|
||||||
|
onClick={() => applyBlockFormat(editor.view, 'paragraph')}
|
||||||
|
>
|
||||||
|
<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={() => applyBlockFormat(editor.view, 1)}
|
||||||
>
|
>
|
||||||
<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={() => applyBlockFormat(editor.view, 2)}
|
||||||
>
|
>
|
||||||
<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={() => applyBlockFormat(editor.view, 3)}
|
||||||
>
|
>
|
||||||
<Heading3Icon className="h-4 w-4" />
|
<Heading3Icon className="h-4 w-4" />
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
{/* جهت متن — فقط محتوای ویرایشگر را عوض میکند */}
|
||||||
<ToolButton
|
<ToolButton
|
||||||
label="لیست نقطهای"
|
label="راستچین (فارسی)"
|
||||||
active={editor.isActive('bulletList')}
|
active={direction === 'rtl'}
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
onClick={() => setDirection('rtl')}
|
||||||
>
|
>
|
||||||
|
<AlignRightIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
label="چپچین (انگلیسی)"
|
||||||
|
active={direction === 'ltr'}
|
||||||
|
onClick={() => setDirection('ltr')}
|
||||||
|
>
|
||||||
|
<AlignLeftIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<ToolButton label="لینک" active={editor.isActive('link')} onClick={setLink}>
|
<ToolButton label="افزودن لینک" active={editor.isActive('link')} onClick={openLinkModalForSelection}>
|
||||||
<Link2Icon className="h-4 w-4" />
|
<Link2Icon className="h-4 w-4" />
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||||||
@@ -263,5 +416,17 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
||||||
|
{linkModal.open && (
|
||||||
|
<LinkModal
|
||||||
|
initial={linkModal.initial}
|
||||||
|
editing={linkModal.editing}
|
||||||
|
onApply={applyLink}
|
||||||
|
onRemove={removeLink}
|
||||||
|
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import { mergeAttributes } from '@tiptap/core'
|
||||||
|
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
|
import { ImageNodeView } from './ImageNodeView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تصویر مقاله با کپشن.
|
||||||
|
* خروجی HTML در سایت: <figure><img><figcaption>کپشن</figcaption></figure>
|
||||||
|
* و در ویرایشگر با NodeView سفارشی (ورودی کپشن + دکمه حذف) نمایش داده میشود.
|
||||||
|
*/
|
||||||
|
export const ArticleImage = Image.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
// ویژگیهای استاندارد تصویر (src/alt/title/width/height) از افزونه پایه
|
||||||
|
...this.parent?.(),
|
||||||
|
caption: {
|
||||||
|
default: '',
|
||||||
|
parseHTML: (element) =>
|
||||||
|
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'figure',
|
||||||
|
priority: 60,
|
||||||
|
getAttrs: (element) => {
|
||||||
|
if (!(element instanceof HTMLElement)) return false
|
||||||
|
const img = element.querySelector('img')
|
||||||
|
if (!img) return false
|
||||||
|
return {
|
||||||
|
src: img.getAttribute('src'),
|
||||||
|
alt: img.getAttribute('alt'),
|
||||||
|
title: img.getAttribute('title'),
|
||||||
|
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// تصاویر بدون figure (محتوای قدیمی یا چسباندهشده)
|
||||||
|
{ tag: 'img[src]:not([src^="data:"])' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
const { caption, ...imageAttributes } = HTMLAttributes
|
||||||
|
const image: [string, Record<string, unknown>] = [
|
||||||
|
'img',
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, imageAttributes),
|
||||||
|
]
|
||||||
|
|
||||||
|
// کپشن خالی → عنصر figcaption اصلاً رندر نمیشود (فقط تصویر میماند)
|
||||||
|
return caption
|
||||||
|
? [
|
||||||
|
'figure',
|
||||||
|
{ class: 'article-figure' },
|
||||||
|
image,
|
||||||
|
['figcaption', { class: 'article-figure__caption' }, caption],
|
||||||
|
]
|
||||||
|
: ['figure', { class: 'article-figure' }, image]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(ImageNodeView)
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
import type { EditorState } from '@tiptap/pm/state'
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
|
import { LATIN_CLASS, RTL_CLASS, findScriptRuns } from '../../utils/bidiText'
|
||||||
|
|
||||||
|
/** نام مارکهای قدیمی که خودشان اندازه/جهت متن را تعیین میکنند */
|
||||||
|
const LEGACY_MARKS = new Set(['englishText', 'smallSize'])
|
||||||
|
|
||||||
|
type BidiState = { dir: 'rtl' | 'ltr' }
|
||||||
|
|
||||||
|
/** کلید پلاگین — جهت متن با متای همین کلید بهروز میشود */
|
||||||
|
export const bidiPluginKey = new PluginKey<BidiState>('bidiTypography')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تزئین دوزبانه متن ویرایشگر — اجرای لاتین (و در متن انگلیسی، اجرای فارسی) با یک
|
||||||
|
* span جهتدار نشانهگذاری میشود تا ویرایشگر هم دقیقاً همان اندازه «۲ پیکسل کوچکتر»
|
||||||
|
* خروجی سایت را نشان دهد. تزئینها سند را تغییر نمیدهند و ذخیره نمیشوند.
|
||||||
|
*/
|
||||||
|
export const BidiTypography = Extension.create({
|
||||||
|
name: 'bidiTypography',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin<BidiState>({
|
||||||
|
key: bidiPluginKey,
|
||||||
|
state: {
|
||||||
|
init: () => ({ dir: 'rtl' }),
|
||||||
|
apply: (tr, value) => {
|
||||||
|
const dir = tr.getMeta(bidiPluginKey) as BidiState['dir'] | undefined
|
||||||
|
return dir && dir !== value.dir ? { dir } : value
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations: (state) =>
|
||||||
|
buildDecorations(state, bidiPluginKey.getState(state)?.dir ?? 'rtl'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildDecorations(state: EditorState, dir: 'rtl' | 'ltr'): DecorationSet {
|
||||||
|
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
|
||||||
|
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
|
||||||
|
const decorations: Decoration[] = []
|
||||||
|
const { doc, schema } = state
|
||||||
|
|
||||||
|
doc.descendants((node, pos, parent) => {
|
||||||
|
if (node.type.name === 'codeBlock' || parent?.type.name === 'codeBlock') return false
|
||||||
|
if (!node.isText || !node.text) return
|
||||||
|
if (node.marks.some((mark) => mark.type === schema.marks.code || LEGACY_MARKS.has(mark.type.name))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const run of findScriptRuns(node.text, dir)) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.inline(pos + run.from, pos + run.to, { class: className, dir: runDir }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations)
|
||||||
|
}
|
||||||
@@ -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,23 +1,71 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useId, useRef, useState } from 'react'
|
||||||
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||||
|
import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
|
||||||
import { adminApi } from '../../admin/adminClient'
|
import { adminApi } from '../../admin/adminClient'
|
||||||
import { useAdmin } from '../../admin/AdminContext'
|
import { useAdmin } from '../../admin/AdminContext'
|
||||||
|
import { useSmartTextField } from './useSmartTextField'
|
||||||
|
|
||||||
type ImageFieldProps = {
|
type ImageFieldProps = {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
onSave: (url: string) => Promise<boolean>
|
onSave: (url: string) => Promise<boolean>
|
||||||
|
/** متن جایگزین (Alt) */
|
||||||
|
alt?: string
|
||||||
|
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||||
|
/** عنوان تصویر (Title) */
|
||||||
|
title?: string
|
||||||
|
onSaveTitle?: (title: string) => Promise<boolean>
|
||||||
|
/**
|
||||||
|
* پیشنهاد Alt وقتی کاربر چیزی ننوشته — مثل «{عنوان مقاله} - تصویر شاخص».
|
||||||
|
* هرگز از URL یا نام فایل ساخته نمیشود.
|
||||||
|
*/
|
||||||
|
altFallback?: string
|
||||||
|
/** پیشنهاد عنوان تصویر — پیشفرض «Photo 1» */
|
||||||
|
titleFallback?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const META_FIELD_CLASS =
|
||||||
|
'mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش استفاده میشود.
|
* انتخاب و آپلود تصویر به MinIO با پیشنمایش بزرگ.
|
||||||
* پس از آپلود موفق، URL نسبی /media/... ذخیره میشود.
|
* ترتیب فیلدها: ۱) عنوان تصویر (پیشفرض «Photo 1») ۲) متن جایگزین (پیشفرض «{عنوان} - تصویر شاخص»).
|
||||||
|
* هر دو «دستی یا پیشفرض» هستند و Alt هرگز از URL ساخته نمیشود.
|
||||||
|
* پیشفرض خودکار با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمیگردد.
|
||||||
*/
|
*/
|
||||||
export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
export function ImageField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
alt,
|
||||||
|
onSaveAlt,
|
||||||
|
title,
|
||||||
|
onSaveTitle,
|
||||||
|
altFallback = '',
|
||||||
|
titleFallback = '',
|
||||||
|
}: ImageFieldProps) {
|
||||||
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const titleId = useId()
|
||||||
|
const altId = useId()
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
|
|
||||||
|
const showTitle = title !== undefined || !!onSaveTitle
|
||||||
|
const showAlt = alt !== undefined || !!onSaveAlt
|
||||||
|
|
||||||
|
const titleField = useSmartTextField({
|
||||||
|
value: title ?? '',
|
||||||
|
fallback: titleFallback,
|
||||||
|
hardFallback: DEFAULT_IMAGE_TITLE,
|
||||||
|
onCommit: onSaveTitle,
|
||||||
|
})
|
||||||
|
const altField = useSmartTextField({
|
||||||
|
value: alt ?? '',
|
||||||
|
fallback: altFallback,
|
||||||
|
hardFallback: label,
|
||||||
|
onCommit: onSaveAlt,
|
||||||
|
})
|
||||||
|
|
||||||
if (!isAuthenticated || !isEditMode) return null
|
if (!isAuthenticated || !isEditMode) return null
|
||||||
|
|
||||||
const pick = async (file: File | undefined) => {
|
const pick = async (file: File | undefined) => {
|
||||||
@@ -28,6 +76,9 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
|||||||
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/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
||||||
|
if (showTitle) await titleField.commit()
|
||||||
|
if (showAlt) await altField.commit()
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
@@ -37,19 +88,25 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
|||||||
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>
|
||||||
<div className="mt-2 flex items-center gap-3">
|
|
||||||
|
{/* پیشنمایش بزرگ تصویر */}
|
||||||
|
<div className="mt-2 overflow-hidden border border-ivory/15 bg-char">
|
||||||
{value ? (
|
{value ? (
|
||||||
<img
|
<img
|
||||||
src={value}
|
src={value}
|
||||||
alt={label}
|
alt={altField.display || label}
|
||||||
className="h-16 w-24 border border-ivory/15 object-cover"
|
title={titleField.display || undefined}
|
||||||
|
className="h-[27rem] w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
|
<div className="flex h-[27rem] w-full flex-col items-center justify-center gap-2 border border-dashed border-ivory/20 text-ivory/30">
|
||||||
—
|
<ImageIcon className="h-7 w-7" aria-hidden="true" />
|
||||||
|
<span className="text-[11px] font-light">تصویری انتخاب نشده است</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
@@ -61,19 +118,62 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
|
|||||||
) : (
|
) : (
|
||||||
<ImagePlusIcon className="h-3.5 w-3.5" />
|
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
|
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
|
||||||
</button>
|
</button>
|
||||||
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(showTitle || showAlt) && (
|
||||||
|
<div className="mt-4 grid max-w-md gap-4 border-t border-ivory/10 pt-4 sm:grid-cols-2">
|
||||||
|
{showTitle && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor={titleId} className="text-[11px] font-light text-ivory/50">
|
||||||
|
عنوان تصویر (Title)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={titleId}
|
||||||
|
type="text"
|
||||||
|
dir="auto"
|
||||||
|
value={titleField.display}
|
||||||
|
onChange={(event) => titleField.onChange(event.target.value)}
|
||||||
|
onFocus={titleField.onFocus}
|
||||||
|
onBlur={titleField.onBlur}
|
||||||
|
placeholder={titleFallback.trim() || DEFAULT_IMAGE_TITLE}
|
||||||
|
className={META_FIELD_CLASS}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAlt && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor={altId} className="text-[11px] font-light text-ivory/50">
|
||||||
|
متن جایگزین (Alt)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={altId}
|
||||||
|
type="text"
|
||||||
|
dir="auto"
|
||||||
|
value={altField.display}
|
||||||
|
onChange={(event) => altField.onChange(event.target.value)}
|
||||||
|
onFocus={altField.onFocus}
|
||||||
|
onBlur={altField.onBlur}
|
||||||
|
placeholder={
|
||||||
|
altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحهخوانها'
|
||||||
|
}
|
||||||
|
className={META_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(event) => {
|
||||||
void pick(e.target.files?.[0])
|
void pick(event.target.files?.[0])
|
||||||
e.target.value = ''
|
event.target.value = ''
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { NodeViewWrapper } from '@tiptap/react'
|
||||||
|
import type { NodeViewProps } from '@tiptap/react'
|
||||||
|
import { PlusIcon, XIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
type ImageAttrs = {
|
||||||
|
src: string
|
||||||
|
alt: string | null
|
||||||
|
title: string | null
|
||||||
|
caption: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const DELETE_BUTTON_CLASS =
|
||||||
|
'flex h-7 w-7 shrink-0 items-center justify-center border border-red-400/50 bg-ink/85 text-red-300 backdrop-blur-sm transition-colors duration-150 hover:bg-red-500/20'
|
||||||
|
|
||||||
|
const INLINE_FIELD_CLASS =
|
||||||
|
'w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||||
|
|
||||||
|
const ADD_CAPTION_CLASS =
|
||||||
|
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
|
||||||
|
* <figure><img><figcaption>کپشن</figcaption></figure>
|
||||||
|
*
|
||||||
|
* ویرایش کاملاً درونخطی است (بدون پاپاور/مودال):
|
||||||
|
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره میشود.
|
||||||
|
* - دکمه X کپشن را کامل حذف میکند: صفت کپشن به مقدار پیشفرض برمیگردد و عنصر
|
||||||
|
* figcaption با ورودیاش از DOM ویرایشگر (و از خروجی ذخیرهشده) حذف میشود؛
|
||||||
|
* فقط تصویر میماند و «افزودن کپشن» دوباره جعبه را باز میکند.
|
||||||
|
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت میشود.
|
||||||
|
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
||||||
|
*/
|
||||||
|
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
||||||
|
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
||||||
|
const hasCaption = Boolean((caption ?? '').trim())
|
||||||
|
const [captionOpen, setCaptionOpen] = useState(true)
|
||||||
|
const captionInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const focusOnOpen = useRef(false)
|
||||||
|
const showCaption = captionOpen || hasCaption
|
||||||
|
|
||||||
|
// فقط وقتی کاربر «افزودن کپشن» را میزند ورودی فوکوس میگیرد (نه هنگام بارگذاری مقاله)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusOnOpen.current) return
|
||||||
|
focusOnOpen.current = false
|
||||||
|
captionInputRef.current?.focus()
|
||||||
|
}, [captionOpen])
|
||||||
|
|
||||||
|
/** حذف کامل کپشن: عنصر figcaption و ورودیاش از DOM ویرایشگر بیرون میرود */
|
||||||
|
const removeCaption = () => {
|
||||||
|
updateAttributes({ caption: '' })
|
||||||
|
setCaptionOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCaption = () => {
|
||||||
|
focusOnOpen.current = true
|
||||||
|
setCaptionOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper
|
||||||
|
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
|
||||||
|
data-drag-handle
|
||||||
|
>
|
||||||
|
<figure className="article-figure relative">
|
||||||
|
<img src={src} alt={alt ?? ''} title={title ?? ''} draggable={false} />
|
||||||
|
|
||||||
|
{/* حذف کل تصویر */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={() => deleteNode()}
|
||||||
|
aria-label="حذف تصویر"
|
||||||
|
title="حذف تصویر"
|
||||||
|
className={`absolute left-2 top-2 z-10 ${DELETE_BUTTON_CLASS}`}
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCaption ? (
|
||||||
|
<figcaption className="article-figure__caption" contentEditable={false}>
|
||||||
|
<input
|
||||||
|
ref={captionInputRef}
|
||||||
|
type="text"
|
||||||
|
value={caption ?? ''}
|
||||||
|
onChange={(event) => updateAttributes({ caption: event.target.value })}
|
||||||
|
placeholder="توضیح تصویر (کپشن)…"
|
||||||
|
aria-label="کپشن تصویر"
|
||||||
|
className={INLINE_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
{/* حذف کامل کپشن — تصویر دستنخورده میماند */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={removeCaption}
|
||||||
|
aria-label="حذف کپشن"
|
||||||
|
title="حذف کپشن (تصویر حذف نمیشود)"
|
||||||
|
className={DELETE_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</figcaption>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={addCaption}
|
||||||
|
className={ADD_CAPTION_CLASS}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
افزودن کپشن
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,215 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import { Link2Icon, Trash2Icon, XIcon } from 'lucide-react'
|
||||||
|
import { buildLinkRel, normalizeHref } from '../../utils/link'
|
||||||
|
|
||||||
|
export type LinkModalValue = {
|
||||||
|
href: string
|
||||||
|
newTab: boolean
|
||||||
|
nofollow: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkModalProps = {
|
||||||
|
/** مقادیر اولیه — از لینک فعال زیر نشانگر یا خالی برای لینک تازه */
|
||||||
|
initial: LinkModalValue
|
||||||
|
/** ویرایش لینک موجود (دکمه «حذف لینک» نمایش داده میشود) */
|
||||||
|
editing: boolean
|
||||||
|
onApply: (value: LinkModalValue) => void
|
||||||
|
onRemove: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_CLASS =
|
||||||
|
'mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||||
|
|
||||||
|
/** مدت انیمیشن ورود/خروج — با duration-300 کلاسها هماهنگ است */
|
||||||
|
const TRANSITION_MS = 300
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مودال ثبت و ویرایش لینک — انیمیشن نرم fade و scale با کلاسهای Tailwind.
|
||||||
|
* ورود با یک فریم تاخیر (تا کلاسهای بسته اعمال شوند) و خروج پیش از بستن مودال اجرا میشود.
|
||||||
|
* جایگزین پنجره پیشفرض مرورگر برای لینک شده است.
|
||||||
|
*/
|
||||||
|
export function LinkModal({ initial, editing, onApply, onRemove, onClose }: LinkModalProps) {
|
||||||
|
const [href, setHref] = useState(initial.href)
|
||||||
|
const [newTab, setNewTab] = useState(initial.newTab)
|
||||||
|
const [nofollow, setNofollow] = useState(initial.nofollow)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
|
||||||
|
// انیمیشن ورود: ابتدا حالت بسته رندر میشود، سپس به حالت باز میرود
|
||||||
|
useEffect(() => {
|
||||||
|
const frame = window.requestAnimationFrame(() => setVisible(true))
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** بستن نرم: اول انیمیشن خروج کامل میشود، بعد عملیات و unmount */
|
||||||
|
const closeWith = (action?: () => void) => {
|
||||||
|
if (closing) return
|
||||||
|
setClosing(true)
|
||||||
|
setVisible(false)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
action?.()
|
||||||
|
onClose()
|
||||||
|
}, TRANSITION_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const normalized = normalizeHref(href)
|
||||||
|
if (!normalized) {
|
||||||
|
setError('نشانی معتبر نیست — نمونه: https://example.com یا /contact')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeWith(() => onApply({ href: normalized, newTab, nofollow }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const relPreview = buildLinkRel({ newTab, nofollow })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[97] flex items-center justify-center p-6"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="ثبت لینک"
|
||||||
|
>
|
||||||
|
{/* پسزمینه تار — fade نرم */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-char/70 backdrop-blur-sm transition-opacity duration-300 ease-out ${
|
||||||
|
visible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={() => closeWith()}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* کارت مودال — fade + scale نرم */}
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
dir="rtl"
|
||||||
|
className={`relative w-full max-w-md border border-ivory/10 bg-ink p-7 shadow-2xl transition-all duration-300 ease-out ${
|
||||||
|
visible ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-95 opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||||
|
<Link2Icon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{editing ? 'ویرایش لینک' : 'افزودن لینک'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => closeWith()}
|
||||||
|
aria-label="بستن"
|
||||||
|
className="text-ivory/50 transition-colors hover:text-ivory"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* نشانی */}
|
||||||
|
<label htmlFor="link-href" className="mt-6 block text-xs font-light text-ivory/60">
|
||||||
|
نشانی پیوند (URL)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="link-href"
|
||||||
|
type="text"
|
||||||
|
dir="ltr"
|
||||||
|
autoFocus
|
||||||
|
value={href}
|
||||||
|
onChange={(event) => {
|
||||||
|
setHref(event.target.value)
|
||||||
|
if (error) setError('')
|
||||||
|
}}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-2 border-r-2 border-red-400/70 pr-3 text-[11px] font-light leading-6 text-red-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* باز شدن در تب جدید */}
|
||||||
|
<label className="mt-5 flex cursor-pointer items-center gap-3 text-sm font-light text-ivory/80">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newTab}
|
||||||
|
onChange={(event) => setNewTab(event.target.checked)}
|
||||||
|
className="h-4 w-4 accent-copper"
|
||||||
|
/>
|
||||||
|
باز شدن در تب جدید
|
||||||
|
<span className="text-[10px] font-light text-ivory/35" dir="ltr">target="_blank"</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* نوع لینک از نظر سئو */}
|
||||||
|
<fieldset className="mt-5">
|
||||||
|
<legend className="text-xs font-light text-ivory/60">انتقال اعتبار سئو (rel)</legend>
|
||||||
|
<div className="mt-3 grid gap-2.5">
|
||||||
|
{[
|
||||||
|
{ key: 'dofollow', label: 'Dofollow', hint: 'اعتبار سئو به مقصد منتقل میشود', value: false },
|
||||||
|
{ key: 'nofollow', label: 'Nofollow', hint: 'گوگل این لینک را دنبال نکند', value: true },
|
||||||
|
].map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.key}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 border px-4 py-3 transition-colors duration-150 ${
|
||||||
|
nofollow === option.value
|
||||||
|
? 'border-copper/60 bg-copper/10'
|
||||||
|
: 'border-ivory/12 hover:border-ivory/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="link-rel"
|
||||||
|
checked={nofollow === option.value}
|
||||||
|
onChange={() => setNofollow(option.value)}
|
||||||
|
className="mt-0.5 h-4 w-4 accent-copper"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span className="text-sm font-light text-ivory">{option.label}</span>
|
||||||
|
<span className="text-[11px] font-light text-ivory/45">{option.hint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{relPreview && (
|
||||||
|
<p className="mt-4 text-[10px] font-light text-ivory/35" dir="ltr">
|
||||||
|
rel="{relPreview}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* دکمهها */}
|
||||||
|
<div className="mt-7 flex items-center justify-between gap-3">
|
||||||
|
{editing ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => closeWith(onRemove)}
|
||||||
|
className="flex items-center gap-1.5 border border-red-400/40 px-4 py-2.5 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
حذف لینک
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => closeWith()}
|
||||||
|
className="border border-ivory/20 px-5 py-2.5 text-xs font-light text-ivory/70 transition-colors hover:border-ivory/40 hover:text-ivory"
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-copper px-6 py-2.5 text-xs font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
{editing ? 'ذخیره لینک' : 'افزودن لینک'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import Select from 'react-select'
|
||||||
|
import CreatableSelect from 'react-select/creatable'
|
||||||
|
import type { ClassNamesConfig, GroupBase, MultiValueRemoveProps } from 'react-select'
|
||||||
|
import { XIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export type LuxeOption = { label: string; value: string }
|
||||||
|
|
||||||
|
type LuxeClassNames<IsMulti extends boolean> = ClassNamesConfig<
|
||||||
|
LuxeOption,
|
||||||
|
IsMulti,
|
||||||
|
GroupBase<LuxeOption>
|
||||||
|
>
|
||||||
|
|
||||||
|
const CONTROL_CLASS_NAMES = (isFocused: boolean): string =>
|
||||||
|
`!min-h-[42px] !rounded-none !border !bg-char !transition-colors ${
|
||||||
|
isFocused
|
||||||
|
? '!border-copper !shadow-[0_0_0_1px_rgba(213,135,76,0.35)]'
|
||||||
|
: '!border-ivory/15 !shadow-none hover:!border-copper/50'
|
||||||
|
}`
|
||||||
|
|
||||||
|
const OPTION_CLASS_NAMES = (isSelected: boolean, isFocused: boolean): string =>
|
||||||
|
`!cursor-pointer !rounded-none !text-[13px] ${
|
||||||
|
isSelected
|
||||||
|
? '!bg-copper/25 !font-bold !text-copper'
|
||||||
|
: isFocused
|
||||||
|
? '!bg-copper/15 !text-ivory'
|
||||||
|
: '!bg-transparent !text-ivory/85'
|
||||||
|
}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کلاسهای Tailwind برای همه اسلاتهای react-select — تم تیره پروژه.
|
||||||
|
* react-select برای هر اسلات تابع میخواهد، پس همه با تابع برگردانده میشوند.
|
||||||
|
*/
|
||||||
|
function makeClassNames<IsMulti extends boolean>(
|
||||||
|
extras?: Partial<LuxeClassNames<IsMulti>>,
|
||||||
|
): LuxeClassNames<IsMulti> {
|
||||||
|
return {
|
||||||
|
control: (state) => CONTROL_CLASS_NAMES(state.isFocused),
|
||||||
|
option: (state) => OPTION_CLASS_NAMES(state.isSelected, state.isFocused),
|
||||||
|
valueContainer: () => '!gap-1 !px-3 !py-0.5',
|
||||||
|
placeholder: () => '!text-[13px] !text-ivory/35',
|
||||||
|
singleValue: () => '!text-[13px] !text-ivory',
|
||||||
|
input: () => '!text-[13px] !text-ivory',
|
||||||
|
indicatorsContainer: () => '!text-ivory/40',
|
||||||
|
dropdownIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||||
|
clearIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||||
|
indicatorSeparator: () => '!bg-ivory/15',
|
||||||
|
menu: () => '!mt-1 !rounded-none !border !border-ivory/10 !bg-char !shadow-lift',
|
||||||
|
menuList: () => '!p-1',
|
||||||
|
noOptionsMessage: () => '!text-xs !text-ivory/45',
|
||||||
|
loadingMessage: () => '!text-xs !text-ivory/45',
|
||||||
|
multiValue: () => '!m-0.5 !rounded-full !border !border-copper/40 !bg-copper/15 !py-0.5 !pe-0 !ps-2',
|
||||||
|
multiValueLabel: () => '!text-xs !text-copper',
|
||||||
|
...extras,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** دکمه ضربدر حذف برچسب انتخابشده — همیشه واضح و قابل کلیک */
|
||||||
|
function TagRemoveButton({ innerProps }: MultiValueRemoveProps<LuxeOption, true>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...innerProps}
|
||||||
|
role="button"
|
||||||
|
aria-label="حذف برچسب"
|
||||||
|
title="حذف برچسب"
|
||||||
|
className="mx-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-copper transition-colors duration-150 hover:bg-copper hover:text-char"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalTarget = (): HTMLElement | undefined =>
|
||||||
|
typeof document === 'undefined' ? undefined : document.body
|
||||||
|
|
||||||
|
const uniqueOptions = (values: string[]): LuxeOption[] =>
|
||||||
|
[...new Set(values.filter(Boolean))].map((item) => ({ label: item, value: item }))
|
||||||
|
|
||||||
|
type LuxeSelectProps = {
|
||||||
|
value: string
|
||||||
|
options: string[]
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
isClearable?: boolean
|
||||||
|
isDisabled?: boolean
|
||||||
|
inputId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** react-select تکی با تم تیره — گزینه فعلی حتی اگر در لیست نباشد حفظ میشود */
|
||||||
|
export function LuxeSelect({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'انتخاب کنید…',
|
||||||
|
isClearable = true,
|
||||||
|
isDisabled = false,
|
||||||
|
inputId,
|
||||||
|
}: LuxeSelectProps) {
|
||||||
|
const selectOptions = useMemo(
|
||||||
|
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||||
|
[options, value],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select<LuxeOption, false>
|
||||||
|
inputId={inputId}
|
||||||
|
classNamePrefix="luxe"
|
||||||
|
options={selectOptions}
|
||||||
|
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||||
|
onChange={(option) => onChange(option?.value ?? '')}
|
||||||
|
placeholder={placeholder}
|
||||||
|
isClearable={isClearable}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||||
|
classNames={makeClassNames<false>()}
|
||||||
|
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||||
|
menuPosition="fixed"
|
||||||
|
menuPortalTarget={portalTarget()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LuxeCreatableSelectProps = {
|
||||||
|
value: string
|
||||||
|
options: string[]
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
inputId?: string
|
||||||
|
/** متن گزینه «ساخت مقدار جدید» در فهرست بازشو */
|
||||||
|
createLabel?: (input: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** react-select تکی و قابل ساخت — کاربر با کلیک روی گزینه ساخت، مقدار تازه میسازد */
|
||||||
|
export function LuxeCreatableSelect({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'انتخاب یا نوشتن مورد جدید…',
|
||||||
|
inputId,
|
||||||
|
createLabel = (input) => `افزودن «${input}»`,
|
||||||
|
}: LuxeCreatableSelectProps) {
|
||||||
|
const selectOptions = useMemo(
|
||||||
|
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||||
|
[options, value],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreatableSelect<LuxeOption, false>
|
||||||
|
inputId={inputId}
|
||||||
|
classNamePrefix="luxe"
|
||||||
|
options={selectOptions}
|
||||||
|
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||||
|
onChange={(option) => onChange(option?.value?.trim() ?? '')}
|
||||||
|
placeholder={placeholder}
|
||||||
|
formatCreateLabel={(input) => createLabel(input)}
|
||||||
|
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||||
|
classNames={makeClassNames<false>()}
|
||||||
|
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||||
|
menuPosition="fixed"
|
||||||
|
menuPortalTarget={portalTarget()}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryOption = { label: string; value: string }
|
||||||
|
|
||||||
|
/** کلید نگهداری دستههای ساختهشده در مرورگر */
|
||||||
|
const CREATED_CATEGORIES_KEY = 'aramesh_article_categories'
|
||||||
|
|
||||||
|
/** دستههایی که ادمین ساخته است — برای مقالههای بعدی هم در فهرست میمانند */
|
||||||
|
function readCreatedCategories(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(CREATED_CATEGORIES_KEY)
|
||||||
|
const parsed: unknown = raw ? JSON.parse(raw) : []
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.filter((item): item is string => typeof item === 'string')
|
||||||
|
: []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeCreatedCategory(category: string): void {
|
||||||
|
try {
|
||||||
|
const current = readCreatedCategories()
|
||||||
|
if (current.includes(category)) return
|
||||||
|
window.localStorage.setItem(CREATED_CATEGORIES_KEY, JSON.stringify([...current, category]))
|
||||||
|
} catch {
|
||||||
|
/* حافظه مرورگر در دسترس نیست — بیصدا رد شو */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategorySelectProps = {
|
||||||
|
/** مقدار انتخابشده — یک شیء {value,label} یا null */
|
||||||
|
value: CategoryOption | null
|
||||||
|
/** تغییر انتخاب — null یعنی فقط انتخاب پاک شده است (دکمه X) */
|
||||||
|
onChange: (option: CategoryOption | null) => void
|
||||||
|
/** دستههای پایه */
|
||||||
|
options: string[]
|
||||||
|
/** متن گزینه «ساخت دسته جدید» در فهرست بازشو */
|
||||||
|
createLabel?: (input: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* انتخابگر تکمقداری دستهبندی (react-select Creatable).
|
||||||
|
* - isMulti=false و isClearable: دکمه X فقط انتخاب جاری را پاک میکند و گزینهها دستنخورده میمانند.
|
||||||
|
* - ساخت دسته جدید: به انتهای فهرست گزینهها اضافه و برای مقاله جاری انتخاب میشود و
|
||||||
|
* در همین مرورگر برای مقالههای بعدی نگه داشته میشود.
|
||||||
|
*/
|
||||||
|
export function CategorySelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
createLabel = (input) => `ایجاد دسته جدید: ${input}`,
|
||||||
|
}: CategorySelectProps) {
|
||||||
|
const [createdOptions, setCreatedOptions] = useState<string[]>(() => readCreatedCategories())
|
||||||
|
|
||||||
|
// گزینههای پایه + دستههای ساختهشده (همیشه در انتهای فهرست)
|
||||||
|
const selectOptions = useMemo(
|
||||||
|
() => uniqueOptions([...options, ...createdOptions.filter((item) => !options.includes(item))]),
|
||||||
|
[options, createdOptions],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** ساخت دسته جدید: افزودن به انتهای فهرست + انتخاب خودکار برای مقاله جاری */
|
||||||
|
const handleCreate = (input: string) => {
|
||||||
|
const category = input.trim()
|
||||||
|
if (!category) return
|
||||||
|
setCreatedOptions((previous) =>
|
||||||
|
previous.includes(category) ? previous : [...previous, category],
|
||||||
|
)
|
||||||
|
storeCreatedCategory(category)
|
||||||
|
onChange({ label: category, value: category })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreatableSelect<CategoryOption, false>
|
||||||
|
classNamePrefix="luxe"
|
||||||
|
isMulti={false}
|
||||||
|
isClearable
|
||||||
|
options={selectOptions}
|
||||||
|
value={value}
|
||||||
|
onChange={(option) => onChange(option ? { label: option.label, value: option.value } : null)}
|
||||||
|
onCreateOption={handleCreate}
|
||||||
|
formatCreateLabel={(input) => createLabel(input)}
|
||||||
|
placeholder="انتخاب یا نوشتن دسته جدید…"
|
||||||
|
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||||
|
classNames={makeClassNames<false>()}
|
||||||
|
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||||
|
menuPosition="fixed"
|
||||||
|
menuPortalTarget={portalTarget()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LuxeTagsSelectProps = {
|
||||||
|
value: string[]
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
placeholder?: string
|
||||||
|
}/** هشتگها با react-select قابل ساخت — هر برچسب دکمه ضربدر حذف مستقل دارد */
|
||||||
|
export function LuxeTagsSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'هشتگ بنویسید و Enter بزنید…',
|
||||||
|
}: LuxeTagsSelectProps) {
|
||||||
|
const options = useMemo(() => uniqueOptions(value), [value])
|
||||||
|
|
||||||
|
/** پاکسازی و حذف تکراریها هنگام افزودن یا حذف برچسب */
|
||||||
|
const applyTags = (next: readonly LuxeOption[]) => {
|
||||||
|
const tags = next.map((option) => option.value.trim()).filter(Boolean)
|
||||||
|
onChange([...new Set(tags)])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreatableSelect<LuxeOption, true>
|
||||||
|
classNamePrefix="luxe"
|
||||||
|
isMulti
|
||||||
|
options={options}
|
||||||
|
value={options}
|
||||||
|
onChange={applyTags}
|
||||||
|
placeholder={placeholder}
|
||||||
|
formatCreateLabel={(input) => `افزودن هشتگ «${input}»`}
|
||||||
|
noOptionsMessage={() => 'هشتگی یافت نشد'}
|
||||||
|
closeMenuOnSelect={false}
|
||||||
|
components={{ MultiValueRemove: TagRemoveButton }}
|
||||||
|
classNames={makeClassNames<true>({ multiValueRemove: () => '!bg-transparent' })}
|
||||||
|
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||||
|
menuPosition="fixed"
|
||||||
|
menuPortalTarget={portalTarget()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
||||||
import type { ArticleBodyDoc } from '../../data/site'
|
import type { ArticleBodyDoc } from '../../data/site'
|
||||||
|
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
|
||||||
|
|
||||||
export type SeoInput = {
|
export type SeoInput = {
|
||||||
title: string
|
title: string
|
||||||
@@ -13,78 +14,6 @@ export type SeoInput = {
|
|||||||
type Status = 'good' | 'ok' | 'bad'
|
type Status = 'good' | 'ok' | 'bad'
|
||||||
type Check = { label: string; status: Status; hint: string }
|
type Check = { label: string; status: Status; hint: string }
|
||||||
|
|
||||||
// ---------- استخراج متن/ساختار از سند TipTap ----------
|
|
||||||
|
|
||||||
function docToText(body: SeoInput['body']): string {
|
|
||||||
if (Array.isArray(body)) return body.join(' ')
|
|
||||||
const out: string[] = []
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (!node || typeof node !== 'object') return
|
|
||||||
const n = node as { type?: string; text?: string; content?: unknown[] }
|
|
||||||
if (n.type === 'text' && n.text) out.push(n.text)
|
|
||||||
for (const c of n.content ?? []) walk(c)
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
return out.join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function docHeadings(body: SeoInput['body']): string[] {
|
|
||||||
if (Array.isArray(body)) return []
|
|
||||||
const out: string[] = []
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (!node || typeof node !== 'object') return
|
|
||||||
const n = node as { type?: string; text?: string; attrs?: { level?: number }; content?: unknown[] }
|
|
||||||
if (n.type === 'heading' && n.attrs?.level === 2) {
|
|
||||||
const texts: string[] = []
|
|
||||||
const collect = (inner: unknown) => {
|
|
||||||
if (!inner || typeof inner !== 'object') return
|
|
||||||
const t = inner as { type?: string; text?: string; content?: unknown[] }
|
|
||||||
if (t.type === 'text' && t.text) texts.push(t.text)
|
|
||||||
for (const c of t.content ?? []) collect(c)
|
|
||||||
}
|
|
||||||
collect(n)
|
|
||||||
out.push(texts.join(''))
|
|
||||||
}
|
|
||||||
for (const c of n.content ?? []) walk(c)
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function docImages(body: SeoInput['body']): { src: string; alt: string }[] {
|
|
||||||
if (Array.isArray(body)) return []
|
|
||||||
const out: { src: string; alt: string }[] = []
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (!node || typeof node !== 'object') return
|
|
||||||
const n = node as { type?: string; attrs?: { src?: string; alt?: string }; content?: unknown[] }
|
|
||||||
if (n.type === 'image') out.push({ src: n.attrs?.src ?? '', alt: n.attrs?.alt ?? '' })
|
|
||||||
for (const c of n.content ?? []) walk(c)
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function docLinks(body: SeoInput['body']): string[] {
|
|
||||||
if (Array.isArray(body)) return []
|
|
||||||
const out: string[] = []
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (!node || typeof node !== 'object') return
|
|
||||||
const n = node as { type?: string; marks?: { type?: string; attrs?: { href?: string } }[]; content?: unknown[] }
|
|
||||||
for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) out.push(m.attrs.href)
|
|
||||||
for (const c of n.content ?? []) walk(c)
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstParagraph(body: SeoInput['body']): string {
|
|
||||||
if (Array.isArray(body)) return body[0] ?? ''
|
|
||||||
const p = (body as { content?: { type?: string; content?: unknown[] }[] }).content?.find(
|
|
||||||
(c) => c.type === 'paragraph',
|
|
||||||
)
|
|
||||||
return docToText({ type: 'doc', content: p ? [p] : [] } as never)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- محاسبه بررسیها ----------
|
// ---------- محاسبه بررسیها ----------
|
||||||
|
|
||||||
export function useSeoChecks(input: SeoInput): Check[] {
|
export function useSeoChecks(input: SeoInput): Check[] {
|
||||||
@@ -117,7 +46,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
|||||||
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
|
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
|
||||||
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
|
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
|
||||||
)
|
)
|
||||||
const h2s = docHeadings(input.body)
|
const h2s = docHeadings(input.body, 2)
|
||||||
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
|
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
|
||||||
else
|
else
|
||||||
checks.push(
|
checks.push(
|
||||||
@@ -140,7 +69,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
|||||||
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
|
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
|
||||||
|
|
||||||
// تیتر H2
|
// تیتر H2
|
||||||
const h2s = docHeadings(input.body)
|
const h2s = docHeadings(input.body, 2)
|
||||||
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
|
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
|
||||||
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخشبندی کنید.' })
|
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخشبندی کنید.' })
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { setBlockType, splitBlock } from '@tiptap/pm/commands'
|
||||||
|
import type { EditorView } from '@tiptap/pm/view'
|
||||||
|
import type { ResolvedPos } from '@tiptap/pm/model'
|
||||||
|
|
||||||
|
/** قالب بلوک جاری — پاراگراف یا یکی از سطوح تیتر */
|
||||||
|
export type BlockFormat = 'paragraph' | 1 | 2 | 3
|
||||||
|
|
||||||
|
type LineBounds = {
|
||||||
|
/** ابتدای بلوک جاری */
|
||||||
|
start: number
|
||||||
|
/** پایان بلوک جاری */
|
||||||
|
end: number
|
||||||
|
/** ابتدای خط جاری (پس از نزدیکترین شکست نرم قبل از نشانگر) */
|
||||||
|
lineStart: number
|
||||||
|
/** پایان خط جاری (نزدیکترین شکست نرم پس از نشانگر) */
|
||||||
|
lineEnd: number
|
||||||
|
/** موقعیت شکست نرم قبل از خط جاری — null یعنی خط، ابتدای بلوک است */
|
||||||
|
before: number | null
|
||||||
|
/** موقعیت شکست نرم بعد از خط جاری — null یعنی خط، انتهای بلوک است */
|
||||||
|
after: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مرزهای خط جاری داخل بلوک جاری — خطوطی که با شکست نرم (<br>) از هم جدا شدهاند */
|
||||||
|
function lineBounds($from: ResolvedPos): LineBounds {
|
||||||
|
const start = $from.start()
|
||||||
|
const end = $from.end()
|
||||||
|
let lineStart = start
|
||||||
|
let lineEnd = end
|
||||||
|
let before: number | null = null
|
||||||
|
let after: number | null = null
|
||||||
|
|
||||||
|
$from.parent.forEach((node, offset) => {
|
||||||
|
if (node.type.name !== 'hardBreak') return
|
||||||
|
const pos = start + offset
|
||||||
|
if (pos < $from.pos) {
|
||||||
|
lineStart = pos + 1
|
||||||
|
before = pos
|
||||||
|
} else if (after === null) {
|
||||||
|
lineEnd = pos
|
||||||
|
after = pos
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { start, end, lineStart, lineEnd, before, after }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جدا کردن خط جاری از خطوط چسبیده با شکست نرم (Shift+Enter یا متن چسباندهشده از Word).
|
||||||
|
* بدون این جداسازی، عوض کردن قالب یک خط، قالب همه خطوط همان بلوک را با هم عوض میکند.
|
||||||
|
* اگر گزینش از خط عبور کند (قالببندی چندخطیِ عمدی) دستنخورده میماند.
|
||||||
|
*/
|
||||||
|
function isolateCurrentLine(view: EditorView): boolean {
|
||||||
|
const { state } = view
|
||||||
|
const { selection } = state
|
||||||
|
const bounds = lineBounds(selection.$from)
|
||||||
|
|
||||||
|
if (bounds.before === null && bounds.after === null) return false
|
||||||
|
|
||||||
|
const selectionInsideLine =
|
||||||
|
selection.empty ||
|
||||||
|
(selection.from >= bounds.lineStart && selection.to <= bounds.lineEnd)
|
||||||
|
if (!selectionInsideLine) return false
|
||||||
|
|
||||||
|
const { tr } = state
|
||||||
|
if (bounds.after !== null) {
|
||||||
|
tr.delete(bounds.after, bounds.after + 1)
|
||||||
|
tr.split(bounds.after)
|
||||||
|
}
|
||||||
|
if (bounds.before !== null) {
|
||||||
|
tr.delete(bounds.before, bounds.before + 1)
|
||||||
|
tr.split(bounds.before)
|
||||||
|
}
|
||||||
|
if (!tr.docChanged) return false
|
||||||
|
|
||||||
|
view.dispatch(tr)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قالببندی بلوک جاری (تیتر ۱/۲/۳ یا پاراگراف) — فقط همان خطی که نشانگر در آن است
|
||||||
|
* و به خطوط بالا و پایین کاری ندارد. کلیک دوباره روی همان سطح تیتر، آن را به
|
||||||
|
* پاراگراف برمیگرداند (رفتار toggle).
|
||||||
|
*/
|
||||||
|
export function applyBlockFormat(view: EditorView, format: BlockFormat): boolean {
|
||||||
|
view.focus()
|
||||||
|
isolateCurrentLine(view)
|
||||||
|
|
||||||
|
const { state } = view
|
||||||
|
const paragraph = state.schema.nodes.paragraph
|
||||||
|
const heading = state.schema.nodes.heading
|
||||||
|
if (!paragraph || !heading) return false
|
||||||
|
|
||||||
|
if (format === 'paragraph') return setBlockType(paragraph)(state, view.dispatch, view)
|
||||||
|
|
||||||
|
const current = state.selection.$from.parent
|
||||||
|
const sameHeading = current.type === heading && current.attrs.level === format
|
||||||
|
return sameHeading
|
||||||
|
? setBlockType(paragraph)(state, view.dispatch, view)
|
||||||
|
: setBlockType(heading, { level: format })(state, view.dispatch, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter داخل تیتر: بلوک تازه پاراگراف میشود (نه تیتر تازه) تا قالب تیتر به خط
|
||||||
|
* بعدی سرایت نکند و مرز بلوکها تمیز بماند.
|
||||||
|
* این هندلر پیش از keymap پیشفرض اجرا میشود و فقط همین حالت را میگیرد.
|
||||||
|
*/
|
||||||
|
export function handleHeadingEnter(view: EditorView, event: KeyboardEvent): boolean {
|
||||||
|
if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const { state } = view
|
||||||
|
const { selection } = state
|
||||||
|
const paragraph = state.schema.nodes.paragraph
|
||||||
|
if (!paragraph || !selection.empty || selection.$from.parent.type.name !== 'heading') return false
|
||||||
|
|
||||||
|
// خط تازه با splitBlock ساخته و بعد به پاراگراف تبدیل میشود
|
||||||
|
if (!splitBlock(state, view.dispatch, view)) return false
|
||||||
|
setBlockType(paragraph)(view.state, view.dispatch, view)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
type SmartTextFieldOptions = {
|
||||||
|
/** مقدار ذخیرهشده در بیرون (منبع حقیقت) */
|
||||||
|
value: string
|
||||||
|
/** پیشنهاد خودکار — وقتی کاربر چیزی ننوشته، همین متن نمایش داده و ذخیره میشود */
|
||||||
|
fallback?: string
|
||||||
|
/** آخرین گزینه وقتی پیشنهادی هم وجود ندارد (مثل «Photo 1») */
|
||||||
|
hardFallback?: string
|
||||||
|
/** ذخیره مقدار نهایی — فقط وقتی نسبت به مقدار ذخیرهشده تغییر کرده باشد */
|
||||||
|
onCommit?: (next: string) => void | Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فیلد متنی با «پیشفرض هوشمند» برای Alt/Title تصویر:
|
||||||
|
* - متن کاربر تا لحظه ذخیره فقط در حالت محلی میماند؛ در حین تایپ نه trim میشود و نه از
|
||||||
|
* بیرون بازنویسی میشود، پس فاصله انتهای متن و جای نشانگر حفظ میشود.
|
||||||
|
* - با فوکوس، پیشفرض خودکار پاک میشود تا کاربر بلافاصله تایپ کند.
|
||||||
|
* - با بلور، اگر فیلد خالی بماند پیشفرض خودکار برمیگردد و مقدار نهایی ذخیره میشود.
|
||||||
|
*/
|
||||||
|
export function useSmartTextField({
|
||||||
|
value,
|
||||||
|
fallback = '',
|
||||||
|
hardFallback = '',
|
||||||
|
onCommit,
|
||||||
|
}: SmartTextFieldOptions) {
|
||||||
|
const [text, setText] = useState(value)
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
|
||||||
|
// تازهترین مقادیر در ref نگه داشته میشوند تا هندلرها به closure قدیمی وابسته نباشند
|
||||||
|
const textRef = useRef(text)
|
||||||
|
textRef.current = text
|
||||||
|
const focusedRef = useRef(focused)
|
||||||
|
focusedRef.current = focused
|
||||||
|
const fallbackRef = useRef(fallback)
|
||||||
|
fallbackRef.current = fallback
|
||||||
|
const hardFallbackRef = useRef(hardFallback)
|
||||||
|
hardFallbackRef.current = hardFallback
|
||||||
|
const valueRef = useRef(value)
|
||||||
|
valueRef.current = value
|
||||||
|
|
||||||
|
// همگامسازی با مقدار ذخیرهشده از بیرون (بارگذاری مقاله، بهروزرسانی خوشبینانه)
|
||||||
|
// اما هرگز در حین ویرایش کاربر — وگرنه نشانگر میپرد و فاصلهها میخورند
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusedRef.current) return
|
||||||
|
setText(value)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
/** مقدار نهایی فیلد: متن کاربر → پیشنهاد خودکار → پیشفرض ثابت */
|
||||||
|
const resolve = useCallback((): string => {
|
||||||
|
const current = textRef.current.trim()
|
||||||
|
if (current) return current
|
||||||
|
return fallbackRef.current.trim() || hardFallbackRef.current.trim()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const commit = useCallback(async (): Promise<void> => {
|
||||||
|
const next = resolve()
|
||||||
|
setText(next)
|
||||||
|
if (onCommit && next !== valueRef.current.trim()) await onCommit(next)
|
||||||
|
}, [onCommit, resolve])
|
||||||
|
|
||||||
|
const onFocus = useCallback(() => {
|
||||||
|
setFocused(true)
|
||||||
|
// پیشفرض خودکار متن واقعی نیست؛ با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند
|
||||||
|
const current = textRef.current.trim()
|
||||||
|
const auto = fallbackRef.current.trim() || hardFallbackRef.current.trim()
|
||||||
|
if (!current || current === auto) setText('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onBlur = useCallback(() => {
|
||||||
|
setFocused(false)
|
||||||
|
void commit()
|
||||||
|
}, [commit])
|
||||||
|
|
||||||
|
const onChange = useCallback((next: string) => {
|
||||||
|
setText(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** متن نمایشی — در نبود متن کاربر، پیشنهاد خودکار نشان داده میشود */
|
||||||
|
const display = focused ? text : text !== '' ? text : fallback
|
||||||
|
|
||||||
|
return { display, focused, onFocus, onBlur, onChange, commit, resolve }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* ثابتهای مشترک رابط کاربری — تنها منبع مقادیر پیشفرض (بدون متن پراکنده در کامپوننتها).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** عنوان پیشفرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */
|
||||||
|
export const DEFAULT_IMAGE_TITLE = 'Photo 1'
|
||||||
|
|
||||||
|
/** متن جایگزین پیشفرض تصویرهای داخل متن (وقتی نام فایل چیزی به دست نمیدهد) */
|
||||||
|
export const DEFAULT_IMAGE_ALT = 'تصویر مقاله'
|
||||||
|
|
||||||
|
/** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار میگیرد */
|
||||||
|
export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص'
|
||||||
|
|
||||||
|
/** سرعت مطالعه مبنای محاسبه زمان مطالعه — واژه در دقیقه */
|
||||||
|
export const WORDS_PER_MINUTE = 200
|
||||||
|
|
||||||
|
/** برچسب زمان مطالعه در خروجی */
|
||||||
|
export const READING_TIME_LABEL = 'دقیقه مطالعه'
|
||||||
@@ -40,4 +40,14 @@ export type Article = {
|
|||||||
status: ArticleStatus;
|
status: ArticleStatus;
|
||||||
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
||||||
seo_keyphrase: string;
|
seo_keyphrase: string;
|
||||||
|
/** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */
|
||||||
|
meta_description?: string;
|
||||||
|
/** متن جایگزین تصویر شاخص (Alt) */
|
||||||
|
image_alt?: string;
|
||||||
|
/** عنوان تصویر شاخص (Title) */
|
||||||
|
image_title?: string;
|
||||||
|
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده میشود */
|
||||||
|
author?: string;
|
||||||
|
/** هشتگها/برچسبهای مقاله */
|
||||||
|
tags?: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
+175
-7
@@ -7,8 +7,6 @@
|
|||||||
|
|
||||||
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
|
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
|
||||||
|
|
||||||
/* فونت سایت سلفهاست است (public/fonts) — هیچ وابستگی خارجی وجود ندارد.
|
|
||||||
Pinar فونت اصلی است (۵ وزن) و Vazirmatn بهعنوان جایگزین پشتیبان میماند. */
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Pinar';
|
font-family: 'Pinar';
|
||||||
src: url('/fonts/pinar/Pinar-Light.woff2') format('woff2'),
|
src: url('/fonts/pinar/Pinar-Light.woff2') format('woff2'),
|
||||||
@@ -85,6 +83,7 @@ body {
|
|||||||
.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;
|
||||||
@@ -93,6 +92,11 @@ body {
|
|||||||
.article-body > :first-child {
|
.article-body > :first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
/* ---------- تایپوگرافی مقاله ----------
|
||||||
|
راستچین (فارسی): پایه ۱۴ / تیتر۱ ۱۸ / تیتر۲ ۱۶ / تیتر۳ ۱۴
|
||||||
|
چپچین (انگلیسی): پایه ۱۲ / تیتر۱ ۱۶ / تیتر۲ ۱۴ / تیتر۳ ۱۲
|
||||||
|
جهت با data-dir روی پوسته ویرایشگر یا مقاله تعیین میشود. */
|
||||||
|
.article-body h1,
|
||||||
.article-body h2,
|
.article-body h2,
|
||||||
.article-body h3 {
|
.article-body h3 {
|
||||||
color: #f5f3ef;
|
color: #f5f3ef;
|
||||||
@@ -100,15 +104,75 @@ body {
|
|||||||
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-right: 0.85rem;
|
|
||||||
border-right: 3px solid #d5874c;
|
|
||||||
}
|
}
|
||||||
.article-body h3 {
|
.article-body h3 {
|
||||||
font-size: 1.25rem;
|
font-size: 14px;
|
||||||
color: #e8c9a8;
|
}
|
||||||
|
[data-dir='rtl'] .article-body {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body {
|
||||||
|
direction: ltr;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- تایپوگرافی دوزبانه (فارسی و لاتین در یک خط) ----------
|
||||||
|
متن لاتین همیشه ۲ پیکسل کوچکتر از متن فارسیِ همان بلوک است:
|
||||||
|
تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲.
|
||||||
|
calc(1em - 2px) نسبت به اندازه همان بلوک حساب میشود، پس قاعده در همه سطحها درست
|
||||||
|
میماند و خط پایه هم جابهجا نمیشود (line-height نسبی است).
|
||||||
|
در مقاله انگلیسی (data-dir=ltr) پایه همان اندازه کوچکتر است و واژههای فارسی
|
||||||
|
۲ پیکسل بزرگتر میشوند تا تفاضل همیشه ۲ پیکسل بماند. */
|
||||||
|
.article-body .latin,
|
||||||
|
.article-body .persian,
|
||||||
|
.article-body .english-text {
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body .latin,
|
||||||
|
.article-body .english-text {
|
||||||
|
font-size: calc(1em - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body .persian {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-dir='ltr'] .article-body .latin,
|
||||||
|
[data-dir='ltr'] .article-body .english-text {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-dir='ltr'] .article-body .persian {
|
||||||
|
font-size: calc(1em + 2px);
|
||||||
|
}
|
||||||
|
.article-body span[data-small="true"] {
|
||||||
|
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
|
||||||
|
}
|
||||||
|
/* مارک «متن انگلیسی» ویرایشگر — چپبهراست و ۱۲ پیکسل بدون بههمزدن جهت متن فارسی */
|
||||||
|
.english-text {
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
/* اندازه از قواعد دوزبانه بالا میآید: ۲ پیکسل کوچکتر از متن فارسی همان بلوک */
|
||||||
}
|
}
|
||||||
.article-body ul,
|
.article-body ul,
|
||||||
.article-body ol {
|
.article-body ol {
|
||||||
@@ -152,6 +216,35 @@ body {
|
|||||||
border: 1px solid rgba(245, 243, 239, 0.1);
|
border: 1px solid rgba(245, 243, 239, 0.1);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
/* ---------- تصویر کپشندار: <figure><img><figcaption> ---------- */
|
||||||
|
.article-body figure {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.article-body figure img {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.article-body figcaption {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
color: rgba(245, 243, 239, 0.5);
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.9;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/* ویرایشگر: کپشن ورودی و دکمه حذف زیر تصویر */
|
||||||
|
[data-wysiwyg] .article-figure-node {
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
[data-wysiwyg] .article-figure__caption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
[data-wysiwyg] .article-figure-node.is-selected .article-figure {
|
||||||
|
outline: 1px dashed rgba(213, 135, 76, 0.8);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
.article-body strong {
|
.article-body strong {
|
||||||
color: #f5f3ef;
|
color: #f5f3ef;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -179,3 +272,78 @@ body {
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -1,32 +1,40 @@
|
|||||||
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, ChevronRightIcon } from 'lucide-react';
|
||||||
import { renderToHTMLString } from '@tiptap/static-renderer';
|
import { renderToHTMLString } from '@tiptap/static-renderer';
|
||||||
import { Reveal } from '../components/Reveal';
|
import { Reveal } from '../components/Reveal';
|
||||||
import { 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 { applySeo } from '../seo';
|
import { applySeo } from '../seo';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||||
|
import { docToText } from '../utils/tiptapDoc';
|
||||||
|
import { detectDirection } from '../utils/direction';
|
||||||
|
import { wrapScriptRuns } from '../utils/bidiText';
|
||||||
import type { Article } from '../data/site';
|
import type { Article } from '../data/site';
|
||||||
|
|
||||||
/** بدنه مقاله را به HTML تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
||||||
function renderBody(article: Article): { html: string | null; legacy: string[] } {
|
function renderBody(
|
||||||
|
article: Article,
|
||||||
|
dir: 'rtl' | 'ltr',
|
||||||
|
): { 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 }),
|
// تیترها شناسه لنگر میگیرند و متن لاتین/فارسی برای قاعده «۲ پیکسل کوچکتر» بستهبندی میشود
|
||||||
legacy: [],
|
const { items, html: htmlWithIds } = extractToc(html, (root) => wrapScriptRuns(root, dir));
|
||||||
};
|
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() {
|
||||||
@@ -38,8 +46,9 @@ export function ArticleDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!article) return;
|
if (!article) return;
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${article.title} | ${brand}`,
|
title: `${article.title} | ${brand.name}`,
|
||||||
description: article.excerpt,
|
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
||||||
|
description: article.meta_description || article.excerpt,
|
||||||
path: `/blog/${article.slug}`,
|
path: `/blog/${article.slug}`,
|
||||||
image: article.image,
|
image: article.image,
|
||||||
type: 'article',
|
type: 'article',
|
||||||
@@ -47,10 +56,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: 'fa',
|
inLanguage: 'fa',
|
||||||
author: { '@type': 'Organization', name: brand },
|
author: { '@type': 'Organization', name: article.author?.trim() || brand },
|
||||||
publisher: { '@type': 'Organization', name: brand },
|
publisher: { '@type': 'Organization', name: brand },
|
||||||
mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href,
|
mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href,
|
||||||
},
|
},
|
||||||
@@ -70,13 +79,29 @@ export function ArticleDetail() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { html, legacy } = renderBody(article);
|
const dir = detectDirection(article.title, article.excerpt, docToText(article.body));
|
||||||
|
const { html, toc, legacy } = renderBody(article, dir);
|
||||||
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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جهت غالب متن مقاله — بدنه (با data-dir، طبق قاعده CSS مقاله) و سمت فهرست مطالب
|
||||||
|
* از همین پیروی میکنند: فارسی راستبهچپ (فهرست سمت راست)، انگلیسی چپبهراست (فهرست سمت چپ).
|
||||||
|
*/
|
||||||
|
const hasToc = toc.length > 0;
|
||||||
|
// در چیدمان راستبهچپ ستون شماره ۱ سمت راست است؛ پس ستونها صریح شماره میخورند
|
||||||
|
const gridColumns = hasToc ? 'lg:grid-cols-[17rem_minmax(0,1fr)]' : '';
|
||||||
|
const bodyColumns = 'lg:col-start-2';
|
||||||
|
const tocColumns = 'lg:col-start-1';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
|
<section dir={dir} 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}
|
||||||
|
title={article.image_title || undefined}
|
||||||
|
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"
|
||||||
@@ -88,28 +113,30 @@ export function ArticleDetail() {
|
|||||||
<Link to="/" className="transition-colors hover:text-copper">
|
<Link to="/" className="transition-colors hover:text-copper">
|
||||||
صفحه اصلی
|
صفحه اصلی
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
||||||
<Link to="/blog" className="transition-colors hover:text-copper">
|
<Link to="/blog" className="transition-colors hover:text-copper">
|
||||||
وبلاگ
|
وبلاگ
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
||||||
<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">
|
||||||
|
<div
|
||||||
|
dir={dir}
|
||||||
|
data-dir={dir}
|
||||||
|
className={`mx-auto grid max-w-5xl gap-12 lg:gap-14 ${gridColumns}`}
|
||||||
|
>
|
||||||
|
<div className={`min-w-0 lg:row-start-1 ${hasToc ? bodyColumns : ''}`}>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<p className="border-r-2 border-copper pr-6 text-lg font-light leading-10 text-ivory/80">
|
<p className="border-s-2 border-copper ps-6 text-lg font-light leading-10 text-ivory/80">
|
||||||
{article.excerpt}
|
{article.excerpt}
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -127,6 +154,18 @@ export function ArticleDetail() {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* فهرست مطالب — در دسکتاپ کنار متن میچسبد و همراه مقاله پایین میآید */}
|
||||||
|
{hasToc && (
|
||||||
|
<aside
|
||||||
|
dir={dir}
|
||||||
|
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
|
||||||
|
>
|
||||||
|
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<section className="bg-char py-20 lg:py-28" aria-label="مقالات مرتبط">
|
<section className="bg-char py-20 lg:py-28" aria-label="مقالات مرتبط">
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Newsletter } from '../components/home/Newsletter'
|
|||||||
import { useSite } from '../api'
|
import { useSite } from '../api'
|
||||||
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 { toPersianDigits } from '../utils/digits'
|
||||||
import { PlusIcon } from 'lucide-react'
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
function DraftBadge({ className = '' }: { className?: string }) {
|
function DraftBadge({ className = '' }: { className?: string }) {
|
||||||
@@ -91,10 +93,10 @@ 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}
|
{toPersianDigits(featured.read)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2 text-copper">
|
<span className="flex items-center gap-2 text-copper">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function ProjectDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${project.title} | ${brand}`,
|
title: `${project.title} | ${brand.name}`,
|
||||||
description: project.summary,
|
description: project.summary,
|
||||||
path: `/projects/${project.slug}`,
|
path: `/projects/${project.slug}`,
|
||||||
image: project.image,
|
image: project.image,
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* دوزبانهسازی خودکار متن مقاله — قاعده: متن لاتین همیشه «۲ پیکسل کوچکتر» از متن
|
||||||
|
* فارسیِ همان بلوک است (تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲) و در
|
||||||
|
* مقاله انگلیسی برعکس، واژههای فارسی ۲ پیکسل بزرگتر میشوند.
|
||||||
|
*
|
||||||
|
* اجرای غیرغالب متن با یک span جهتدار جدا میشود تا نه جهت متن بههم بریزد
|
||||||
|
* (unicode-bidi: isolate) و نه خط پایه جابهجا شود؛ اندازه در CSS با
|
||||||
|
* calc(1em - 2px) نسبت به همان بلوک حساب میشود.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** اجرای متن لاتین — حروف/ارقام لاتین بههمراه فاصله و نشانههای داخلی */
|
||||||
|
const LATIN_PATTERN =
|
||||||
|
"[A-Za-z0-9\\u00c0-\\u024f]+(?:[ \\t.,:;'\"()&/+%\\u2013\\u2014-]+[A-Za-z0-9\\u00c0-\\u024f]+)*"
|
||||||
|
|
||||||
|
/** اجرای متن فارسی/عربی — با نیمفاصله و ارقام فارسی */
|
||||||
|
const RTL_PATTERN = '[\\u0590-\\u08ff\\u200c]+(?:[ \\t\\u200c]+[\\u0590-\\u08ff\\u200c]+)*'
|
||||||
|
|
||||||
|
/** کلاس span متن لاتین و متن فارسی — همینها در CSS اندازه ۲ پیکسل تفاضلی میگیرند */
|
||||||
|
export const LATIN_CLASS = 'latin'
|
||||||
|
export const RTL_CLASS = 'persian'
|
||||||
|
|
||||||
|
/** عناصری که متنشان دستنخورده میماند (کد، مارکهای قدیمی و اجرای بستهبندیشده) */
|
||||||
|
const SKIP_SELECTOR = 'code, pre, kbd, samp, script, style, .english-text, [data-small], .latin, .persian'
|
||||||
|
|
||||||
|
/** همه اجراهای غیرغالب یک متن — با بازه نسبت به ابتدای همان متن */
|
||||||
|
export function findScriptRuns(text: string, dir: 'rtl' | 'ltr'): { from: number; to: number }[] {
|
||||||
|
const scan = new RegExp(dir === 'rtl' ? LATIN_PATTERN : RTL_PATTERN, 'g')
|
||||||
|
const runs: { from: number; to: number }[] = []
|
||||||
|
|
||||||
|
for (const match of text.matchAll(scan)) {
|
||||||
|
const from = match.index ?? 0
|
||||||
|
runs.push({ from, to: from + match[0].length })
|
||||||
|
}
|
||||||
|
|
||||||
|
return runs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بستهبندی اجرای غیرغالب همه گرههای متنی داخل یک ریشه HTML.
|
||||||
|
* در مقاله فارسی اجرای لاتین و در مقاله انگلیسی اجرای فارسی جدا میشود.
|
||||||
|
*/
|
||||||
|
export function wrapScriptRuns(root: HTMLElement, dir: 'rtl' | 'ltr'): void {
|
||||||
|
const document = root.ownerDocument
|
||||||
|
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
|
||||||
|
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
|
||||||
|
|
||||||
|
const texts: Text[] = []
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||||
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||||
|
const text = node as Text
|
||||||
|
if (!text.parentElement || text.parentElement.closest(SKIP_SELECTOR)) continue
|
||||||
|
if (!text.data.trim()) continue
|
||||||
|
if (findScriptRuns(text.data, dir).length) texts.push(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const text of texts) {
|
||||||
|
const value = text.data
|
||||||
|
const fragment = document.createDocumentFragment()
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
for (const run of findScriptRuns(value, dir)) {
|
||||||
|
if (run.from > cursor) fragment.append(value.slice(cursor, run.from))
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.className = className
|
||||||
|
span.setAttribute('dir', runDir)
|
||||||
|
span.textContent = value.slice(run.from, run.to)
|
||||||
|
fragment.append(span)
|
||||||
|
cursor = run.to
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < value.length) fragment.append(value.slice(cursor))
|
||||||
|
text.replaceWith(fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 @@
|
|||||||
|
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
|
||||||
|
|
||||||
|
/** تبدیل ارقام لاتین به ارقام فارسی — برای نمایش یکدست فارسی */
|
||||||
|
export function toPersianDigits(value: string | number): string {
|
||||||
|
return String(value).replace(/[0-9]/g, (digit) => PERSIAN_DIGITS[Number(digit)])
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* تشخیص جهت غالب متن — پایه چیدمان مقاله و فهرست مطالب.
|
||||||
|
* سایت فارسی است؛ اما مقالهای که متنش لاتین است باید چپبهراست خوانده شود.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** خطهای راستبهچپ: عبری، عربی و فارسی */
|
||||||
|
const RTL_CHARS = /[\u0590-\u05ff\u0600-\u06ff\u0700-\u074f\ufb50-\ufdff\ufe70-\ufeff]/
|
||||||
|
/** ارقام فارسی/عربی — در شمارش جهت بیطرفاند */
|
||||||
|
const RTL_DIGITS = /[\u0660-\u0669\u06f0-\u06f9]/
|
||||||
|
/** حروف لاتین (با علامتها) */
|
||||||
|
const LTR_CHARS = /[A-Za-z\u00c0-\u024f]/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جهت غالب نمونههای متن: فارسی/عربی → rtl و لاتین → ltr.
|
||||||
|
* در حالت مساوی (مثلاً متن کوتاه یا فقط رقم) پیشفرض سایت، راستبهچپ، برمیگردد.
|
||||||
|
*/
|
||||||
|
export function detectDirection(...samples: (string | null | undefined)[]): 'rtl' | 'ltr' {
|
||||||
|
let rtl = 0
|
||||||
|
let ltr = 0
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
for (const char of sample ?? '') {
|
||||||
|
if (RTL_DIGITS.test(char)) continue
|
||||||
|
if (RTL_CHARS.test(char)) rtl += 1
|
||||||
|
else if (LTR_CHARS.test(char)) ltr += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtl >= ltr ? 'rtl' : 'ltr'
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/** پروتکلها و الگوهای مجاز برای نشانی پیوند — ویرایشگر و مودال لینک هر دو از اینها استفاده میکنند */
|
||||||
|
const ALLOWED_URI = /^(https?:\/\/|\/|#|mailto:|tel:)/i
|
||||||
|
const BARE_DOMAIN = /^[\w-]+(\.[\w-]+)+([/?#].*)?$/i
|
||||||
|
/** نشانی کامل بدون فاصله — برای تشخیص متن چسباندهشده یا تایپشده */
|
||||||
|
const URL_LIKE = /^(https?:\/\/\S+|www\.\S+|[a-z0-9-]+(\.[a-z0-9-]+)+(\/\S*)?)$/i
|
||||||
|
|
||||||
|
/** آیا متن یک نشانی کامل است؟ (برای بازکردن خودکار مودال لینک) */
|
||||||
|
export function isUrlLike(value: string): boolean {
|
||||||
|
return URL_LIKE.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** آیا نشانی برای درج در لینک مجاز است؟ */
|
||||||
|
export function isAllowedHref(uri: string): boolean {
|
||||||
|
return ALLOWED_URI.test(uri.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نرمالسازی نشانی ورودی کاربر:
|
||||||
|
* «example.com» → «https://example.com» و ورودی خالی/نامعتبر → null
|
||||||
|
*/
|
||||||
|
export function normalizeHref(raw: string): string | null {
|
||||||
|
const value = raw.trim()
|
||||||
|
if (!value) return null
|
||||||
|
if (isAllowedHref(value)) return value
|
||||||
|
if (BARE_DOMAIN.test(value)) return `https://${value}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LinkOptions = {
|
||||||
|
/** باز شدن در تب جدید */
|
||||||
|
newTab: boolean
|
||||||
|
/** عدم انتقال اعتبار سئو (nofollow) */
|
||||||
|
nofollow: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ساخت مقدار rel بر اساس گزینههای انتخابی کاربر */
|
||||||
|
export function buildLinkRel({ newTab, nofollow }: LinkOptions): string | null {
|
||||||
|
const tokens: string[] = []
|
||||||
|
if (newTab) tokens.push('noopener', 'noreferrer')
|
||||||
|
if (nofollow) tokens.push('nofollow')
|
||||||
|
return tokens.length ? tokens.join(' ') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** خواندن گزینه nofollow از مقدار rel لینک موجود */
|
||||||
|
export function parseLinkRel(rel?: string | null): LinkOptions {
|
||||||
|
const tokens = (rel ?? '').split(/\s+/).filter(Boolean)
|
||||||
|
return { newTab: tokens.includes('noopener') || tokens.includes('noreferrer'), nofollow: tokens.includes('nofollow') }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { READING_TIME_LABEL, WORDS_PER_MINUTE } from '../data/constants'
|
||||||
|
import { toPersianDigits } from './digits'
|
||||||
|
import { docToText } from './tiptapDoc'
|
||||||
|
|
||||||
|
export { WORDS_PER_MINUTE, READING_TIME_LABEL }
|
||||||
|
|
||||||
|
/** شمارش واژههای متن سند (بدون شمارش نویسههای قالببندی) */
|
||||||
|
export function countWords(body: unknown): number {
|
||||||
|
return docToText(body)
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** زمان مطالعه بر حسب دقیقه — سقف واژهها تقسیم بر سرعت مطالعه، حداقل یک دقیقه */
|
||||||
|
export function readingTimeMinutes(body: unknown): number {
|
||||||
|
return Math.max(1, Math.ceil(countWords(body) / WORDS_PER_MINUTE))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** زمان مطالعه آماده نمایش با ارقام فارسی — مثل «۵ دقیقه مطالعه» */
|
||||||
|
export function formatReadingTime(body: unknown): string {
|
||||||
|
return `${toPersianDigits(readingTimeMinutes(body))} ${READING_TIME_LABEL}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن برای فهرست مطالب.
|
||||||
|
* حروف و اعداد فارسی/عربی و لاتین در شناسه میمانند (در URL بهصورت percent-encoded
|
||||||
|
* نوشته میشوند و در همه مرورگرها کار میکنند)؛ فقط نشانهها و فاصلهها به خط تیره
|
||||||
|
* تبدیل میشوند. نیمفاصله و نویسههای نامرئی جهت هم حذف میشوند تا تیترهای تکراری
|
||||||
|
* شناسه یکتا بگیرند.
|
||||||
|
*/
|
||||||
|
export function slugifyFa(input: string): string {
|
||||||
|
return (input ?? '')
|
||||||
|
.toString()
|
||||||
|
.normalize('NFC')
|
||||||
|
// نیمفاصله، اتصالدهنده و نویسههای نامرئی جهت → حذف
|
||||||
|
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
|
||||||
|
// یکسانسازی حروف عربی با معادل فارسی
|
||||||
|
.replace(/[يى]/g, 'ی')
|
||||||
|
.replace(/ك/g, 'ک')
|
||||||
|
.replace(/[ۀة]/g, 'ه')
|
||||||
|
.toLowerCase()
|
||||||
|
// فقط حروف/اعداد یونیکد (فارسی، عربی، لاتین و...) و فاصله/خط تیره بماند
|
||||||
|
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-{2,}/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی میگیرند (مثل about، about-2) */
|
||||||
|
export function uniqueSlug(base: string, used: Map<string, number>): string {
|
||||||
|
const key = base || 'section'
|
||||||
|
const count = used.get(key) ?? 0
|
||||||
|
used.set(key, count + 1)
|
||||||
|
return count === 0 ? key : `${key}-${count + 1}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* کمکتابعهای مشترک سند TipTap — استخراج متن و ساختار از JSON مقاله.
|
||||||
|
* هم چکلیست سئو و هم محاسبه زمان مطالعه از همینجا استفاده میکنند (DRY).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ArticleBodyDoc } from '../data/site'
|
||||||
|
|
||||||
|
type DocNode = {
|
||||||
|
type?: string
|
||||||
|
text?: string
|
||||||
|
attrs?: Record<string, unknown>
|
||||||
|
marks?: { type?: string; attrs?: Record<string, unknown> }[]
|
||||||
|
content?: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(node: unknown, visit: (node: DocNode) => void): void {
|
||||||
|
if (!node || typeof node !== 'object') return
|
||||||
|
const current = node as DocNode
|
||||||
|
visit(current)
|
||||||
|
for (const child of current.content ?? []) walk(child, visit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** متن یک سند TipTap (یا آرایه پاراگرافهای قدیمی) */
|
||||||
|
export function docToText(body: unknown): string {
|
||||||
|
if (Array.isArray(body)) return body.join(' ')
|
||||||
|
const parts: string[] = []
|
||||||
|
walk(body, (node) => {
|
||||||
|
if (node.type === 'text' && node.text) parts.push(node.text)
|
||||||
|
})
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** متن تیترهای یک سطح مشخص (پیشفرض H2) */
|
||||||
|
export function docHeadings(body: unknown, level = 2): string[] {
|
||||||
|
if (Array.isArray(body)) return []
|
||||||
|
const headings: string[] = []
|
||||||
|
walk(body, (node) => {
|
||||||
|
if (node.type !== 'heading' || node.attrs?.level !== level) return
|
||||||
|
const texts: string[] = []
|
||||||
|
walk(node, (inner) => {
|
||||||
|
if (inner.type === 'text' && inner.text) texts.push(inner.text)
|
||||||
|
})
|
||||||
|
headings.push(texts.join(''))
|
||||||
|
})
|
||||||
|
return headings
|
||||||
|
}
|
||||||
|
|
||||||
|
/** تصاویر سند با متن جایگزین */
|
||||||
|
export function docImages(body: unknown): { src: string; alt: string }[] {
|
||||||
|
if (Array.isArray(body)) return []
|
||||||
|
const images: { src: string; alt: string }[] = []
|
||||||
|
walk(body, (node) => {
|
||||||
|
if (node.type !== 'image') return
|
||||||
|
images.push({ src: String(node.attrs?.src ?? ''), alt: String(node.attrs?.alt ?? '') })
|
||||||
|
})
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نشانی همه پیوندهای سند */
|
||||||
|
export function docLinks(body: unknown): string[] {
|
||||||
|
if (Array.isArray(body)) return []
|
||||||
|
const links: string[] = []
|
||||||
|
walk(body, (node) => {
|
||||||
|
for (const mark of node.marks ?? []) {
|
||||||
|
if (mark.type === 'link' && mark.attrs?.href) links.push(String(mark.attrs.href))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
/** متن نخستین پاراگراف — برای بررسی کلمه کلیدی در ابتدای متن */
|
||||||
|
export function firstParagraph(body: unknown): string {
|
||||||
|
if (Array.isArray(body)) return body[0] ?? ''
|
||||||
|
const content = (body as { content?: { type?: string }[] } | null)?.content
|
||||||
|
const paragraph = content?.find((child) => child?.type === 'paragraph')
|
||||||
|
return paragraph ? docToText({ type: 'doc', content: [paragraph] }) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as ArticleBodyDoc
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تبدیل بدنه ذخیرهشده مقاله به سند TipTap برای بارگذاری در ویرایشگر.
|
||||||
|
* ردیفهای قدیمی متن را بهصورت آرایه پاراگراف نگه داشتهاند و ممکن است رشته JSON هم
|
||||||
|
* برگردد؛ هر دو شکل به سند تبدیل میشوند تا هنگام ویرایش، متن قبلی از دست نرود.
|
||||||
|
*/
|
||||||
|
export function toArticleBodyDoc(body: unknown): ArticleBodyDoc {
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
const paragraphs = body
|
||||||
|
.map((paragraph) => String(paragraph ?? '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((text) => ({ type: 'paragraph', content: [{ type: 'text', text }] }))
|
||||||
|
return paragraphs.length ? { type: 'doc', content: paragraphs } : { ...EMPTY_DOC }
|
||||||
|
}
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
try {
|
||||||
|
return toArticleBodyDoc(JSON.parse(body))
|
||||||
|
} catch {
|
||||||
|
return { ...EMPTY_DOC }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body && typeof body === 'object') return body as ArticleBodyDoc
|
||||||
|
return { ...EMPTY_DOC }
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import react from '@vitejs/plugin-react'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
// در حالت توسعه، /api و /media به Traefik پروکسی میشوند (پورت ۵۱۷۳ روی هاست)
|
port: 5174, // سرور توسعه فرانتاند روی این پورت اجرا میشود تا تداخل نکند
|
||||||
|
strictPort: true, // جلوگیری از تغییر خودکار پورت
|
||||||
proxy: {
|
proxy: {
|
||||||
|
// در حالت توسعه، درخواستهای API و Media به Traefik (پورت ۵۱۷۳) فرستاده میشوند
|
||||||
'/api': 'http://localhost:5173',
|
'/api': 'http://localhost:5173',
|
||||||
'/media': 'http://localhost:5173',
|
'/media': 'http://localhost:5173',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user