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();
+};
+---
+
+
@@ -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}
>