Author SHA1 Message Date
MMD fe6957bf83 Merge remote-tracking branch 'origin/dashboard/blog-changes'
# Conflicts:
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/admin/entities.ts
#	frontend/src/components/ArticleCard.tsx
#	frontend/src/index.css
2026-09-15 14:41:46 +03:30
fatemeh sane 049291c109 feat(admin): enhance editor UI (links, image captions) & fix categories
Added animated auto-opening link modal, custom image node with isolated caption deletion, fixed single-select category data binding, improved featured image layout, and updated README.
2026-09-15 11:33:04 +03:30
MMD 53673d0f9b Merge branch 'bilingual-en'
# Conflicts:
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/admin/entities.ts
#	frontend/src/components/ArticleCard.tsx
#	frontend/src/index.css
#	frontend/src/pages/ArticleDetail.tsx
2026-09-13 18:45:06 +03:30
MMD 9d55f521a0 Merge remote-tracking branch 'origin/dashboard/blog-changes' 2026-09-13 18:29:51 +03:30
MMD 35c03ef35e اصلاح عنوان تب صفحات جزئیات — brand به‌جای brand.name 2026-09-13 18:29:34 +03:30
fatemeh sane bb6c586d99 Update blog editor features 2026-09-13 10:39:25 +03:30
fatemeh sane 20c95e73b3 Merge branch 'site-v2' of https://git.bennu-api-bahrestan.ir/Behrad/aramesh-backend 2026-09-12 19:19:45 +03:30
30 changed files with 2389 additions and 378 deletions
+18 -3
View File
@@ -1,11 +1,26 @@
# Aramesh Construction Group — Frontend # Aramesh Construction Group — Frontend
A React + Vite + Tailwind CSS application. All text content and assets are fetched dynamically from the backend API (`GET /api/site`). A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
## Typography ## Typography
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering. The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
## CMS, SEO & Editor Features
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, 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"`) with specific font scaling (RTL Base: 14px, LTR Base: 12px) mapped to a fixed toolbar that maintains layout stability.
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls (separate 'X' buttons to delete either the image entirely or just the caption text).
* **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
+147 -5
View File
@@ -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",
+4
View File
@@ -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": {
+216 -55
View File
@@ -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,28 +9,48 @@ 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 type { Article, ArticleBodyDoc } from '../data/site'
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
type Draft = { /** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
id: number type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
slug: string
/**
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت می‌شوند:
* عنوان (مبنای Alt پویا)، دسته‌بندی و متادیتای تصویر شاخص.
* `category` در فرم یک شیء تک‌مقداری {value,label} یا null است
* که هنگام ذخیره به رشته (ستون category در بک‌اند) نگاشت می‌شود.
*/
type ArticleMetaForm = {
title: string title: string
title_en: string category: CategoryOption | null
date: string image_alt: string
date_en: string image_title: string
category: string }
category_en: string
image: string /** مقدار رشته‌ای ذخیره‌شده → گزینه react-select (یا null وقتی خالی است) */
read: string function toCategoryOption(value: string): CategoryOption | null {
read_en: string const category = (value ?? '').trim()
excerpt: string return category ? { label: category, value: category } : null
excerpt_en: string }
body: ArticleBodyDoc
body_en: ArticleBodyDoc /** گزینه react-select → مقدار رشته‌ای ذخیره‌سازی در بک‌اند */
status: 'draft' | 'published' function categoryValue(option: CategoryOption | null): string {
seo_keyphrase: 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> = {
@@ -57,11 +78,16 @@ export function ArticleWritePage() {
slug: `article-${Date.now()}`, slug: `article-${Date.now()}`,
title: '', title: '',
title_en: '', title_en: '',
date: new Date().toLocaleDateString('fa-IR'), date: new Date().toISOString().split('T')[0],
date_en: '', date_en: '',
category: 'یادداشت', category: ARTICLE_CATEGORIES[0],
category_en: '', category_en: '',
tags: [],
image: '', image: '',
image_alt: '',
image_title: '',
author: '',
meta_description: '',
read: '۵ دقیقه', read: '۵ دقیقه',
read_en: '', read_en: '',
excerpt: '', excerpt: '',
@@ -71,6 +97,25 @@ export function ArticleWritePage() {
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)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const draftRef = useRef(draft) const draftRef = useRef(draft)
@@ -105,6 +150,7 @@ export function ArticleWritePage() {
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
setDraft({ setDraft({
...(article as unknown as Draft), ...(article as unknown as Draft),
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc, body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
body_en: body_en:
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody) rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
@@ -112,6 +158,13 @@ export function ArticleWritePage() {
: EMPTY_DOC, : EMPTY_DOC,
}) })
setCreatedId(article.id) setCreatedId(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])
@@ -207,9 +260,15 @@ export function ArticleWritePage() {
queueSave({ [name]: e.target.value }), queueSave({ [name]: e.target.value }),
}) })
const metaLength = (draft.meta_description ?? '').trim().length
// در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند // در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
// متن جایگزین تصویر شاخص — با 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">
{/* نوار بالا */} {/* نوار بالا */}
@@ -302,25 +361,49 @@ export function ArticleWritePage() {
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'} : 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}
</p> </p>
{/* عنوان — در ویرایش انگلیسی، متن فارسی فعلی جای‌نگهدار می‌ماند */} {/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
<input در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره می‌شود */}
type="text" <Controller
{...textField(suf('title'))} name="title"
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'} control={control}
aria-label="عنوان مقاله" render={({ field }) => (
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none" <input
type="text"
{...field}
onChange={(event) => {
field.onChange(event.target.value)
queueSave({ [suf('title')]: event.target.value })
}}
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
aria-label="عنوان مقاله"
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
/>
)}
/> />
<div className="mt-4 h-px w-28 bg-copper" /> <div className="mt-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>
@@ -328,23 +411,56 @@ export function ArticleWritePage() {
<div className="mt-8"> <div className="mt-8">
<ArticleEditor <ArticleEditor
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>} doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
onChange={(json) => queueSave({ [suf('body')]: json })} onChange={(json) => {
// زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری
const minutes = formatReadingTime(json)
if (editLang === 'en') {
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
} else {
queueSave({ body: json, read: `${minutes} دقیقه` })
}
}}
/> />
</div> </div>
{/* توضیحات متای سئو */}
<section className="mt-8 border border-ivory/15 bg-char p-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-bold text-copper">توضیحات سئو (Meta Description)</h2>
<span
className={`text-[11px] font-light ${
metaLength >= 150 && metaLength <= 160 ? 'text-emerald-400' : 'text-ivory/40'
}`}
>
{metaLength.toLocaleString('fa-IR')} از ۱۶۰ نویسه
</span>
</div>
<p className="mt-1 text-xs font-light text-ivory/50">
این همان متنی است که در صفحه نتایج گوگل زیر لینک مقاله به کاربران نمایش داده می‌شود (۱۵۰ تا ۱۶۰ نویسه).
</p>
<textarea
id="meta-description"
rows={3}
value={draft.meta_description ?? ''}
onChange={(e) => queueSave({ meta_description: e.target.value })}
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
</section>
{/* چکیده و متادیتا */} {/* چکیده و متادیتا */}
<SeoChecklist <SeoChecklist
input={{ input={{
title: draft.title, title: draft.title,
slug: draft.slug, slug: draft.slug,
excerpt: draft.excerpt, excerpt: draft.meta_description || draft.excerpt,
body: draft.body, body: draft.body,
keyphrase: draft.seo_keyphrase, keyphrase: draft.seo_keyphrase,
}} }}
/> />
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله"> <section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
<h2 className="text-sm font-bold text-copper">جزئیات انتشار</h2> <h2 className="text-sm font-bold text-copper">جزئیات انتشار، دسته‌بندی و هشتگ‌ها</h2>
<div className="mt-5 grid gap-5"> <div className="mt-5 grid gap-5">
<div> <div>
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمی‌شود)</label> <label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمی‌شود)</label>
@@ -356,38 +472,83 @@ export function ArticleWritePage() {
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none" className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/> />
</div> </div>
<div>
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label> <div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<textarea <div>
rows={2} <label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
{...textField(suf('excerpt'))} <textarea
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : undefined} rows={2}
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none" {...textField(suf('excerpt'))}
/> placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارت‌های وبلاگ…'}
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
</div>
<div>
<label htmlFor="article-author" className="text-xs font-light text-ivory/50">نویسنده</label>
<input
id="article-author"
type="text"
{...textField('author')}
placeholder="نام و نام خانوادگی نویسنده"
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
/>
</div>
</div> </div>
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div> <div>
<label className="text-xs font-light text-ivory/50">دسته</label> <label className="text-xs font-light text-ivory/50">دسته‌بندی مقاله (Category)</label>
<input <div className="mt-2">
type="text" {/* تک‌انتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
{...textField(suf('category'))} <Controller
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" 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>
<div> <div>
<label className="text-xs font-light text-ivory/50">تاریخ</label> <label className="text-xs font-light text-ivory/50">هشتگ‌ها / برچسب‌ها</label>
<input <div className="mt-2">
type="text" <LuxeTagsSelect
{...textField(suf('date'))} value={normalizeTags(draft.tags)}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none" onChange={(tags) => queueSave({ tags })}
/> />
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
<div>
<label htmlFor="article-date" className="text-xs font-light text-ivory/50">تاریخ انتشار (شمسی)</label>
<div className="mt-2">
<JalaliDatePicker
id="article-date"
value={draft.date ?? ''}
onChange={(isoDate) => queueSave({ date: isoDate })}
/>
</div>
</div> </div>
<div> <div>
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label> <label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
<input <input
type="text" type="text"
{...textField(suf('read'))} disabled={editLang !== 'en'}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none" {...(editLang === 'en' ? textField('read_en') : { value: String(draft.read ?? '') })}
className={`mt-2 w-full text-sm font-light ${
editLang === 'en'
? 'border border-ivory/15 bg-char px-3 py-2 text-ivory focus:border-copper focus:outline-none'
: 'cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-ivory/50'
}`}
/> />
</div> </div>
<div> <div>
+147 -18
View File
@@ -8,6 +8,8 @@ import { adminApi } from './adminClient'
import { editableFields } from './entities' import { editableFields } from './entities'
import type { EntityConfig, FieldConfig } from './entities' import type { EntityConfig, FieldConfig } from './entities'
import { ImageField } from '../components/admin/ImageField' import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import { ArticleEditor } from '../components/admin/ArticleEditor' import { ArticleEditor } from '../components/admin/ArticleEditor'
type EntityDrawerProps = { type EntityDrawerProps = {
@@ -117,22 +119,46 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
<div className="flex-1 overflow-y-auto px-6 py-6"> <div className="flex-1 overflow-y-auto px-6 py-6">
<div className="grid gap-6"> <div className="grid gap-6">
{fields.map((field) => ( {fields.map((field) => {
<FieldControl const isImage = field.type === 'image'
key={field.name} // فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
field={field} const altKey = isImage
value={item[field.name]} ? field.name === 'src' && 'alt' in item
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined} ? 'alt'
onSave={(value) => saveField(field.name, value)} : `${field.name}_alt`
registerFlush={ : ''
field.type === 'wysiwyg' const imageTitleKey = isImage ? `${field.name}_title` : ''
? (fn) => { // پیشنهاد Alt از عنوان رکورد ساخته می‌شود (هرگز از URL)
flushRef.current = fn const imageAltFallback = [
} String(item[config.titleKey] ?? '').trim(),
: undefined field.altFallbackSuffix,
} ]
/> .filter(Boolean)
))} .join(' - ')
return (
<FieldControl
key={field.name}
field={field}
value={item[field.name]}
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
altValue={altKey ? String(item[altKey] ?? '') : undefined}
altFallback={imageAltFallback}
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
onSave={(value) => saveField(field.name, value)}
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
onSaveImageTitle={
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
}
registerFlush={
field.type === 'wysiwyg'
? (fn) => {
flushRef.current = fn
}
: undefined
}
/>
)
})}
</div> </div>
</div> </div>
@@ -157,19 +183,36 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
function FieldControl({ function FieldControl({
field, field,
value, value,
altValue,
altFallback,
placeholder, placeholder,
imageTitleValue,
onSave, onSave,
onSaveAlt,
onSaveImageTitle,
registerFlush, registerFlush,
}: { }: {
field: FieldConfig field: FieldConfig
value: unknown value: unknown
/** مقدار Alt تصویر — فقط برای فیلدهای image */
altValue?: string
altFallback?: string
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */ /** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */
placeholder?: unknown placeholder?: unknown
/** مقدار 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
@@ -177,7 +220,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') {
@@ -193,7 +290,11 @@ function FieldControl({
} }
if (field.type === 'list') { if (field.type === 'list') {
return <ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را می‌بیند' : undefined} /> return field.ui === 'tags' ? (
<TagsField field={field} value={value} onSave={onSave} />
) : (
<ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را می‌بیند' : undefined} />
)
} }
if (field.type === 'textarea') { if (field.type === 'textarea') {
@@ -230,6 +331,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,
+53 -18
View File
@@ -1,40 +1,60 @@
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'
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */ /** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */
shared?: boolean shared?: boolean
/** پسوند پیشنهاد 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, shared = false): FieldConfig => ({ const F = (
name: string,
label: string,
type: FieldType = 'text',
hint?: string,
sharedOrExtra?: boolean | Partial<FieldConfig>,
): FieldConfig => ({
name, name,
label, label,
type, type,
hint, hint,
shared, ...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null
? sharedOrExtra
: { shared: !!sharedOrExtra }),
}) })
/** پیکربندی همه منابع قابل ویرایش — هم مجموعه‌ها و هم رکوردهای یگانه */ /** دسته‌بندی‌های استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده می‌شوند */
export const ARTICLE_CATEGORIES = [
'معماری و ساخت',
'سرمایه‌گذاری ملکی',
'طراحی داخلی',
'اخبار و اطلاعیه‌ها',
'یادداشت مدیریت',
]
export const ENTITIES: Record<string, EntityConfig> = { export const ENTITIES: Record<string, EntityConfig> = {
services: { services: {
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection', resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
@@ -60,14 +80,32 @@ export const ENTITIES: Record<string, EntityConfig> = {
fields: [ fields: [
F('title', 'عنوان'), F('title', 'عنوان'),
F('body', 'متن مقاله', 'wysiwyg'), F('body', 'متن مقاله', 'wysiwyg'),
F('excerpt', 'چکیده', 'textarea'), F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
F('image', 'تصویر شاخص', 'image', undefined, true), F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود', true),
F('category', 'دسته'), F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
F('date', 'تاریخ'), F('category', 'دسته‌بندی (Category)', 'select', 'می‌توانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
F('read', 'زمان مطالعه'), F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
F('author', 'نویسنده', 'text', undefined, true),
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
F('read', 'زمان مطالعه (دقیقه)'),
F('slug', 'نشانی (slug)', 'text', undefined, true), F('slug', 'نشانی (slug)', 'text', undefined, true),
], ],
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: { type: 'doc', content: [{ type: 'paragraph' }] } }, defaults: {
title: 'مقاله جدید',
slug: `article-${Date.now()}`,
date: new Date().toISOString().split('T')[0],
category: ARTICLE_CATEGORIES[0],
tags: ['مدیریت پروژه', 'ساخت‌وساز'],
image: '',
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',
@@ -135,7 +173,6 @@ export const ENTITIES: Record<string, EntityConfig> = {
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)], fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
defaults: { label: 'آیتم جدید', href: '/' }, defaults: { label: 'آیتم جدید', href: '/' },
}, },
seo_meta: { seo_meta: {
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection', resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
titleKey: 'page_key', titleKey: 'page_key',
@@ -146,8 +183,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',
+4 -2
View File
@@ -2,7 +2,9 @@ import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react' import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site' import type { Article } from '../data/site'
import { formatJalali } from '../utils/date'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { toPersianDigits } from '../utils/digits'
export function ArticleCard({ article }: { article: Article }) { export function ArticleCard({ article }: { article: Article }) {
const { t, href } = useLang() const { t, href } = useLang()
@@ -35,10 +37,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>
+54
View File
@@ -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>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import type { MouseEvent } from 'react'
import { ListIcon } from 'lucide-react'
import { slugifyFa, uniqueSlug } from '../utils/slug'
export type TocItem = { id: string; text: string; level: 2 | 3 }
/**
* استخراج فهرست مطالب از HTML ویرایشگر — تیترهای H2 و H3 شناسه لنگر می‌گیرند
* (با slugify و پشتیبانی فارسی) و HTML برگشتی همان است که باید رندر شود.
*/
export function extractToc(html: string): { items: TocItem[]; html: string } {
if (!html || typeof window === 'undefined') return { items: [], html }
const doc = new DOMParser().parseFromString(html, 'text/html')
const used = new Map<string, number>()
const items: TocItem[] = []
doc.body.querySelectorAll('h2, h3').forEach((el) => {
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
if (!text) return
const id = uniqueSlug(slugifyFa(text), used)
el.setAttribute('id', id)
items.push({ id, text, level: el.tagName === 'H3' ? 3 : 2 })
})
return { items, html: doc.body.innerHTML }
}
type TableOfContentsProps = {
items: TocItem[]
title?: string
className?: string
}
/** فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
useEffect(() => {
setActiveId(items[0]?.id ?? '')
}, [items])
useEffect(() => {
if (!items.length || typeof IntersectionObserver === 'undefined') return
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
const first = visible[0]?.target.id
if (first) setActiveId(first)
},
{ rootMargin: '-15% 0px -70% 0px', threshold: [0, 1] },
)
items.forEach((item) => {
const el = document.getElementById(item.id)
if (el) observer.observe(el)
})
return () => observer.disconnect()
}, [items])
if (items.length === 0) return null
const go = (event: MouseEvent<HTMLButtonElement>, id: string) => {
event.preventDefault()
const el = document.getElementById(id)
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
window.history.replaceState(null, '', `#${id}`)
}
return (
<nav
aria-label="فهرست مطالب"
className={`rounded-2xl border border-white/10 bg-char/60 p-6 backdrop-blur-xl shadow-[0_20px_50px_rgba(0,0,0,0.35),inset_0_1px_1px_rgba(255,255,255,0.08)] ${className}`}
>
<p className="flex items-center gap-2 text-sm font-bold text-copper">
<ListIcon className="h-4 w-4" aria-hidden="true" />
{title}
</p>
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-r border-ivory/10 pr-3">
{items.map((item) => {
const isActive = item.id === activeId
return (
<li key={item.id}>
<button
type="button"
onClick={(event) => go(event, item.id)}
aria-current={isActive ? 'location' : undefined}
className={`block w-full text-right text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'pr-4 text-[13px]' : ''
} ${
isActive
? 'font-normal text-copper'
: 'text-ivory/60 hover:text-copper'
}`}
>
{item.text}
</button>
</li>
)
})}
</ul>
</nav>
)
}
+254 -129
View File
@@ -1,40 +1,64 @@
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 { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
import { ArticleImage } from './ArticleImage'
import { EnglishText } from './EnglishText'
import { LinkModal } from './LinkModal'
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,
] ]
type ArticleEditorProps = { type ArticleEditorProps = {
@@ -42,6 +66,30 @@ type ArticleEditorProps = {
onChange: (json: Record<string, unknown>) => void onChange: (json: Record<string, unknown>) => void
} }
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 +106,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 +123,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 }: 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 +140,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,10 +176,33 @@ 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
},
}, },
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, direction])
async function uploadAndInsert(file: File) { async function uploadAndInsert(file: File) {
if (!editor) return if (!editor) return
setUploading(true) setUploading(true)
@@ -125,143 +213,180 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
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
</ToolButton> label="پاراگراف"
<ToolButton active={editor.isActive('paragraph')}
label="ایتالیک" onClick={() => editor.chain().focus().setParagraph().run()}
active={editor.isActive('italic')} >
onClick={() => editor.chain().focus().toggleItalic().run()} <PilcrowIcon className="h-4 w-4" />
> </ToolButton>
<ItalicIcon className="h-4 w-4" /> <ToolButton
</ToolButton> label="تیتر ۱"
<ToolButton active={editor.isActive('heading', { level: 1 })}
label="زیرخط" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive('underline')} >
onClick={() => editor.chain().focus().toggleUnderline().run()} <Heading1Icon className="h-4 w-4" />
> </ToolButton>
<UnderlineIcon className="h-4 w-4" /> <ToolButton
</ToolButton> label="تیتر ۲"
<ToolButton active={editor.isActive('heading', { level: 2 })}
label="خط‌خورده" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('strike')} >
onClick={() => editor.chain().focus().toggleStrike().run()} <Heading2Icon className="h-4 w-4" />
> </ToolButton>
<StrikethroughIcon className="h-4 w-4" /> <ToolButton
</ToolButton> label="تیتر ۳"
active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<Heading3Icon className="h-4 w-4" />
</ToolButton>
<Divider /> <Divider />
<ToolButton {/* جهت متن — فقط محتوای ویرایشگر را عوض می‌کند */}
label="تیتر میانی" <ToolButton
active={editor.isActive('heading', { level: 2 })} label="راست‌چین (فارسی)"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={direction === 'rtl'}
> onClick={() => setDirection('rtl')}
<Heading2Icon className="h-4 w-4" /> >
</ToolButton> <AlignRightIcon className="h-4 w-4" />
<ToolButton </ToolButton>
label="تیتر کوچک" <ToolButton
active={editor.isActive('heading', { level: 3 })} label="چپ‌چین (انگلیسی)"
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={direction === 'ltr'}
> onClick={() => setDirection('ltr')}
<Heading3Icon className="h-4 w-4" /> >
</ToolButton> <AlignLeftIcon className="h-4 w-4" />
</ToolButton>
<Divider /> <Divider />
<ToolButton <ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
label="لیست نقطه‌ای" <BoldIcon className="h-4 w-4" />
active={editor.isActive('bulletList')} </ToolButton>
onClick={() => editor.chain().focus().toggleBulletList().run()} <ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
> <ItalicIcon className="h-4 w-4" />
<ListIcon className="h-4 w-4" /> </ToolButton>
</ToolButton> <ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
<ToolButton <UnderlineIcon className="h-4 w-4" />
label="لیست شماره‌ای" </ToolButton>
active={editor.isActive('orderedList')} <ToolButton label="خط‌خورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
onClick={() => editor.chain().focus().toggleOrderedList().run()} <StrikethroughIcon className="h-4 w-4" />
> </ToolButton>
<ListOrderedIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="نقل‌قول"
active={editor.isActive('blockquote')}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<QuoteIcon className="h-4 w-4" />
</ToolButton>
<Divider /> <Divider />
<ToolButton label="لینک" active={editor.isActive('link')} onClick={setLink}> <ToolButton label="لیست نقطه‌ای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
<Link2Icon className="h-4 w-4" /> <ListIcon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}> <ToolButton label="لیست شماره‌ای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />} <ListOrderedIcon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton label="نقل‌قول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
<QuoteIcon className="h-4 w-4" />
</ToolButton>
<Divider /> <Divider />
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}> <ToolButton label="افزودن لینک" active={editor.isActive('link')} onClick={openLinkModalForSelection}>
<Undo2Icon className="h-4 w-4" /> <Link2Icon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}> <ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
<Redo2Icon className="h-4 w-4" /> {uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
</ToolButton> </ToolButton>
<Divider />
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
<Undo2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
<Redo2Icon className="h-4 w-4" />
</ToolButton>
</div>
<EditorContent editor={editor} />
<input
ref={fileRef}
type="file"
data-editor-file-input
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void uploadAndInsert(file)
e.target.value = ''
}}
/>
</div> </div>
<EditorContent editor={editor} /> {/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاس‌های Tailwind مدیریت می‌کند */}
{linkModal.open && (
<input <LinkModal
ref={fileRef} initial={linkModal.initial}
type="file" editing={linkModal.editing}
data-editor-file-input onApply={applyLink}
accept="image/jpeg,image/png,image/webp,image/gif,image/avif" onRemove={removeLink}
className="hidden" onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
onChange={(e) => { />
const file = e.target.files?.[0] )}
if (file) void uploadAndInsert(file) </>
e.target.value = ''
}}
/>
</div>
) )
} }
@@ -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,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,
]
},
})
+143 -29
View File
@@ -1,5 +1,6 @@
import { useRef, useState } from 'react' import { useEffect, 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'
@@ -7,19 +8,79 @@ 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 [altText, setAltText] = useState(alt ?? '')
const [titleText, setTitleText] = useState(title ?? '')
const showTitle = title !== undefined || !!onSaveTitle
const showAlt = alt !== undefined || !!onSaveAlt
// همگام‌سازی با مقادیر ذخیره‌شده (پس از به‌روزرسانی خوش‌بینانه در درخت داده)
useEffect(() => {
setAltText(alt ?? '')
}, [alt])
useEffect(() => {
setTitleText(title ?? '')
}, [title])
if (!isAuthenticated || !isEditMode) return null if (!isAuthenticated || !isEditMode) return null
/** عنوان نمایشی: ورودی کاربر → پیشنهاد → «Photo 1» (با تغییر عنوان مقاله زنده به‌روز می‌شود) */
const titleDisplayed = titleText.trim() || titleFallback.trim() || DEFAULT_IMAGE_TITLE
/** Alt نمایشی: ورودی کاربر → پیشنهاد عنوان‌محور (هرگز URL) */
const altDisplayed = altText.trim() || altFallback.trim()
const resolveTitle = () => titleDisplayed
const resolveAlt = () => altDisplayed || label
const persistMeta = async () => {
const nextTitle = resolveTitle()
const nextAlt = resolveAlt()
setTitleText(nextTitle)
setAltText(nextAlt)
if (showTitle && nextTitle !== (title ?? '').trim() && onSaveTitle) await onSaveTitle(nextTitle)
if (showAlt && nextAlt !== (alt ?? '').trim() && onSaveAlt) await onSaveAlt(nextAlt)
}
const pick = async (file: File | undefined) => { const pick = async (file: File | undefined) => {
if (!file) return if (!file) return
setUploading(true) setUploading(true)
@@ -28,52 +89,105 @@ 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('ذخیره نشد')
await persistMeta()
}) })
} finally { } finally {
setUploading(false) setUploading(false)
} }
} }
const commitMeta = () => {
if (!onSaveTitle && !onSaveAlt) return
void persistMeta()
}
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={altText || label}
className="h-16 w-24 border border-ivory/15 object-cover" title={titleText || 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">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
>
{uploading ? (
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlusIcon className="h-3.5 w-3.5" />
)}
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
</button>
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
</div>
</div> </div>
<div className="mt-3">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
>
{uploading ? (
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlusIcon className="h-3.5 w-3.5" />
)}
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
</button>
</div>
{(showTitle || showAlt) && (
<div className="mt-4 grid max-w-md gap-4 border-t border-ivory/10 pt-4 sm:grid-cols-2">
{showTitle && (
<div>
<label htmlFor={titleId} className="text-[11px] font-light text-ivory/50">
عنوان تصویر (Title)
</label>
<input
id={titleId}
type="text"
dir="auto"
value={titleDisplayed}
onChange={(event) => setTitleText(event.target.value)}
onBlur={commitMeta}
placeholder={titleFallback || DEFAULT_IMAGE_TITLE}
className={META_FIELD_CLASS}
/>
</div>
)}
{showAlt && (
<div>
<label htmlFor={altId} className="text-[11px] font-light text-ivory/50">
متن جایگزین (Alt)
</label>
<input
id={altId}
type="text"
dir="auto"
value={altDisplayed}
onChange={(event) => setAltText(event.target.value)}
onBlur={commitMeta}
placeholder={
altFallback || 'توضیح کوتاه تصویر برای سئو و صفحه‌خوان‌ها'
}
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,100 @@
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'
/**
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
* <figure><img><figcaption>کپشن</figcaption></figure>
* دو دکمه حذف مستقل دارد: یکی برای خود تصویر و یکی برای حذف کامل کپشن.
* با حذف کپشن، عنصر <figcaption> به‌طور کامل از DOM حذف می‌شود و فقط تصویر می‌ماند.
*/
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
const { src, alt, title, caption } = node.attrs as ImageAttrs
const hasCaption = Boolean((caption ?? '').trim())
const [editingCaption, setEditingCaption] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const showCaption = hasCaption || editingCaption
// هنگام افزودن کپشن تازه، ورودی خودکار فوکوس می‌گیرد
useEffect(() => {
if (editingCaption && !hasCaption) inputRef.current?.focus()
}, [editingCaption, hasCaption])
/** حذف کپشن: مقدار صفت پاک و عنصر figcaption کاملاً unmount می‌شود */
const removeCaption = () => {
updateAttributes({ caption: '' })
setEditingCaption(false)
}
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={inputRef}
type="text"
value={caption ?? ''}
onChange={(event) => updateAttributes({ caption: event.target.value })}
placeholder="توضیح تصویر (کپشن)…"
aria-label="کپشن تصویر"
className="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"
/>
{/* حذف فقط کپشن — تصویر دست‌نخورده می‌ماند */}
<button
type="button"
onClick={removeCaption}
aria-label="حذف کپشن"
title="حذف کپشن (تصویر حذف نمی‌شود)"
className={DELETE_BUTTON_CLASS}
>
<XIcon className="h-3.5 w-3.5" />
</button>
</figcaption>
)}
</figure>
{/* وقتی کپشنی وجود ندارد، فقط یک دکمه افزودن نمایش داده می‌شود */}
{!showCaption && (
<button
type="button"
contentEditable={false}
onClick={() => setEditingCaption(true)}
className="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"
>
<PlusIcon className="h-3 w-3" aria-hidden="true" />
افزودن کپشن
</button>
)}
</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
/>
)
}
+215
View File
@@ -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=&quot;_blank&quot;</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=&quot;{relPreview}&quot;
</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()}
/>
)
}
+3 -74
View File
@@ -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 بخش‌بندی کنید.' })
+15
View File
@@ -0,0 +1,15 @@
/**
* ثابت‌های مشترک رابط کاربری — تنها منبع مقادیر پیش‌فرض (بدون متن پراکنده در کامپوننت‌ها).
*/
/** عنوان پیش‌فرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */
export const DEFAULT_IMAGE_TITLE = 'Photo 1'
/** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار می‌گیرد */
export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص'
/** سرعت مطالعه مبنای محاسبه زمان مطالعه — واژه در دقیقه */
export const WORDS_PER_MINUTE = 200
/** برچسب زمان مطالعه در خروجی */
export const READING_TIME_LABEL = 'دقیقه مطالعه'
+10
View File
@@ -55,4 +55,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[];
}; };
+144 -3
View File
@@ -101,6 +101,7 @@ html[lang='en'] .article-body h3 {
.article-body { .article-body {
line-height: 2.4; line-height: 2.4;
word-wrap: break-word; word-wrap: break-word;
font-size: 14px; /* متن اصلی ۱۴ تنظیم شد */
} }
.article-body p { .article-body p {
margin-top: 1.75rem; margin-top: 1.75rem;
@@ -109,6 +110,11 @@ html[lang='en'] .article-body h3 {
.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;
@@ -116,15 +122,46 @@ html[lang='en'] .article-body h3 {
margin-top: 3rem; margin-top: 3rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
line-height: 1.9; line-height: 1.9;
/* فاصله از هدر چسبان هنگام پرش لنگرهای فهرست مطالب */
scroll-margin-top: 7rem;
}
.article-body h1 {
font-size: 18px;
} }
.article-body h2 { .article-body h2 {
font-size: 1.6rem; font-size: 16px;
padding-inline-start: 0.85rem; padding-inline-start: 0.85rem;
border-inline-start: 3px solid #d5874c; border-inline-start: 3px solid #d5874c;
} }
.article-body h3 { .article-body h3 {
font-size: 1.25rem; font-size: 14px;
color: #e8c9a8; }
[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;
}
.article-body span[data-small="true"] {
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
}
/* مارک «متن انگلیسی» ویرایشگر — چپ‌به‌راست و ۱۲ پیکسل بدون به‌هم‌زدن جهت متن فارسی */
.english-text {
direction: ltr;
unicode-bidi: isolate;
font-size: 12px;
} }
.article-body ul, .article-body ul,
.article-body ol { .article-body ol {
@@ -168,6 +205,35 @@ html[lang='en'] .article-body h3 {
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;
@@ -195,3 +261,78 @@ html[lang='en'] .article-body h3 {
background: transparent; background: transparent;
padding: 0; padding: 0;
} }
/* ---------- تقویم شمسی (react-multi-date-picker) — تم تیره با لهجه مسی ---------- */
.aramesh-datepicker.rmdp-wrapper {
width: max-content;
border: 1px solid rgba(245, 243, 239, 0.12);
border-radius: 0;
background-color: #1e1a16;
color: #f5f3ef;
box-shadow: 0 24px 50px -18px rgba(0, 0, 0, 0.75);
}
.aramesh-datepicker .rmdp-calendar {
padding: 8px;
}
.aramesh-datepicker .rmdp-header-values {
color: #d5874c;
font-size: 13px;
font-weight: 700;
}
.aramesh-datepicker .rmdp-arrow {
border-color: #d5874c;
}
.aramesh-datepicker .rmdp-arrow-container {
box-shadow: none;
}
.aramesh-datepicker .rmdp-arrow-container:hover {
background-color: rgba(213, 135, 76, 0.2);
box-shadow: none;
}
.aramesh-datepicker .rmdp-week-day {
color: rgba(245, 243, 239, 0.45);
font-size: 11px;
font-weight: 400;
}
.aramesh-datepicker .rmdp-day {
color: rgba(245, 243, 239, 0.85);
}
.aramesh-datepicker .rmdp-day span {
border-radius: 0;
}
.aramesh-datepicker .rmdp-day:hover:not(.rmdp-disabled):not(.rmdp-selected) span {
background-color: rgba(213, 135, 76, 0.16);
color: #d5874c;
}
.aramesh-datepicker .rmdp-day.rmdp-selected span:not(.highlight) {
background-color: #d5874c;
color: #1e1a16;
font-weight: 700;
box-shadow: none;
}
.aramesh-datepicker .rmdp-day.rmdp-today span {
background-color: rgba(213, 135, 76, 0.25);
color: #f5f3ef;
}
.aramesh-datepicker .rmdp-day.rmdp-deactive {
color: rgba(245, 243, 239, 0.22);
}
.aramesh-datepicker .rmdp-day.rmdp-disabled {
color: rgba(245, 243, 239, 0.12);
}
.aramesh-datepicker .rmdp-month-picker,
.aramesh-datepicker .rmdp-year-picker {
background-color: #1e1a16;
border-radius: 0;
}
.aramesh-datepicker .rmdp-panel {
background-color: #1e1a16;
}
.aramesh-datepicker .rmdp-panel-body li {
background-color: rgba(213, 135, 76, 0.15);
color: #f5f3ef;
box-shadow: none;
}
.aramesh-datepicker .rmdp-action-button {
color: #d5874c;
}
+55 -40
View File
@@ -1,10 +1,12 @@
import React from 'react'; import React from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react'; import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react';
import { renderToHTMLString } from '@tiptap/static-renderer'; import { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal'; import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button'; import { ButtonLink } from '../components/Button';
import { ArticleCard } from '../components/ArticleCard'; import { ArticleCard } from '../components/ArticleCard';
import { ArticleMeta } from '../components/ArticleMeta';
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
import { Newsletter } from '../components/home/Newsletter'; import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api'; import { useSite } from '../api';
import { useLang } from '../i18n/lang'; import { useLang } from '../i18n/lang';
@@ -13,21 +15,21 @@ import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
import type { Article } from '../data/site'; import type { Article } from '../data/site';
/** بدنه مقاله را به HTML تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */ /** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */
function renderBody(article: Article): { html: string | null; legacy: string[] } { function renderBody(article: Article): { html: string | null; toc: TocItem[]; legacy: string[] } {
const body = article.body as unknown; const body = article.body as unknown;
if (Array.isArray(body)) return { html: null, legacy: body }; if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') { if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
try { try {
return { const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never });
html: renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }), // تیترهای H2/H3 شناسه لنگر می‌گیرند تا فهرست مطالب به آن‌ها بپرد
legacy: [], const { items, html: htmlWithIds } = extractToc(html);
}; return { html: htmlWithIds, toc: items, legacy: [] };
} catch { } catch {
return { html: null, legacy: [] }; return { html: null, toc: [], legacy: [] };
} }
} }
return { html: null, legacy: [] }; return { html: null, toc: [], legacy: [] };
} }
export function ArticleDetail() { export function ArticleDetail() {
@@ -41,7 +43,8 @@ export function ArticleDetail() {
if (!article) return; if (!article) return;
applySeo({ applySeo({
title: `${article.title} | ${brand.name}`, title: `${article.title} | ${brand.name}`,
description: article.excerpt, // توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
description: article.meta_description || article.excerpt,
path: href(`/blog/${article.slug}`), path: href(`/blog/${article.slug}`),
image: article.image, image: article.image,
type: 'article', type: 'article',
@@ -50,10 +53,10 @@ export function ArticleDetail() {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Article', '@type': 'Article',
headline: article.title, headline: article.title,
description: article.excerpt, description: article.meta_description || article.excerpt,
image: article.image ? new URL(article.image, window.location.origin).href : undefined, image: article.image ? new URL(article.image, window.location.origin).href : undefined,
inLanguage: lang, inLanguage: lang,
author: { '@type': 'Organization', name: brand }, author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
publisher: { '@type': 'Organization', name: brand }, publisher: { '@type': 'Organization', name: brand },
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href, mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
}, },
@@ -73,13 +76,18 @@ export function ArticleDetail() {
} }
const { html, legacy } = renderBody(article); const { html, toc, legacy } = renderBody(article);
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3); const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
return ( return (
<> <>
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden"> <section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
<img src={article.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" /> <img
src={article.image}
alt={article.image_alt || article.title}
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"
@@ -99,36 +107,43 @@ export function ArticleDetail() {
<span className="text-copper">{article.category}</span> <span className="text-copper">{article.category}</span>
</nav> </nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1> <h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
<div className="mt-6 flex items-center gap-5 text-xs font-light text-ivory/60">
<span>{article.date}</span> {/* نویسنده، تاریخ جلالی و زمان مطالعه */}
<span className="flex items-center gap-1.5"> <ArticleMeta article={article} className="mt-7" />
<ClockIcon className="h-3.5 w-3.5" />
{article.read}
</span>
</div>
</div> </div>
</section> </section>
<article className="bg-ink py-20 lg:py-28"> <article className="bg-ink py-20 lg:py-28">
<div className="mx-auto max-w-3xl px-6"> <div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal> <div className="mx-auto grid max-w-5xl gap-12 lg:grid-cols-[minmax(0,1fr)_17rem] lg:gap-14">
<p className="border-copper ltr:border-l-2 ltr:pl-6 rtl:border-r-2 rtl:pr-6 text-lg font-light leading-10 text-ivory/80"> <div className="min-w-0">
{article.excerpt} <Reveal>
</p> <p className="border-copper ltr:border-l-2 ltr:pl-6 rtl:border-r-2 rtl:pr-6 text-lg font-light leading-10 text-ivory/80">
</Reveal> {article.excerpt}
{html ? ( </p>
<Reveal delay={0.05}>
<div
className="article-body mt-4 text-base font-light text-ivory/70"
dangerouslySetInnerHTML={{ __html: html }} />
</Reveal>
) : (
legacy.map((paragraph, i) =>
<Reveal key={i} delay={0.03 * i}>
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
</Reveal> </Reveal>
) {html ? (
)} <Reveal delay={0.05}>
<div
className="article-body mt-4 text-base font-light text-ivory/70"
dangerouslySetInnerHTML={{ __html: html }} />
</Reveal>
) : (
legacy.map((paragraph, i) =>
<Reveal key={i} delay={0.03 * i}>
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
</Reveal>
)
)}
</div>
{/* فهرست مطالب — در دسکتاپ کنار متن چسبان می‌ماند */}
{toc.length > 0 && (
<aside className="order-first lg:order-none">
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
</aside>
)}
</div>
</div> </div>
</article> </article>
+4 -2
View File
@@ -9,6 +9,8 @@ import { useSite } from '../api'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { EditableItem } from '../admin/Editable' import { EditableItem } from '../admin/Editable'
import { useAdmin } from '../admin/AdminContext' import { useAdmin } from '../admin/AdminContext'
import { formatJalali } from '../utils/date'
import { toPersianDigits } from '../utils/digits'
import { PlusIcon } from 'lucide-react' import { PlusIcon } from 'lucide-react'
function DraftBadge({ className = '' }: { className?: string }) { function DraftBadge({ className = '' }: { className?: string }) {
@@ -94,10 +96,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">
+69
View File
@@ -0,0 +1,69 @@
import DateObject from 'react-date-object'
import gregorian from 'react-date-object/calendars/gregorian'
import persian from 'react-date-object/calendars/persian'
import persian_fa from 'react-date-object/locales/persian_fa'
/** قالب رسمی نمایش تاریخ جلالی — تقویم شمسی و اعداد فارسی */
export const JALALI_DATE_FORMATTER = new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
calendar: 'persian',
})
/** ساعت به وقت جلالی (فارسی) */
export const JALALI_TIME_FORMATTER = new Intl.DateTimeFormat('fa-IR', {
hour: '2-digit',
minute: '2-digit',
})
const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T\s].*)?$/
/**
* نمایش هر مقدار تاریخ به شکل جلالی (مثل «۲۲ شهریور ۱۴۰۵»).
* رشته‌هایی که تاریخ نیستند (مثل تاریخ شمسی دست‌نویس) دست‌نخورده برمی‌گردند.
*/
export function formatJalali(value?: string | number | Date | null, fallback = ''): string {
if (value === null || value === undefined || value === '') return fallback
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? fallback : JALALI_DATE_FORMATTER.format(value)
}
if (typeof value === 'number') {
const fromNumber = new Date(value)
return Number.isNaN(fromNumber.getTime()) ? fallback : JALALI_DATE_FORMATTER.format(fromNumber)
}
const raw = String(value).trim()
if (!raw) return fallback
if (ISO_DATE.test(raw)) {
const parsed = new Date(raw)
if (!Number.isNaN(parsed.getTime())) return JALALI_DATE_FORMATTER.format(parsed)
}
return raw
}
/** رشته ISO میلادی ذخیره‌شده (YYYY-MM-DD) → DateObject شمسی برای انتخابگر تاریخ */
export function toJalaliDateObject(value?: string | null): DateObject | null {
const raw = (value ?? '').trim()
if (!raw) return null
if (ISO_DATE.test(raw)) {
const parsed = new DateObject({ date: raw })
if (parsed.isValid) return parsed.convert(persian, persian_fa)
}
// تاریخ‌های شمسی دست‌نویس مثل ۱۴۰۳/۰۵/۱۲
const manual = new DateObject({ date: raw, calendar: persian, locale: persian_fa })
return manual.isValid ? manual : null
}
/** انتخاب انتخابگر تاریخ → رشته ISO میلادی برای ذخیره در دیتابیس */
export function toISODate(value?: DateObject | Date | null): string {
if (!value) return ''
const asObject = value instanceof Date ? new DateObject({ date: value }) : value
const gregorianDate = asObject.convert(gregorian)
const pad = (n: number) => String(n).padStart(2, '0')
return `${gregorianDate.year}-${pad(gregorianDate.month.number)}-${pad(gregorianDate.day)}`
}
+6
View File
@@ -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)])
}
+48
View File
@@ -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') }
}
+22
View File
@@ -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}`
}
+37
View File
@@ -0,0 +1,37 @@
import slugify from 'slugify'
/**
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن.
* slugify حروف فارسی و عربی را به معادل لاتین ترانویسی می‌کند تا لنگرهای
* فهرست مطالب در همه مرورگرها ASCII، پایدار و قابل اشتراک‌گذاری بمانند.
*/
export function slugifyFa(input: string): string {
const normalized = (input ?? '')
.toString()
.normalize('NFC')
// نیم‌فاصله، اتصال‌دهنده و نویسه‌های نامرئی جهت → فاصله
.replace(/[\u200b-\u200f\u202a-\u202e]/g, ' ')
// یکسان‌سازی حروف عربی با معادل فارسی
.replace(/[يى]/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/[ۀة]/g, 'ه')
.trim()
const slug = slugify(normalized, {
lower: true,
trim: true,
replacement: '-',
// فقط حروف/اعداد/فاصله/خط تیره بماند — بقیه نشانه‌ها حذف شوند
remove: /[^\w\s-]/g,
})
return slug.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '')
}
/** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی می‌گیرند (مثل about، about-2) */
export function uniqueSlug(base: string, used: Map<string, number>): string {
const key = base || 'section'
const count = used.get(key) ?? 0
used.set(key, count + 1)
return count === 0 ? key : `${key}-${count + 1}`
}
+75
View File
@@ -0,0 +1,75 @@
/**
* کمک‌تابع‌های مشترک سند TipTap — استخراج متن و ساختار از JSON مقاله.
* هم چک‌لیست سئو و هم محاسبه زمان مطالعه از همین‌جا استفاده می‌کنند (DRY).
*/
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] }) : ''
}
+1
View File
@@ -5,6 +5,7 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",