From 735289481c9cc2c4bc1bf52edb395172fd12d56f Mon Sep 17 00:00:00 2001 From: Web-serfer Date: Sun, 26 Apr 2026 23:47:23 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/astro.config.mjs | 2 +- .../src/components/blog/PostViewCounter.astro | 42 +++++++++++++++ frontend/src/icons/eye.svg | 1 + frontend/src/layouts/ArticleLayout.astro | 36 ++++++++++++- frontend/src/lib/pb.ts | 16 ++++++ frontend/src/pages/api/increment-views.ts | 53 +++++++++++++++++++ frontend/src/pages/blog/[slug].astro | 4 +- 7 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/blog/PostViewCounter.astro create mode 100644 frontend/src/icons/eye.svg create mode 100644 frontend/src/pages/api/increment-views.ts diff --git a/frontend/astro.config.mjs b/frontend/astro.config.mjs index 32b6d47..f0471b3 100644 --- a/frontend/astro.config.mjs +++ b/frontend/astro.config.mjs @@ -10,7 +10,7 @@ import solidJs from '@astrojs/solid-js'; // https://astro.build/config export default defineConfig({ site: 'https://avtourist-surgut.ru', - integrations: [mdx(), icon(), sitemap({ + integrations: [mdx(), icon({ iconDir: 'src/icons' }), sitemap({ filter: (page) => { const blockedPaths = ['/auth/', '/blog/search', '/404']; return !blockedPaths.some(path => page.includes(path)); diff --git a/frontend/src/components/blog/PostViewCounter.astro b/frontend/src/components/blog/PostViewCounter.astro new file mode 100644 index 0000000..02cb5ac --- /dev/null +++ b/frontend/src/components/blog/PostViewCounter.astro @@ -0,0 +1,42 @@ +--- +import { Icon } from 'astro-icon/components'; + +interface Props { + views: number; + className?: string; +} + +const { views, className = '' } = Astro.props; + +const formatViews = (n: number) => { + if (n >= 1000) { + return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; + } + return n.toString(); +}; +--- + +
+ + {formatViews(views)} +
+ + \ No newline at end of file diff --git a/frontend/src/icons/eye.svg b/frontend/src/icons/eye.svg new file mode 100644 index 0000000..5b10714 --- /dev/null +++ b/frontend/src/icons/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/layouts/ArticleLayout.astro b/frontend/src/layouts/ArticleLayout.astro index 09131e1..f906260 100644 --- a/frontend/src/layouts/ArticleLayout.astro +++ b/frontend/src/layouts/ArticleLayout.astro @@ -9,6 +9,7 @@ import ConsultationModal from "@components/base/ConsultationModal.astro"; import Toast from "@components/base/Toast.astro"; import PostSocialShare from "@components/blog/PostSocialShare.astro"; import PostReactionButtons from "@components/blog/PostReactionButtons.astro"; +import PostViewCounter from "@components/blog/PostViewCounter.astro"; export interface Props { title: string; @@ -27,6 +28,7 @@ export interface Props { postUrl: string; initialLikes?: number; initialDislikes?: number; + initialViews?: number; } const { @@ -45,7 +47,8 @@ const { postId, postUrl, initialLikes = 0, - initialDislikes = 0 + initialDislikes = 0, + initialViews = 0 } = Astro.props; --- @@ -104,6 +107,10 @@ const { {readmeTime} мин + + + {initialViews} +
@@ -302,6 +309,33 @@ const { consultationBtn?.addEventListener("click", () => { window.dispatchEvent(new CustomEvent("open-modal", { detail: "consultation-modal" })); }); + + // 5. СЧЁТЧИК ПРОСМОТРОВ + const viewsEl = document.querySelector('.meta-views') as HTMLElement & { dataset: { postId: string } }; + if (viewsEl?.dataset?.postId) { + const postId = viewsEl.dataset.postId; + const hasViewed = sessionStorage.getItem(`viewed_${postId}`); + + if (!hasViewed) { + fetch(`/api/increment-views?postId=${postId}`, { method: 'POST' }) + .then(res => res.json()) + .then(data => { + if (data.views !== undefined) { + viewsEl.textContent = formatViews(data.views); + } + }) + .catch(() => {}); + + sessionStorage.setItem(`viewed_${postId}`, 'true'); + } + } + + function formatViews(n: number): string { + if (n >= 1000) { + return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; + } + return n.toString(); + } } document.addEventListener("DOMContentLoaded", setupArticleLogic); diff --git a/frontend/src/lib/pb.ts b/frontend/src/lib/pb.ts index 22921da..e868bc1 100644 --- a/frontend/src/lib/pb.ts +++ b/frontend/src/lib/pb.ts @@ -169,4 +169,20 @@ export function getPostImageUrl(post: { image?: string }): string { return fileUrl; } return '/images/blog/default.avif'; +} + +export async function getPostViews(postId: string): Promise { + try { + const post = await pb.collection('posts').getOne(postId); + return post.views || 0; + } catch { + return 0; + } +} + +export async function incrementPostViews(postId: string): Promise { + const post = await pb.collection('posts').getOne(postId); + const newViews = (post.views || 0) + 1; + await pb.collection('posts').update(postId, { views: newViews }); + return newViews; } \ No newline at end of file diff --git a/frontend/src/pages/api/increment-views.ts b/frontend/src/pages/api/increment-views.ts new file mode 100644 index 0000000..83f5da4 --- /dev/null +++ b/frontend/src/pages/api/increment-views.ts @@ -0,0 +1,53 @@ +import type { APIRoute } from 'astro'; + +const POCKETBASE_URL = import.meta.env.POCKETBASE_URL || 'http://127.0.0.1:8090'; + +const POCKETBASE_ID_REGEX = /^[a-z0-9]{15}$/; + +export const POST: APIRoute = async ({ url }) => { + try { + const postId = url.searchParams.get('postId'); + + if (!postId || !POCKETBASE_ID_REGEX.test(postId)) { + return new Response( + JSON.stringify({ error: 'Некорректный postId' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const postRes = await fetch( + `${POCKETBASE_URL}/api/collections/posts/records/${postId}`, + ); + + if (!postRes.ok) { + return new Response( + JSON.stringify({ error: 'Пост не найден' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + const post = await postRes.json(); + const newViews = (post.views || 0) + 1; + + await fetch( + `${POCKETBASE_URL}/api/collections/posts/records/${postId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ views: newViews }), + } + ); + + return new Response( + JSON.stringify({ views: newViews }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + + } catch (error) { + console.error('[Increment Views API] Error:', error); + return new Response( + JSON.stringify({ error: 'Внутренняя ошибка сервера' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +}; \ No newline at end of file diff --git a/frontend/src/pages/blog/[slug].astro b/frontend/src/pages/blog/[slug].astro index 6453e03..22a0e05 100644 --- a/frontend/src/pages/blog/[slug].astro +++ b/frontend/src/pages/blog/[slug].astro @@ -4,7 +4,7 @@ import { SITE_URL } from '@constants'; import Comments from '@components/blog/comments/Comments.tsx'; import RelatedPosts from '@components/blog/RelatedPosts.astro'; import ArticleTableOfContents from '@components/blog/ArticleTableOfContents.astro'; -import { getPostBySlug, getPosts, getPostImageUrl, getPostVotesStats } from '@lib/pb'; +import { getPostBySlug, getPosts, getPostImageUrl, getPostVotesStats, getPostViews } from '@lib/pb'; import { marked } from 'marked'; export const prerender = false; @@ -22,6 +22,7 @@ if (!post) { } const { likes = 0, dislikes = 0 } = await getPostVotesStats(post.id).catch(() => ({ likes: 0, dislikes: 0 })); +const views = await getPostViews(post.id).catch(() => 0); // Конвертируем markdown в HTML const contentHtml = marked(post.content || ''); @@ -74,6 +75,7 @@ const heroImage = getPostImageUrl(post); postUrl={currentUrl} initialLikes={likes} initialDislikes={dislikes} + initialViews={views} >