diff --git a/backend/pb_migrations/1776514159_created_comments.js b/backend/pb_migrations/1776514159_created_comments.js new file mode 100644 index 0000000..00f2597 --- /dev/null +++ b/backend/pb_migrations/1776514159_created_comments.js @@ -0,0 +1,126 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4274335913", + "max": 0, + "min": 0, + "name": "content", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1372126313", + "max": 0, + "min": 0, + "name": "post_slug", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation2375276105", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1032740943", + "max": 0, + "min": 0, + "name": "parent", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_533777971", + "indexes": [], + "listRule": null, + "name": "comments", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_533777971"); + + return app.delete(collection); +}) diff --git a/backend/pb_migrations/1776514585_updated_comments.js b/backend/pb_migrations/1776514585_updated_comments.js new file mode 100644 index 0000000..d7e21f7 --- /dev/null +++ b/backend/pb_migrations/1776514585_updated_comments.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_533777971") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "user.id = @request.auth.id ", + "listRule": " status = \"published\" || @request.auth.id != \"\" ", + "updateRule": " user.id = @request.auth.id ", + "viewRule": " status = \"published\" || @request.auth.id != \"\" " + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_533777971") + + // update collection data + unmarshal({ + "createRule": null, + "deleteRule": null, + "listRule": null, + "updateRule": null, + "viewRule": null + }, collection) + + return app.save(collection) +}) diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000..7a2b718 Binary files /dev/null and b/bun.lockb differ diff --git a/frontend/astro.config.mjs b/frontend/astro.config.mjs index f25366d..32b6d47 100644 --- a/frontend/astro.config.mjs +++ b/frontend/astro.config.mjs @@ -5,6 +5,7 @@ import node from '@astrojs/node'; import mdx from '@astrojs/mdx'; import icon from "astro-icon"; import sitemap from '@astrojs/sitemap'; +import solidJs from '@astrojs/solid-js'; // https://astro.build/config export default defineConfig({ @@ -14,7 +15,7 @@ export default defineConfig({ const blockedPaths = ['/auth/', '/blog/search', '/404']; return !blockedPaths.some(path => page.includes(path)); }, - })], + }), solidJs()], vite: { plugins: [tailwindcss()], build: { diff --git a/frontend/package.json b/frontend/package.json index 9fa0dc3..1300204 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,14 +13,16 @@ }, "dependencies": { "@astrojs/mdx": "^5.0.3", - "nodemailer": "^6.9.14", "@astrojs/node": "^10.0.4", "@astrojs/sitemap": "^3.7.2", + "@astrojs/solid-js": "^6.0.1", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", "astro-icon": "^1.1.5", "marked": "^18.0.0", + "nodemailer": "^6.9.14", "pocketbase": "^0.21.0", + "solid-js": "^1.9.12", "tailwindcss": "^4.2.2" }, "devDependencies": { diff --git a/frontend/src/components/blog/BlogCard.astro b/frontend/src/components/blog/BlogCard.astro index 8f050d7..0509663 100644 --- a/frontend/src/components/blog/BlogCard.astro +++ b/frontend/src/components/blog/BlogCard.astro @@ -6,6 +6,7 @@ export interface Props { categoryColor?: string; date: string; readTime: string; + readmeTime?: string; image?: string; slug?: string; } @@ -17,6 +18,7 @@ const { categoryColor = 'bg-gold', date, readTime, + readmeTime, image, slug = '#' } = Astro.props; @@ -52,7 +54,7 @@ const imageUrl = image || '/images/blog/default.avif'; - {readTime} + {readmeTime || readTime} diff --git a/frontend/src/components/blog/PostReactionButtons.astro b/frontend/src/components/blog/PostReactionButtons.astro index 9446a03..8180083 100644 --- a/frontend/src/components/blog/PostReactionButtons.astro +++ b/frontend/src/components/blog/PostReactionButtons.astro @@ -224,18 +224,25 @@ const { initialLikes = 0, initialDislikes = 0, postId } = Astro.props; const dislikeBtn = container.querySelector('.dislike-btn'); async function loadUserVote() { - if (!pb.authStore.isValid) return; - try { + console.log('[Vote] Загрузка голосов для post:', postId); const response = await fetch(`/api/votes?post_id=${postId}`, { credentials: 'include', }); + console.log('[Vote] Ответ:', response.status); if (response.ok) { const result = await response.json(); - if (result.userVote === 'like') { - likeBtn?.classList.add('active'); - } else if (result.userVote === 'dislike') { - dislikeBtn?.classList.add('active'); + console.log('[Vote] Данные:', result); + // Обновляем счетчики + if (likesCount) likesCount.textContent = result.likes.toString(); + if (dislikesCount) dislikesCount.textContent = result.dislikes.toString(); + // Показываем голос пользователя + if (pb.authStore.isValid) { + if (result.userVote === 'like') { + likeBtn?.classList.add('active'); + } else if (result.userVote === 'dislike') { + dislikeBtn?.classList.add('active'); + } } } } catch (e) { @@ -243,7 +250,15 @@ const { initialLikes = 0, initialDislikes = 0, postId } = Astro.props; } } + // Вызываем при загрузке и при изменении авторизации loadUserVote(); + + // Также при изменении авторизации + window.addEventListener('storage', (e) => { + if (e.key === 'pb_auth') { + loadUserVote(); + } + }); likeBtn?.addEventListener('click', () => { if (postId) handleVote(postId, 'like'); diff --git a/frontend/src/components/blog/RelatedPosts.astro b/frontend/src/components/blog/RelatedPosts.astro index 93b30f6..5e8da5b 100644 --- a/frontend/src/components/blog/RelatedPosts.astro +++ b/frontend/src/components/blog/RelatedPosts.astro @@ -11,6 +11,7 @@ interface Post { categoryColor: string; date: string; readTime: string; + readmeTime?: string; image: string; } @@ -48,6 +49,7 @@ const filteredPosts = currentSlug categoryColor={post.categoryColor} date={formatDate(post.date)} readTime={post.readTime} + readmeTime={post.readmeTime} image={getPostImageUrl(post)} slug={`/blog/${post.slug}`} /> diff --git a/frontend/src/components/blog/comments/CommentForm.tsx b/frontend/src/components/blog/comments/CommentForm.tsx new file mode 100644 index 0000000..ad092d6 --- /dev/null +++ b/frontend/src/components/blog/comments/CommentForm.tsx @@ -0,0 +1,197 @@ +import { createSignal, Show } from "solid-js"; + +interface CommentFormData { + content: string; +} + +interface ValidationErrors { + content?: string; +} + +interface CommentFormProps { + onSubmit: (data: CommentFormData) => void; + isReply?: boolean; + onCancel?: () => void; + user?: { + name: string; + email: string; + avatar?: string; + }; +} + +const MAX_MESSAGE_LENGTH = 2000; +const MIN_MESSAGE_LENGTH = 10; + +const DANGEROUS_PATTERNS = [ + /)<[^<]*)*<\/script>/gi, + /javascript:/gi, + /on\w+\s*=/gi, + /)<[^<]*)*<\/iframe>/gi, + /)<[^<]*)*<\/object>/gi, + //gi, + /data:text\/html/gi, + /expression\s*\(/gi, + /url\s*\(\s*['"]*\s*javascript:/gi, +]; + +export default function CommentForm(props: CommentFormProps) { + const [content, setContent] = createSignal(""); + const [errors, setErrors] = createSignal({}); + const [touched, setTouched] = createSignal<{ [key: string]: boolean }>({}); + + const sanitizeInput = (input: string): string => { + return input + .replace(/[<>]/g, "") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/&/g, "&"); + }; + + const containsDangerousContent = (input: string): boolean => { + return DANGEROUS_PATTERNS.some((pattern) => pattern.test(input)); + }; + + const validateContent = (value: string): string | undefined => { + const trimmed = value.trim(); + if (!trimmed) return "Комментарий обязателен"; + if (trimmed.length < MIN_MESSAGE_LENGTH) + return `Минимум ${MIN_MESSAGE_LENGTH} символов`; + if (trimmed.length > MAX_MESSAGE_LENGTH) + return `Максимум ${MAX_MESSAGE_LENGTH} символов`; + if (containsDangerousContent(trimmed)) return "Обнаружен опасный контент"; + return undefined; + }; + + const handleContentChange = (e: Event) => { + const target = e.target as HTMLTextAreaElement; + let value = target.value; + + if (containsDangerousContent(value)) { + DANGEROUS_PATTERNS.forEach((pattern) => { + value = value.replace(pattern, ""); + }); + } + + if (value.length > MAX_MESSAGE_LENGTH) { + value = value.slice(0, MAX_MESSAGE_LENGTH); + } + + setContent(value); + if (touched().content) { + setErrors((prev) => ({ ...prev, content: validateContent(value) })); + } + }; + + const validateForm = (): boolean => { + const contentError = validateContent(content()); + setErrors({ content: contentError }); + setTouched({ content: true }); + return !contentError; + }; + + const handleSubmit = (e: Event) => { + e.preventDefault(); + if (!validateForm()) return; + + props.onSubmit({ + content: sanitizeInput(content().trim()), + }); + + setContent(""); + setErrors({}); + setTouched({}); + }; + + const handleBlur = () => { + setTouched((prev) => ({ ...prev, content: true })); + setErrors((prev) => ({ ...prev, content: validateContent(content()) })); + }; + + const isValid = () => { + return !errors().content && content().trim(); + }; + + return ( +
+

+ + + + {props.isReply ? "Написать ответ" : "Оставить комментарий"} +

+ +
+
+