add contact request

This commit is contained in:
2026-07-14 11:19:41 +03:30
parent 493ee8105e
commit 259a725fba
15 changed files with 1863 additions and 69 deletions
+672 -24
View File
@@ -8,6 +8,15 @@
"name": "noro-admin",
"version": "0.1.0",
"dependencies": {
"@tiptap/extension-character-count": "^3.27.3",
"@tiptap/extension-image": "^3.27.3",
"@tiptap/extension-link": "^3.27.3",
"@tiptap/extension-placeholder": "^3.27.3",
"@tiptap/extension-text-align": "^3.27.3",
"@tiptap/extension-underline": "^3.27.3",
"@tiptap/pm": "^3.27.3",
"@tiptap/starter-kit": "^3.27.3",
"@tiptap/vue-3": "^3.27.3",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
@@ -45,7 +54,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -55,7 +63,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -65,7 +72,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
@@ -91,7 +97,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
@@ -492,6 +497,31 @@
"node": ">=12"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -517,7 +547,6 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@@ -919,6 +948,480 @@
"win32"
]
},
"node_modules/@tiptap/core": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.3.tgz",
"integrity": "sha512-TJj5929M96C1KlH796wS8MywfHDh49RhmakOyzyMMc9pFmRj9UXi1gj0TCXgsZtjEOG7B+m/DRvNOvnuvR9kmg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.27.3"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.27.3.tgz",
"integrity": "sha512-VGoVMcqcGkkoduzEqQ70ZK5OOHBDn5iYeHJSgkvNoSHzYHl0CaKoWxoDKJjSWC5DAM4mBU+tXTlsiUp3evRMfA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.27.3.tgz",
"integrity": "sha512-pchbycFppBqBmvk+OxrQLIPZLNd3rNbcm7zOa+OTjluphpAcsJ6AmHBmiRhJUYWitV6/SA+0nujcaWxBp5hNcQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.27.3.tgz",
"integrity": "sha512-PKSM9g8BXzO0Lxm2gOin7FAFvxO6T0QvlXTXVke/GJn/sR1tEz7w5ybsvweytUPmSliUc601pfIUOHKEhIVC2Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3",
"@tiptap/pm": "3.27.3"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.27.3.tgz",
"integrity": "sha512-owQW6mcgnYgQU1z5BqmslcjZFBUm1SGM/Ax++4R6XelIt/7ol6uSTjzjAzum+ASW7UlBbuJHuSgRjl8cA+RGkg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.27.3"
}
},
"node_modules/@tiptap/extension-character-count": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-3.27.3.tgz",
"integrity": "sha512-dtgj4ume0d+4Zkd8osj9mGLNhZpB5BxketX0JkIdi7M5dzF6aAdEzrIl1wYbccUplVrzNsqxVR4Hs7bOjiJ4tA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.27.3"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.27.3.tgz",
"integrity": "sha512-T7JF10Y3k7fyVujXXLVubdUh7fctgBUoiuxbvjotpltg1U/mPbOcT0UW/uif9j3H8TFvAwNApVw3x8bwwFUakA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.27.3.tgz",
"integrity": "sha512-3CVnzkpGoqqI5KaXI9l9PMwLkx34SYY63tpeo0I0QjDk4LU+JTAQDTPJFSwB4zTsHZ7ZFMU0jjasFOZ1kZRVgw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3",
"@tiptap/pm": "3.27.3"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.27.3.tgz",
"integrity": "sha512-PR25MRv56Nm4ETVLwA8nAh/0RrVdvD4D2nxnAC8kUkxz1WOYDaqTfPqqEeSWDoUUTUp1I1xzDkD4x0gEnAlmZA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.27.3.tgz",
"integrity": "sha512-XsL0Ziual+ynXyx5JgLb0KIOShlMN3MW7+GQvf9PGRb1vOB7IOTRFDxMPrxDWcFIZ6WWMWSrbkCLOKHStt1BWg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.27.3"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.27.3.tgz",
"integrity": "sha512-qL+g1Z6MqZvYG4mHTPwub9II2fWZY+2I5VFfV8fzqy1HchBOyY6Df0mRKDNb0bmjBdFabIXnswz3iqLx3mDTbw==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.27.3",
"@tiptap/pm": "3.27.3"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.27.3.tgz",
"integrity": "sha512-MVBqzwYrDOttkn2GOrvvKTDCymfSS2VGSKpahcKKhge5mweSDiqko2X1vBDnMF8aVW3WxIFfDMmO4VMmh8HDZA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.27.3"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.27.3.tgz",
"integrity": "sha512-cySObJITR2CRrWII87mI2LJ12wEETTnxt2p1vvC6ZtUmKnB50fOu+ux8ZEt6KYNocIOtrEZthNFzTOx7R7UtrQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.3.tgz",
"integrity": "sha512-QHXnsNic6iId8pnsFZ8z4PkX5L+HCHa/D7rAi3nNWtPlSIAOxo4nKrALcB5/tHmY+XL8kEXKH3nsNLNEDLCYPg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.27.3.tgz",
"integrity": "sha512-sNCuo0+Q001B5XTVWY+ULpWXQqtBNRmAqXxAexZl44bx3rDqHG8OzjwT4EM0LIj2+BYVbdqwaJ1vfQbZVEEbig==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3",
"@tiptap/pm": "3.27.3"
}
},
"node_modules/@tiptap/extension-image": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.27.3.tgz",
"integrity": "sha512-3OVLRH54Xkh9yrI5oGIempuqZkhLG1plyJBRnapx0N8j9IjEImOLF78kPYoAZzaBuU78M3Zk/E7HQZgmnaNRBg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.27.3.tgz",
"integrity": "sha512-7VpVi2vn8kGFAc/HLrt96J/E6+LXOGKn8xLhJS863t150yLIG3EPQLA1h+G5O6eo+/w+9YokvJAAJoeDihj1Jg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.27.3.tgz",
"integrity": "sha512-iXmu1tJ/3vP60c9d+zfO3+X64vod2EyBrM1qeufecAtWA6t0bT9tYeka0Zq6qDRGsZK5zViO9F2lsY7eHXiYuA==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3",
"@tiptap/pm": "3.27.3"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.27.3.tgz",
"integrity": "sha512-52rcaSzYjtVGUrNkdB8k1Bw/rId2lBirrWMvmnBLsieoeFd8zo8ow5zkwV057BYSgWXfnMAV7GOpDyq2IoSD9A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3",
"@tiptap/pm": "3.27.3"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.27.3.tgz",
"integrity": "sha512-UddhVaQ7+V9IfQVVE8AJLNzSmyaaNpZPQsoPQxFNlJyOFxD6RwSTT8L4rb/TwbUctxSFWpyd1J7exzf33Ony/g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.27.3"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.27.3.tgz",
"integrity": "sha512-GZPVg4QCFm41yTMPEPeTlH2odNeQwRuWHY/pt3pI5LsfipQ4kDBv6h9we928vJQUnU2Gg9Y3cL5iRbmXIWWTUA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.27.3"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.27.3.tgz",
"integrity": "sha512-oHYoE65WfRSM5tCcXi8MtQmzPxz83E2qw0pa2yHBKwr9xCCQUu8vJHJE+EJGduWdaNImv43+6/6kVMeYYbXRKg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.27.3"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.27.3.tgz",
"integrity": "sha512-G5XPCN7lm0nULYPelJC2eUbKbt1q97j67e/HSWxwMYEXhceec5eNAtVfpDCRyXNO9osyBi2kXqLv/IQtxbch9A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.27.3.tgz",
"integrity": "sha512-Y43dGaN3rA0oU4CWmXkZ2idjZjrxPX9oy0zZL92EgXOguhAUToBs0dIxRG5ZanYX9A8kKKWPlfeRFlBMtRhKvg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.27.3"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.27.3.tgz",
"integrity": "sha512-dp6Rs3I4zLVJPvAw2Q9yrNuYKcX5LTONH3/oyULvGsrqq5DKz7pqscfwhwnDSzrgm/3aCz6B9r1BVHlYI/DXlA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.27.3.tgz",
"integrity": "sha512-wSatZ/bop0pleeC18nF90zvWWEoRe/ewZncvm8LjcMrXwq41kTEs3j7nZflMYAh7X8fZvu00UIMeEyCNJl+5Yg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.27.3.tgz",
"integrity": "sha512-yyhlwRz0Eh6xQCn+GtTDWUGqxGajT02Qf8zBN/3eZQtl3EZ2xrI8lvfLmcV9SAW0srgMd+5hX0CVV+zKeXuxeg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.27.3.tgz",
"integrity": "sha512-YH7ka/Rp7ZwlzTuQCcs0OZ4CMSx/P/MzbseQKxKebEIZreWaKMZAaED97Fnu0dA66VlreD4/1C7xHJy84ovamg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.27.3.tgz",
"integrity": "sha512-IA2QKUVJgzUPEhhkUfr6P5u5GFVfhiApiEaaHXBz07saL2c4HNoVs2RC18QlZDCtLNKrPR1mUScr72u7uYs+YQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.3",
"@tiptap/pm": "3.27.3"
}
},
"node_modules/@tiptap/pm": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.3.tgz",
"integrity": "sha512-ppiG57RxM3HSHHgcHT0hP6Ib4P56Sd5itxdV4w0hIGHKPGyLLKiAkYp+htIHa9T7IvjMdaqxIlsfyV3ZKsS1sw==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.4.1",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.0",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.9",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.41.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.27.3.tgz",
"integrity": "sha512-xX3baFqiC30skntdhxUUvyJo755ON9c1pE83+2Wiq2g+Qnffg9knUuLCZStHoZZ318yB0aBsKAMvHWLtYDo8AA==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.27.3",
"@tiptap/extension-blockquote": "^3.27.3",
"@tiptap/extension-bold": "^3.27.3",
"@tiptap/extension-bullet-list": "^3.27.3",
"@tiptap/extension-code": "^3.27.3",
"@tiptap/extension-code-block": "^3.27.3",
"@tiptap/extension-document": "^3.27.3",
"@tiptap/extension-dropcursor": "^3.27.3",
"@tiptap/extension-gapcursor": "^3.27.3",
"@tiptap/extension-hard-break": "^3.27.3",
"@tiptap/extension-heading": "^3.27.3",
"@tiptap/extension-horizontal-rule": "^3.27.3",
"@tiptap/extension-italic": "^3.27.3",
"@tiptap/extension-link": "^3.27.3",
"@tiptap/extension-list": "^3.27.3",
"@tiptap/extension-list-item": "^3.27.3",
"@tiptap/extension-list-keymap": "^3.27.3",
"@tiptap/extension-ordered-list": "^3.27.3",
"@tiptap/extension-paragraph": "^3.27.3",
"@tiptap/extension-strike": "^3.27.3",
"@tiptap/extension-text": "^3.27.3",
"@tiptap/extension-underline": "^3.27.3",
"@tiptap/extensions": "^3.27.3",
"@tiptap/pm": "^3.27.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/vue-3": {
"version": "3.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.27.3.tgz",
"integrity": "sha512-/VfDPXTM6R9w913oziODzHoZQMdtB0jFvJLF8LxYbO1FwDopPuX/kCWaeDyvr2CYrutbCe3PBj5FzPExOIJ3zQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.27.3",
"@tiptap/extension-floating-menu": "^3.27.3"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.27.3",
"@tiptap/pm": "3.27.3",
"vue": "^3.0.0"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -944,7 +1447,6 @@
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz",
"integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
@@ -958,7 +1460,6 @@
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz",
"integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.39",
@@ -969,7 +1470,6 @@
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz",
"integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
@@ -987,7 +1487,6 @@
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz",
"integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.39",
@@ -1005,7 +1504,6 @@
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz",
"integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.39"
@@ -1015,7 +1513,6 @@
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz",
"integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.39",
@@ -1026,7 +1523,6 @@
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz",
"integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.39",
@@ -1039,7 +1535,6 @@
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz",
"integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.39",
@@ -1053,7 +1548,6 @@
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
"dev": true,
"license": "MIT"
},
"node_modules/accepts": {
@@ -1599,7 +2093,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/date-fns": {
@@ -1729,7 +2222,6 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -1827,7 +2319,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/etag": {
@@ -2322,6 +2813,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -2375,7 +2872,6 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -2536,7 +3032,6 @@
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -2623,6 +3118,12 @@
"node": ">= 0.8"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2649,7 +3150,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -2689,7 +3189,6 @@
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -2854,6 +3353,145 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz",
"integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.11",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz",
"integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.1",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.1.tgz",
"integrity": "sha512-rRqzZnRgkyh69XoOMrfFJHwauHscLBmHbq772kwbic1ymQAM8gXjzEbJse5j1ep2UO2HRIAQL0bY3kZ/RoqjVw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -3060,6 +3698,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -3272,7 +3916,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -3574,7 +4217,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -3726,7 +4369,6 @@
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz",
"integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.39",
@@ -3760,6 +4402,12 @@
"vue": "^3.5.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+10 -1
View File
@@ -12,7 +12,16 @@
"seed": "node server/seed.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"@tiptap/extension-character-count": "^3.27.3",
"@tiptap/extension-image": "^3.27.3",
"@tiptap/extension-link": "^3.27.3",
"@tiptap/extension-placeholder": "^3.27.3",
"@tiptap/extension-text-align": "^3.27.3",
"@tiptap/extension-underline": "^3.27.3",
"@tiptap/pm": "^3.27.3",
"@tiptap/starter-kit": "^3.27.3",
"@tiptap/vue-3": "^3.27.3",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
+2 -1
View File
@@ -16,7 +16,8 @@ const defaultDb = () => ({
clients: [],
stats: [],
blogPosts: [],
media: []
media: [],
contactRequests: []
})
function ensureDataDir() {
+64
View File
@@ -242,6 +242,70 @@ app.put('/api/media/:id', authMiddleware, (req, res) => {
res.json(updated)
})
app.post('/api/contact-requests', (req, res) => {
const { name, email, phone, category, message } = req.body || {}
if (!name?.trim() || !email?.trim() || !category?.trim() || !message?.trim()) {
return res.status(400).json({ error: 'نام، ایمیل، نوع پروژه و توضیحات الزامی است' })
}
const request = {
id: randomUUID(),
name: name.trim(),
email: email.trim(),
phone: phone?.trim() || '',
category: category.trim(),
message: message.trim(),
status: 'new',
createdAt: new Date().toISOString(),
}
updateDb((db) => {
if (!Array.isArray(db.contactRequests)) db.contactRequests = []
db.contactRequests.unshift(request)
})
res.status(201).json({ ok: true, id: request.id })
})
app.get('/api/contact-requests', authMiddleware, (_req, res) => {
const requests = readDb().contactRequests || []
res.json(requests)
})
app.patch('/api/contact-requests/:id', authMiddleware, (req, res) => {
const { status } = req.body || {}
if (!['new', 'read', 'archived'].includes(status)) {
return res.status(400).json({ error: 'وضعیت نامعتبر است' })
}
let updated = null
updateDb((db) => {
if (!Array.isArray(db.contactRequests)) db.contactRequests = []
const i = db.contactRequests.findIndex((r) => r.id === req.params.id)
if (i === -1) return
db.contactRequests[i] = { ...db.contactRequests[i], status }
updated = db.contactRequests[i]
})
if (!updated) return res.status(404).json({ error: 'درخواست یافت نشد' })
res.json(updated)
})
app.delete('/api/contact-requests/:id', authMiddleware, (req, res) => {
let found = false
updateDb((db) => {
if (!Array.isArray(db.contactRequests)) db.contactRequests = []
const i = db.contactRequests.findIndex((r) => r.id === req.params.id)
if (i !== -1) {
db.contactRequests.splice(i, 1)
found = true
}
})
if (!found) return res.status(404).json({ error: 'درخواست یافت نشد' })
res.status(204).end()
})
app.delete('/api/media/:id', authMiddleware, (req, res) => {
let removed = null
updateDb((db) => {
+26
View File
@@ -85,6 +85,17 @@ export interface MediaItem {
createdAt?: string
}
export interface ContactRequest {
id: string
name: string
email: string
phone?: string
category: string
message: string
status: 'new' | 'read' | 'archived'
createdAt: string
}
export interface SiteSettings {
name?: string
nameEn?: string
@@ -296,6 +307,21 @@ export const api = {
deleteMedia(id: string) {
return request<void>(`/media/${id}`, { method: 'DELETE' })
},
getContactRequests() {
return request<ContactRequest[]>('/contact-requests')
},
updateContactRequest(id: string, data: Pick<ContactRequest, 'status'>) {
return request<ContactRequest>(`/contact-requests/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
},
deleteContactRequest(id: string) {
return request<void>(`/contact-requests/${id}`, { method: 'DELETE' })
},
}
export { ApiError }
+423
View File
@@ -0,0 +1,423 @@
<template>
<div class="blog-editor overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div class="flex flex-wrap items-center gap-1 border-b border-slate-200 bg-slate-50 px-2 py-2">
<div class="flex flex-wrap items-center gap-0.5">
<button
v-for="item in headingOptions"
:key="item.level"
type="button"
class="editor-btn"
:class="{ 'is-active': editor?.isActive('heading', { level: item.level }) }"
:title="item.label"
@click="setHeading(item.level)"
>
{{ item.short }}
</button>
</div>
<span class="editor-divider" />
<button
type="button"
class="editor-btn"
:class="{ 'is-active': editor?.isActive('bold') }"
title="درشت"
@click="editor?.chain().focus().toggleBold().run()"
>
<strong>B</strong>
</button>
<button
type="button"
class="editor-btn"
:class="{ 'is-active': editor?.isActive('italic') }"
title="کج"
@click="editor?.chain().focus().toggleItalic().run()"
>
<em>I</em>
</button>
<button
type="button"
class="editor-btn"
:class="{ 'is-active': editor?.isActive('underline') }"
title="زیرخط"
@click="editor?.chain().focus().toggleUnderline().run()"
>
<span class="underline">U</span>
</button>
<button
type="button"
class="editor-btn"
:class="{ 'is-active': editor?.isActive('strike') }"
title="خط‌خورده"
@click="editor?.chain().focus().toggleStrike().run()"
>
<span class="line-through">S</span>
</button>
<span class="editor-divider" />
<button
type="button"
class="editor-btn"
:class="{ 'is-active': editor?.isActive({ textAlign: 'right' }) }"
title="راست‌چین"
@click="editor?.chain().focus().setTextAlign('right').run()"
>
≡
</button>
<button
type="button"
class="editor-btn"
:class="{ 'is-active': editor?.isActive({ textAlign: 'center' }) }"
title="وسط‌چین"
@click="editor?.chain().focus().setTextAlign('center').run()"
>
≡
</button>
<button
type="button"
class="editor-btn"
:class="{ 'is-active': editor?.isActive({ textAlign: 'left' }) }"
title="چپ‌چین"
@click="editor?.chain().focus().setTextAlign('left').run()"
>
≡
</button>
<span class="editor-divider" />
<button
type="button"
class="editor-btn"
:class="{ 'is-active': editor?.isActive('bulletList') }"
title="لیست نقطه‌ای"
@click="editor?.chain().focus().toggleBulletList().run()"
>
•≡
</button>
<button
type="button"
class="editor-btn"
:class="{ 'is-active': editor?.isActive('orderedList') }"
title="لیست شماره‌دار"
@click="editor?.chain().focus().toggleOrderedList().run()"
>
1.
</button>
<button
type="button"
class="editor-btn"
:class="{ 'is-active': editor?.isActive('blockquote') }"
title="نقل‌قول"
@click="editor?.chain().focus().toggleBlockquote().run()"
>
"
</button>
<button
type="button"
class="editor-btn"
title="خط جداکننده"
@click="editor?.chain().focus().setHorizontalRule().run()"
>
―
</button>
<span class="editor-divider" />
<button type="button" class="editor-btn" title="لینک" @click="setLink">
🔗
</button>
<button
v-if="editor?.isActive('link')"
type="button"
class="editor-btn"
title="حذف لینک"
@click="editor?.chain().focus().unsetLink().run()"
>
✕
</button>
<button
type="button"
class="editor-btn"
:disabled="uploadingImage"
title="درج تصویر در محل مکان‌نما"
@click="triggerImageUpload('insert')"
>
{{ uploadingImage ? '...' : '🖼' }}
</button>
<template v-if="editor?.isActive('image')">
<button type="button" class="editor-btn" title="ویرایش تصویر" @click="editSelectedImage">
✎
</button>
<button type="button" class="editor-btn" title="جایگزینی تصویر" @click="triggerImageUpload('replace')">
↻
</button>
<button type="button" class="editor-btn" title="حذف تصویر" @click="deleteSelectedImage">
🗑
</button>
</template>
<input
ref="imageInput"
type="file"
accept="image/*"
class="hidden"
@change="onImageSelected"
/>
<span class="editor-divider" />
<button
type="button"
class="editor-btn"
:disabled="!editor?.can().chain().focus().undo().run()"
title="بازگشت"
@click="editor?.chain().focus().undo().run()"
>
↶
</button>
<button
type="button"
class="editor-btn"
:disabled="!editor?.can().chain().focus().redo().run()"
title="ازنو"
@click="editor?.chain().focus().redo().run()"
>
↷
</button>
</div>
<div class="grid lg:grid-cols-[220px_1fr]">
<aside class="border-b border-slate-200 bg-slate-50/80 p-3 lg:border-b-0 lg:border-l">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
افزودن بلوک
</p>
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
<button
v-for="block in blockWidgets"
:key="block.id"
type="button"
class="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-right text-sm text-slate-700 transition hover:border-brand/40 hover:bg-brand/5"
@click="block.action()"
>
<span class="text-base" aria-hidden="true">{{ block.icon }}</span>
<span>{{ block.label }}</span>
</button>
</div>
</aside>
<div class="relative">
<EditorContent :editor="editor" class="blog-editor-content" />
<p v-if="editor" class="border-t border-slate-100 px-4 py-2 text-xs text-slate-400">
{{ wordCount }} کلمه · {{ characterCount }} کاراکتر
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import TextAlign from '@tiptap/extension-text-align'
import Underline from '@tiptap/extension-underline'
import Placeholder from '@tiptap/extension-placeholder'
import CharacterCount from '@tiptap/extension-character-count'
import { BlogImage } from '@/components/blogImageExtension'
import { api } from '@/api/client'
import { normalizeEditorContent } from '@/utils/blogContent'
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const imageInput = ref<HTMLInputElement | null>(null)
const uploadingImage = ref(false)
const imageUploadMode = ref<'insert' | 'replace'>('insert')
const headingOptions = [
{ level: 2 as const, label: 'عنوان بخش', short: 'H2' },
{ level: 3 as const, label: 'زیرعنوان', short: 'H3' },
{ level: 4 as const, label: 'عنوان کوچک', short: 'H4' },
]
const editor = useEditor({
content: normalizeEditorContent(props.modelValue),
extensions: [
StarterKit.configure({
heading: { levels: [2, 3, 4] },
}),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer' },
}),
BlogImage.configure({
HTMLAttributes: { class: 'blog-editor-image' },
inline: false,
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Placeholder.configure({
placeholder: 'محتوای مقاله را اینجا بنویسید یا از پنل کناری بلوک اضافه کنید...',
}),
CharacterCount,
],
editorProps: {
attributes: {
class: 'blog-editor-prose',
dir: 'rtl',
},
},
onUpdate: ({ editor: current }) => {
emit('update:modelValue', current.getHTML())
},
})
const characterCount = computed(() => editor.value?.storage.characterCount.characters() ?? 0)
const wordCount = computed(() => editor.value?.storage.characterCount.words() ?? 0)
const blockWidgets = computed(() => [
{
id: 'paragraph',
icon: '¶',
label: 'پاراگراف',
action: () => editor.value?.chain().focus().setParagraph().run(),
},
{
id: 'h2',
icon: 'H',
label: 'عنوان اصلی',
action: () => editor.value?.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
id: 'h3',
icon: 'h',
label: 'زیرعنوان',
action: () => editor.value?.chain().focus().toggleHeading({ level: 3 }).run(),
},
{
id: 'list',
icon: '•',
label: 'لیست',
action: () => editor.value?.chain().focus().toggleBulletList().run(),
},
{
id: 'quote',
icon: '❝',
label: 'نقل‌قول',
action: () => editor.value?.chain().focus().toggleBlockquote().run(),
},
{
id: 'image',
icon: '🖼',
label: 'تصویر',
action: () => insertImageAtCursor(),
},
{
id: 'divider',
icon: '―',
label: 'جداکننده',
action: () => editor.value?.chain().focus().setHorizontalRule().run(),
},
])
watch(
() => props.modelValue,
(value) => {
if (!editor.value) return
const current = editor.value.getHTML()
const normalized = normalizeEditorContent(value)
if (normalized !== current) {
editor.value.commands.setContent(normalized, { emitUpdate: false })
}
}
)
function setHeading(level: 2 | 3 | 4) {
editor.value?.chain().focus().toggleHeading({ level }).run()
}
function setLink() {
const previous = editor.value?.getAttributes('link').href as string | undefined
const url = window.prompt('آدرس لینک را وارد کنید:', previous || 'https://')
if (url === null) return
if (!url.trim()) {
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url.trim() }).run()
}
function triggerImageUpload(mode: 'insert' | 'replace' = 'insert') {
imageUploadMode.value = mode
imageInput.value?.click()
}
function insertImageAtCursor() {
if (!editor.value) return
editor.value.chain().focus().run()
triggerImageUpload('insert')
}
function deleteSelectedImage() {
if (!editor.value?.isActive('image')) return
if (!confirm('این تصویر از متن حذف شود؟')) return
editor.value.chain().focus().deleteSelection().run()
}
function editSelectedImage() {
if (!editor.value?.isActive('image')) return
const currentAlt = (editor.value.getAttributes('image').alt as string) || ''
const alt = window.prompt('توضیح تصویر (زیرنویس):', currentAlt)
if (alt === null) return
editor.value.chain().focus().updateAttributes('image', { alt: alt.trim() }).run()
}
async function onImageSelected(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !editor.value) return
uploadingImage.value = true
try {
const uploaded = await api.uploadMedia(file)
const currentAlt =
imageUploadMode.value === 'replace'
? ((editor.value.getAttributes('image').alt as string) || '')
: ''
const alt =
window.prompt('توضیح تصویر (زیرنویس — اختیاری):', currentAlt || file.name) ?? currentAlt
if (imageUploadMode.value === 'replace' && editor.value.isActive('image')) {
editor.value
.chain()
.focus()
.updateAttributes('image', { src: uploaded.url, alt: alt.trim() })
.run()
} else {
editor.value
.chain()
.focus()
.setImage({ src: uploaded.url, alt: alt.trim() })
.run()
}
} catch (err) {
window.alert(err instanceof Error ? err.message : 'خطا در آپلود تصویر')
} finally {
uploadingImage.value = false
input.value = ''
imageUploadMode.value = 'insert'
}
}
onBeforeUnmount(() => {
editor.value?.destroy()
})
</script>
+167
View File
@@ -0,0 +1,167 @@
<template>
<NodeViewWrapper
as="figure"
class="blog-image-node"
:class="{ 'is-selected': selected }"
data-drag-handle
>
<div class="blog-image-node__frame">
<img
:src="node.attrs.src"
:alt="node.attrs.alt || ''"
:title="node.attrs.title || undefined"
class="blog-editor-image"
draggable="false"
@click="selectImage"
>
<div v-if="selected" class="blog-image-node__toolbar">
<button type="button" class="blog-image-node__btn" @click.stop="toggleEdit">
{{ editing ? 'بستن' : 'ویرایش' }}
</button>
<button type="button" class="blog-image-node__btn" @click.stop="replaceImage">
جایگزین
</button>
<button
type="button"
class="blog-image-node__btn blog-image-node__btn--danger"
@click.stop="removeImage"
>
حذف
</button>
</div>
</div>
<figcaption v-if="node.attrs.alt && !editing" class="blog-image-node__caption">
{{ node.attrs.alt }}
</figcaption>
<div v-if="selected && editing" class="blog-image-node__panel" @click.stop>
<label class="blog-image-node__label">توضیح تصویر (زیرنویس)</label>
<input
v-model="altText"
type="text"
class="blog-image-node__input"
placeholder="مثلاً: نمونه طراحی لوگو"
@keydown.enter.prevent="saveAlt"
>
<label class="blog-image-node__label">آدرس تصویر</label>
<input
v-model="srcText"
type="text"
class="blog-image-node__input"
dir="ltr"
placeholder="/uploads/..."
@keydown.enter.prevent="saveSrc"
>
<div class="blog-image-node__actions">
<button type="button" class="blog-image-node__btn" @click="saveEdits">
اعمال تغییرات
</button>
<button type="button" class="blog-image-node__btn" @click="replaceImage">
آپلود تصویر جدید
</button>
</div>
<p v-if="localError" class="blog-image-node__error">{{ localError }}</p>
</div>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden"
@change="onFileSelected"
>
</NodeViewWrapper>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { api } from '@/api/client'
const props = defineProps(nodeViewProps)
const editing = ref(false)
const altText = ref('')
const srcText = ref('')
const localError = ref('')
const uploading = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
watch(
() => props.selected,
(isSelected) => {
if (!isSelected) editing.value = false
}
)
watch(
() => props.node.attrs,
(attrs) => {
altText.value = attrs.alt || ''
srcText.value = attrs.src || ''
},
{ immediate: true, deep: true }
)
function selectImage() {
const pos = props.getPos()
if (typeof pos !== 'number') return
props.editor.chain().focus().setNodeSelection(pos).run()
}
function toggleEdit() {
editing.value = !editing.value
if (editing.value) {
altText.value = props.node.attrs.alt || ''
srcText.value = props.node.attrs.src || ''
}
}
function saveEdits() {
props.updateAttributes({
alt: altText.value.trim(),
src: srcText.value.trim() || props.node.attrs.src,
})
editing.value = false
localError.value = ''
}
function saveAlt() {
saveEdits()
}
function saveSrc() {
saveEdits()
}
function removeImage() {
if (!confirm('این تصویر از متن حذف شود؟')) return
props.deleteNode()
}
function replaceImage() {
fileInput.value?.click()
}
async function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploading.value = true
localError.value = ''
try {
const uploaded = await api.uploadMedia(file)
props.updateAttributes({ src: uploaded.url })
srcText.value = uploaded.url
editing.value = true
} catch (err) {
localError.value = err instanceof Error ? err.message : 'خطا در آپلود تصویر'
} finally {
uploading.value = false
input.value = ''
}
}
</script>
+9
View File
@@ -0,0 +1,9 @@
import Image from '@tiptap/extension-image'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import BlogImageNode from './BlogImageNode.vue'
export const BlogImage = Image.extend({
addNodeView() {
return VueNodeViewRenderer(BlogImageNode)
},
})
+2
View File
@@ -67,6 +67,7 @@ const navItems = [
{ name: 'services', label: 'خدمات', icon: '💼' },
{ name: 'clients', label: 'مشتریان', icon: '🤝' },
{ name: 'blog', label: 'وبلاگ', icon: '📝' },
{ name: 'contact-requests', label: 'درخواست‌ها', icon: '📩' },
{ name: 'media', label: 'رسانه', icon: '🖼️' },
]
@@ -80,6 +81,7 @@ const titles: Record<string, string> = {
clients: 'مشتریان',
blog: 'وبلاگ',
'blog-edit': 'ویرایش مطلب',
'contact-requests': 'درخواست‌های تماس',
media: 'کتابخانه رسانه',
}
+5
View File
@@ -65,6 +65,11 @@ const router = createRouter({
name: 'media',
component: () => import('@/views/MediaView.vue'),
},
{
path: 'contact-requests',
name: 'contact-requests',
component: () => import('@/views/ContactRequestsView.vue'),
},
],
},
],
+118
View File
@@ -40,4 +40,122 @@
.page-title {
@apply text-2xl font-bold text-slate-900;
}
.editor-btn {
@apply inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm text-slate-600 transition hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-40;
}
.editor-btn.is-active {
@apply bg-brand/10 text-brand-700;
}
.editor-divider {
@apply mx-1 hidden h-6 w-px bg-slate-200 sm:inline-block;
}
.blog-editor-content .ProseMirror {
@apply min-h-[420px] px-5 py-4 text-base leading-8 text-slate-700 outline-none;
}
.blog-editor-content .ProseMirror p.is-editor-empty:first-child::before {
@apply pointer-events-none float-right h-0 text-slate-400;
content: attr(data-placeholder);
}
.blog-editor-prose h2 {
@apply mb-3 mt-8 text-2xl font-bold text-slate-900;
}
.blog-editor-prose h3 {
@apply mb-2 mt-6 text-xl font-bold text-slate-900;
}
.blog-editor-prose h4 {
@apply mb-2 mt-4 text-lg font-semibold text-slate-900;
}
.blog-editor-prose p {
@apply my-3;
}
.blog-editor-prose ul {
@apply my-4 list-disc space-y-2 pr-6 marker:text-brand;
}
.blog-editor-prose ol {
@apply my-4 list-decimal space-y-2 pr-6 marker:text-brand;
}
.blog-editor-prose blockquote {
@apply my-6 border-r-4 border-brand/40 bg-slate-50 px-4 py-3 italic text-slate-600;
}
.blog-editor-prose a {
@apply text-brand underline decoration-brand/30 underline-offset-2;
}
.blog-editor-prose hr {
@apply my-8 border-slate-200;
}
.blog-editor-prose img,
.blog-editor-image {
@apply my-6 w-full rounded-xl border border-slate-200 object-cover;
}
.blog-editor-prose figure {
@apply my-6 overflow-hidden rounded-xl border border-slate-200 bg-slate-50;
}
.blog-editor-prose figcaption {
@apply px-4 py-2 text-center text-sm text-slate-500;
}
.blog-image-node {
@apply my-6;
}
.blog-image-node__frame {
@apply relative overflow-hidden rounded-xl border-2 border-transparent transition;
}
.blog-image-node.is-selected .blog-image-node__frame {
@apply border-brand/50 shadow-md shadow-brand/10;
}
.blog-image-node__toolbar {
@apply absolute left-3 top-3 flex flex-wrap gap-1;
}
.blog-image-node__btn {
@apply rounded-md bg-white/95 px-2.5 py-1 text-xs font-medium text-slate-700 shadow-sm transition hover:bg-white;
}
.blog-image-node__btn--danger {
@apply text-red-600 hover:bg-red-50;
}
.blog-image-node__caption {
@apply mt-2 text-center text-sm text-slate-500;
}
.blog-image-node__panel {
@apply mt-3 space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3;
}
.blog-image-node__label {
@apply block text-xs font-medium text-slate-600;
}
.blog-image-node__input {
@apply w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand/20;
}
.blog-image-node__actions {
@apply flex flex-wrap gap-2 pt-1;
}
.blog-image-node__error {
@apply text-xs text-red-600;
}
}
+116
View File
@@ -0,0 +1,116 @@
type LegacyBlock =
| { type: 'h2'; text: string }
| { type: 'h3'; text: string }
| { type: 'h4'; text: string }
| { type: 'p'; text: string }
| { type: 'ul'; items: string[] }
| { type: 'img'; src: string; alt: string }
const imagePattern = /^!\[(.*?)\]\((.*?)\)$/
const headingPattern = /^(#{2,4})\s+(.*)$/
function escapeHtml(text: string) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function parseLegacyContent(content: string): LegacyBlock[] {
const blocks: LegacyBlock[] = []
const lines = content.replace(/\r\n/g, '\n').split('\n')
let paragraph: string[] = []
let listItems: string[] = []
const flushParagraph = () => {
const text = paragraph.join('\n').trim()
if (text) blocks.push({ type: 'p', text })
paragraph = []
}
const flushList = () => {
if (listItems.length) blocks.push({ type: 'ul', items: [...listItems] })
listItems = []
}
for (const rawLine of lines) {
const trimmed = rawLine.trimEnd().trim()
if (!trimmed) {
flushParagraph()
flushList()
continue
}
const imageMatch = trimmed.match(imagePattern)
if (imageMatch) {
flushParagraph()
flushList()
blocks.push({
type: 'img',
alt: imageMatch[1] || '',
src: imageMatch[2] || '',
})
continue
}
const headingMatch = trimmed.match(headingPattern)
if (headingMatch) {
flushParagraph()
flushList()
const level = headingMatch[1].length
const text = headingMatch[2].trim()
if (level === 2) blocks.push({ type: 'h2', text })
else if (level === 3) blocks.push({ type: 'h3', text })
else blocks.push({ type: 'h4', text })
continue
}
if (trimmed.startsWith('* ')) {
flushParagraph()
listItems.push(trimmed.slice(2).trim())
continue
}
flushList()
paragraph.push(trimmed)
}
flushParagraph()
flushList()
return blocks
}
function legacyBlocksToHtml(blocks: LegacyBlock[]) {
return blocks
.map((block) => {
switch (block.type) {
case 'h2':
return `<h2>${escapeHtml(block.text)}</h2>`
case 'h3':
return `<h3>${escapeHtml(block.text)}</h3>`
case 'h4':
return `<h4>${escapeHtml(block.text)}</h4>`
case 'p':
return `<p>${escapeHtml(block.text).replace(/\n/g, '<br>')}</p>`
case 'ul':
return `<ul>${block.items.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`
case 'img':
return `<figure><img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt)}" />${block.alt ? `<figcaption>${escapeHtml(block.alt)}</figcaption>` : ''}</figure>`
default:
return ''
}
})
.join('')
}
export function isHtmlContent(content: string) {
const trimmed = content.trim()
return /^</.test(trimmed) || /<(?:p|h[1-6]|ul|ol|figure|img|blockquote)\b/i.test(trimmed)
}
export function normalizeEditorContent(content: string) {
if (!content?.trim()) return ''
if (isHtmlContent(content)) return content
return legacyBlocksToHtml(parseLegacyContent(content))
}
+79 -41
View File
@@ -1,8 +1,16 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">{{ isNew ? 'مطلب جدید' : 'ویرایش مطلب' }}</h2>
<RouterLink :to="{ name: 'blog' }" class="btn-secondary">بازگشت</RouterLink>
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="page-title">{{ isNew ? 'مطلب جدید' : 'ویرایش مطلب' }}</h2>
<p class="mt-1 text-sm text-slate-500">ویرایشگر بلوکی — شبیه المنتور</p>
</div>
<div class="flex flex-wrap gap-2">
<RouterLink :to="{ name: 'blog' }" class="btn-secondary">بازگشت</RouterLink>
<button type="button" class="btn-primary" :disabled="saving" @click="save">
{{ saving ? 'در حال ذخیره...' : 'ذخیره مطلب' }}
</button>
</div>
</div>
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
@@ -10,55 +18,84 @@
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<form v-else class="space-y-6" @submit.prevent="save">
<section class="card space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label class="label">عنوان</label>
<input v-model="form.title" class="input" required @input="maybeSlug" />
<form v-else class="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_320px]" @submit.prevent="save">
<div class="space-y-6">
<section class="card space-y-4">
<div>
<label class="label">عنوان مقاله</label>
<input
v-model="form.title"
class="input text-lg font-semibold"
required
placeholder="عنوان جذاب برای مقاله..."
@input="maybeSlug"
/>
</div>
<div>
<label class="label">اسلاگ</label>
<input v-model="form.slug" class="input" dir="ltr" required />
<label class="label">خلاصه (نمایش در لیست وبلاگ)</label>
<textarea
v-model="form.excerpt"
class="input min-h-[90px]"
rows="3"
placeholder="۲ تا ۳ جمله خلاصه مقاله..."
/>
</div>
<div class="flex items-center gap-2 pt-6">
</section>
<section>
<label class="label mb-2">محتوای مقاله</label>
<BlogEditor v-model="form.content" />
</section>
</div>
<aside class="space-y-4 xl:sticky xl:top-6">
<section class="card space-y-4">
<h3 class="font-semibold text-slate-800">انتشار</h3>
<div>
<label class="label">اسلاگ (URL)</label>
<input
v-model="form.slug"
class="input"
dir="ltr"
required
@input="slugTouched = true"
/>
</div>
<label class="flex items-center gap-2">
<input
id="published"
v-model="form.published"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-brand focus:ring-brand"
/>
<label for="published" class="text-sm text-slate-700">منتشر شده</label>
</div>
<div class="sm:col-span-2">
<label class="label">خلاصه</label>
<textarea v-model="form.excerpt" class="input min-h-[80px]" rows="3" />
</div>
<div class="sm:col-span-2">
<label class="label">محتوا</label>
<textarea v-model="form.content" class="input min-h-[200px] font-mono text-sm" rows="12" />
</div>
<div class="sm:col-span-2">
<ImageField v-model="form.coverUrl" label="تصویر کاور مطلب" />
</div>
</div>
</section>
<span class="text-sm text-slate-700">منتشر شده</span>
</label>
<button type="submit" class="btn-primary w-full" :disabled="saving">
{{ saving ? 'در حال ذخیره...' : 'ذخیره مطلب' }}
</button>
</section>
<section class="card space-y-4">
<h3 class="font-semibold text-slate-800">سئو</h3>
<div>
<label class="label">عنوان سئو (seoTitle)</label>
<input v-model="form.seoTitle" class="input" />
</div>
<div>
<label class="label">توضیحات سئو (seoDescription)</label>
<textarea v-model="form.seoDescription" class="input min-h-[80px]" rows="3" />
</div>
</section>
<section class="card">
<ImageField v-model="form.coverUrl" label="تصویر کاور" />
</section>
<button type="submit" class="btn-primary" :disabled="saving">
{{ saving ? 'در حال ذخیره...' : 'ذخیره' }}
</button>
<section class="card space-y-4">
<h3 class="font-semibold text-slate-800">سئو</h3>
<div>
<label class="label">عنوان سئو</label>
<input v-model="form.seoTitle" class="input" placeholder="در صورت خالی بودن، عنوان مقاله استفاده می‌شود" />
</div>
<div>
<label class="label">توضیحات سئو</label>
<textarea
v-model="form.seoDescription"
class="input min-h-[90px]"
rows="3"
placeholder="توضیح کوتاه برای موتورهای جستجو..."
/>
</div>
</section>
</aside>
</form>
</div>
</template>
@@ -67,6 +104,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type BlogPost } from '@/api/client'
import BlogEditor from '@/components/BlogEditor.vue'
import ImageField from '@/components/ImageField.vue'
const route = useRoute()
+163
View File
@@ -0,0 +1,163 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">درخواست‌های تماس</h2>
<button type="button" class="btn-secondary" @click="load">بروزرسانی</button>
</div>
<p v-if="error" class="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">{{ error }}</p>
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<div v-else-if="requests.length === 0" class="card text-center text-slate-500">
هنوز درخواستی ثبت نشده است.
</div>
<div v-else class="space-y-4">
<div
v-for="item in requests"
:key="item.id"
class="card"
:class="item.status === 'new' ? 'border-r-4 border-brand' : ''"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold text-slate-900">{{ item.name }}</h3>
<span
class="rounded-full px-2 py-0.5 text-xs"
:class="statusClass(item.status)"
>
{{ statusLabel(item.status) }}
</span>
</div>
<p class="mt-1 text-sm text-slate-500">{{ formatDate(item.createdAt) }}</p>
</div>
<div class="flex flex-wrap gap-2">
<button
v-if="item.status === 'new'"
type="button"
class="btn-secondary text-sm"
@click="setStatus(item, 'read')"
>
علامت‌گذاری خوانده‌شده
</button>
<button
v-if="item.status !== 'archived'"
type="button"
class="btn-secondary text-sm"
@click="setStatus(item, 'archived')"
>
بایگانی
</button>
<button
type="button"
class="text-sm text-red-500 hover:underline"
@click="remove(item.id)"
>
حذف
</button>
</div>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div>
<p class="text-xs text-slate-500">ایمیل</p>
<a :href="`mailto:${item.email}`" class="text-sm text-brand hover:underline" dir="ltr">
{{ item.email }}
</a>
</div>
<div v-if="item.phone">
<p class="text-xs text-slate-500">تلفن</p>
<a :href="`tel:${item.phone}`" class="text-sm text-slate-800" dir="ltr">
{{ item.phone }}
</a>
</div>
<div>
<p class="text-xs text-slate-500">نوع پروژه</p>
<p class="text-sm text-slate-800">{{ categoryLabel(item.category) }}</p>
</div>
</div>
<div class="mt-4 rounded-lg bg-slate-50 p-4">
<p class="text-xs text-slate-500">توضیحات پروژه</p>
<p class="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-slate-700">
{{ item.message }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type ContactRequest } from '@/api/client'
const loading = ref(true)
const error = ref('')
const requests = ref<ContactRequest[]>([])
const categories = ref<Record<string, string>>({})
function formatDate(value: string) {
return new Date(value).toLocaleString('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
})
}
function statusLabel(status: ContactRequest['status']) {
if (status === 'new') return 'جدید'
if (status === 'read') return 'خوانده‌شده'
return 'بایگانی'
}
function statusClass(status: ContactRequest['status']) {
if (status === 'new') return 'bg-brand/10 text-brand-700'
if (status === 'read') return 'bg-blue-100 text-blue-700'
return 'bg-slate-100 text-slate-600'
}
function categoryLabel(slug: string) {
return categories.value[slug] || slug
}
async function load() {
loading.value = true
error.value = ''
try {
const [items, cats] = await Promise.all([
api.getContactRequests(),
api.getPortfolioCategories(),
])
requests.value = items
categories.value = Object.fromEntries(cats.map((c) => [c.slug, c.label]))
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
}
async function setStatus(item: ContactRequest, status: ContactRequest['status']) {
try {
const updated = await api.updateContactRequest(item.id, { status })
item.status = updated.status
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بروزرسانی'
}
}
async function remove(id: string) {
if (!confirm('آیا از حذف این درخواست مطمئن هستید؟')) return
try {
await api.deleteContactRequest(id)
requests.value = requests.value.filter((r) => r.id !== id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در حذف'
}
}
onMounted(load)
</script>
+7 -2
View File
@@ -4,7 +4,7 @@
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div v-for="stat in stats" :key="stat.label" class="card">
<p class="text-3xl font-bold text-brand">{{ stat.value }}</p>
<p class="mt-1 text-sm text-slate-600">{{ stat.label }}</p>
@@ -18,6 +18,7 @@
<RouterLink :to="{ name: 'site' }" class="btn-secondary">تنظیمات سایت</RouterLink>
<RouterLink :to="{ name: 'portfolio-edit' }" class="btn-secondary">نمونه‌کار جدید</RouterLink>
<RouterLink :to="{ name: 'blog-edit' }" class="btn-secondary">مطلب جدید</RouterLink>
<RouterLink :to="{ name: 'contact-requests' }" class="btn-secondary">درخواست‌های تماس</RouterLink>
<RouterLink :to="{ name: 'media' }" class="btn-secondary">آپلود رسانه</RouterLink>
</div>
</div>
@@ -43,24 +44,28 @@ const stats = ref([
{ value: '—', label: 'خدمات' },
{ value: '—', label: 'مشتری' },
{ value: '—', label: 'مطلب وبلاگ' },
{ value: '—', label: 'درخواست جدید' },
])
onMounted(async () => {
try {
const [site, projects, services, clients, posts] = await Promise.all([
const [site, projects, services, clients, posts, contactRequests] = await Promise.all([
api.getSite(),
api.getProjects(),
api.getServices(),
api.getClients(),
api.getBlogPosts(),
api.getContactRequests(),
])
siteName.value = site.name || 'استودیو نورو'
siteTagline.value = site.tagline || ''
const newRequests = contactRequests.filter((r) => r.status === 'new').length
stats.value = [
{ value: String(projects.length), label: 'نمونه‌کار' },
{ value: String(services.length), label: 'خدمات' },
{ value: String(clients.length), label: 'مشتری' },
{ value: String(posts.length), label: 'مطلب وبلاگ' },
{ value: String(newRequests), label: 'درخواست جدید' },
]
} catch {
// stats stay as dashes