diff --git a/frontend/README.md b/frontend/README.md index d38722d..c556c82 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,11 +1,25 @@ - # Aramesh Construction Group — Frontend -A React + Vite + Tailwind CSS application. All text content and assets are fetched dynamically from the backend API (`GET /api/site`). +A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`). ## Typography -The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering. +The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users. + +## CMS, SEO & Editor Features + +The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities: + +* **Advanced SEO Management:** Dynamic `meta_description` fields (150-160 characters) and fully managed Image `Alt` texts (with auto-fallback to titles/filenames) ensure high search engine visibility. +* **Tags & Categorization:** Integrated `react-select` for modern category selection and creatable dynamic tags. +* **Article Metadata UI:** A beautifully designed metadata component displaying the author's avatar, name, and publication date right below the H1 title. +* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links. +* **Custom Tiptap WYSIWYG Editor:** + * Specific block-level toggling for Headings (H1: 18px, H2: 16px, H3: 14px) and Paragraphs (14px). + * A custom feature to wrap English text blocks in LTR styling (`` at 12px) for perfect bi-directional text alignment. +* **Full Jalali (Shamsi) Calendar Integration:** + * Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience. + * Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian. ## Development Setup @@ -36,3 +50,4 @@ npm run build * `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD). * `src/data/site.ts` — Shared TypeScript data models and interfaces. + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b866068..73c9e6d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,7 +19,10 @@ "lucide-react": "0.522.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-multi-date-picker": "^4.5.2", "react-router-dom": "^6.26.2", + "react-select": "^5.10.2", + "slugify": "^1.6.9", "sweetalert2": "^11.26.25" }, "devDependencies": { @@ -933,7 +936,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", - "optional": true, "dependencies": { "@floating-ui/utils": "^0.2.12" } @@ -943,7 +945,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", - "optional": true, "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" @@ -953,8 +954,7 @@ "version": "0.2.12", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", @@ -2020,6 +2020,15 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", @@ -2763,6 +2772,16 @@ "node": ">=6.0.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.407", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz", @@ -3733,6 +3752,12 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3860,7 +3885,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4238,6 +4262,17 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/prosemirror-changeset": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz", @@ -4420,6 +4455,12 @@ "node": ">=0.10.0" } }, + "node_modules/react-date-object": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/react-date-object/-/react-date-object-2.1.9.tgz", + "integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==", + "license": "MIT" + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -4433,12 +4474,36 @@ "react": "^18.3.1" } }, + "node_modules/react-element-popper": { + "version": "2.1.7", + "resolved": "https://registry.npmmirror.com/react-element-popper/-/react-element-popper-2.1.7.tgz", + "integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-multi-date-picker": { + "version": "4.5.2", + "resolved": "https://registry.npmmirror.com/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz", + "integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==", + "license": "MIT", + "dependencies": { + "react-date-object": "^2.1.8", + "react-element-popper": "^2.1.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4481,6 +4546,43 @@ "react-dom": ">=16.8" } }, + "node_modules/react-select": { + "version": "5.10.2", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz", + "integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -4693,6 +4795,15 @@ "node": ">=8" } }, + "node_modules/slugify": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -5055,6 +5166,20 @@ "punycode": "^2.1.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index de07a6f..d1c832f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,10 @@ "lucide-react": "0.522.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-multi-date-picker": "^4.5.2", "react-router-dom": "^6.26.2", + "react-select": "^5.10.2", + "slugify": "^1.6.9", "sweetalert2": "^11.26.25" }, "devDependencies": { diff --git a/frontend/src/admin/ArticleWritePage.tsx b/frontend/src/admin/ArticleWritePage.tsx index a8bc8ea..cd668fe 100644 --- a/frontend/src/admin/ArticleWritePage.tsx +++ b/frontend/src/admin/ArticleWritePage.tsx @@ -8,22 +8,21 @@ import { adminApi } from './adminClient' import { ArticleEditor } from '../components/admin/ArticleEditor' import { SeoChecklist } from '../components/admin/SeoChecklist' import { ImageField } from '../components/admin/ImageField' -import type { ArticleBodyDoc } from '../data/site' +import { JalaliDatePicker } from '../components/admin/JalaliDatePicker' +import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect' +import { ARTICLE_CATEGORIES } from './entities' +import type { Article, ArticleBodyDoc } from '../data/site' const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc -type Draft = { - id: number - slug: string - title: string - date: string - category: string - image: string - read: string - excerpt: string - body: ArticleBodyDoc - status: 'draft' | 'published' - seo_keyphrase: string +/** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته */ +type Draft = Omit & { body: ArticleBodyDoc } + +/** داده‌های قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */ +function normalizeTags(value: unknown): string[] { + if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean) + if (typeof value === 'string') return value.split(',').map((t) => t.trim()).filter(Boolean) + return [] } const STATUS_TEXT: Record = { @@ -50,9 +49,13 @@ export function ArticleWritePage() { id: 0, slug: `article-${Date.now()}`, title: '', - date: new Date().toLocaleDateString('fa-IR'), - category: 'یادداشت', + date: new Date().toISOString().split('T')[0], + category: ARTICLE_CATEGORIES[0], + tags: [], image: '', + image_alt: '', + author: '', + meta_description: '', read: '۵ دقیقه', excerpt: '', body: EMPTY_DOC, @@ -92,6 +95,7 @@ export function ArticleWritePage() { if (!article || createdRef.current) return setDraft({ ...article, + tags: normalizeTags(article.tags), body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc, }) setCreatedId(article.id) @@ -190,6 +194,8 @@ export function ArticleWritePage() { queueSave({ [name]: e.target.value }), }) + const metaLength = (draft.meta_description ?? '').trim().length + return (
{/* نوار بالا */} @@ -280,15 +286,21 @@ export function ArticleWritePage() { />
- {/* تصویر شاخص */} + {/* تصویر شاخص + متن جایگزین */}
{ queueSave({ image: url }) return true }} + onSaveAlt={async (alt) => { + queueSave({ image_alt: alt }) + return true + }} />
@@ -296,23 +308,54 @@ export function ArticleWritePage() {
} - onChange={(json) => queueSave({ body: json })} + onChange={(json) => { + // محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن + const textContent = JSON.stringify(json) + const wordCount = textContent.split(/\s+/).length + const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه' + queueSave({ body: json, read: calculatedReadTime }) + }} />
+ {/* توضیحات متای سئو */} +
+
+

توضیحات سئو (Meta Description)

+ = 150 && metaLength <= 160 ? 'text-emerald-400' : 'text-ivory/40' + }`} + > + {metaLength.toLocaleString('fa-IR')} از ۱۶۰ نویسه + +
+

+ این همان متنی است که در صفحه نتایج گوگل زیر لینک مقاله به کاربران نمایش داده می‌شود (۱۵۰ تا ۱۶۰ نویسه). +

+