Новые изменения в компоненты
This commit is contained in:
parent
faf02848ed
commit
735289481c
7 changed files with 151 additions and 3 deletions
|
|
@ -10,7 +10,7 @@ import solidJs from '@astrojs/solid-js';
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://avtourist-surgut.ru',
|
site: 'https://avtourist-surgut.ru',
|
||||||
integrations: [mdx(), icon(), sitemap({
|
integrations: [mdx(), icon({ iconDir: 'src/icons' }), sitemap({
|
||||||
filter: (page) => {
|
filter: (page) => {
|
||||||
const blockedPaths = ['/auth/', '/blog/search', '/404'];
|
const blockedPaths = ['/auth/', '/blog/search', '/404'];
|
||||||
return !blockedPaths.some(path => page.includes(path));
|
return !blockedPaths.some(path => page.includes(path));
|
||||||
|
|
|
||||||
42
frontend/src/components/blog/PostViewCounter.astro
Normal file
42
frontend/src/components/blog/PostViewCounter.astro
Normal file
|
|
@ -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();
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class={`post-view-counter ${className}`}>
|
||||||
|
<Icon name="eye" class="view-icon" />
|
||||||
|
<span class="view-count">{formatViews(views)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.post-view-counter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-icon {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
color: #eac26e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-count {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
frontend/src/icons/eye.svg
Normal file
1
frontend/src/icons/eye.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Google Material Icons by Material Design Authors - https://github.com/material-icons/material-icons/blob/master/LICENSE --><path fill="currentColor" d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5M12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5s5 2.24 5 5s-2.24 5-5 5m0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3s3-1.34 3-3s-1.34-3-3-3"/></svg>
|
||||||
|
After Width: | Height: | Size: 467 B |
|
|
@ -9,6 +9,7 @@ import ConsultationModal from "@components/base/ConsultationModal.astro";
|
||||||
import Toast from "@components/base/Toast.astro";
|
import Toast from "@components/base/Toast.astro";
|
||||||
import PostSocialShare from "@components/blog/PostSocialShare.astro";
|
import PostSocialShare from "@components/blog/PostSocialShare.astro";
|
||||||
import PostReactionButtons from "@components/blog/PostReactionButtons.astro";
|
import PostReactionButtons from "@components/blog/PostReactionButtons.astro";
|
||||||
|
import PostViewCounter from "@components/blog/PostViewCounter.astro";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -27,6 +28,7 @@ export interface Props {
|
||||||
postUrl: string;
|
postUrl: string;
|
||||||
initialLikes?: number;
|
initialLikes?: number;
|
||||||
initialDislikes?: number;
|
initialDislikes?: number;
|
||||||
|
initialViews?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -45,7 +47,8 @@ const {
|
||||||
postId,
|
postId,
|
||||||
postUrl,
|
postUrl,
|
||||||
initialLikes = 0,
|
initialLikes = 0,
|
||||||
initialDislikes = 0
|
initialDislikes = 0,
|
||||||
|
initialViews = 0
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -104,6 +107,10 @@ const {
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="meta-icon"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="meta-icon"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||||
{readmeTime} мин
|
{readmeTime} мин
|
||||||
</span>
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="meta-icon"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg>
|
||||||
|
<span class="meta-views" data-post-id={postId}>{initialViews}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="article-actions">
|
<div class="article-actions">
|
||||||
|
|
@ -302,6 +309,33 @@ const {
|
||||||
consultationBtn?.addEventListener("click", () => {
|
consultationBtn?.addEventListener("click", () => {
|
||||||
window.dispatchEvent(new CustomEvent("open-modal", { detail: "consultation-modal" }));
|
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);
|
document.addEventListener("DOMContentLoaded", setupArticleLogic);
|
||||||
|
|
|
||||||
|
|
@ -169,4 +169,20 @@ export function getPostImageUrl(post: { image?: string }): string {
|
||||||
return fileUrl;
|
return fileUrl;
|
||||||
}
|
}
|
||||||
return '/images/blog/default.avif';
|
return '/images/blog/default.avif';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPostViews(postId: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const post = await pb.collection('posts').getOne(postId);
|
||||||
|
return post.views || 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementPostViews(postId: string): Promise<number> {
|
||||||
|
const post = await pb.collection('posts').getOne(postId);
|
||||||
|
const newViews = (post.views || 0) + 1;
|
||||||
|
await pb.collection('posts').update(postId, { views: newViews });
|
||||||
|
return newViews;
|
||||||
}
|
}
|
||||||
53
frontend/src/pages/api/increment-views.ts
Normal file
53
frontend/src/pages/api/increment-views.ts
Normal file
|
|
@ -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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -4,7 +4,7 @@ import { SITE_URL } from '@constants';
|
||||||
import Comments from '@components/blog/comments/Comments.tsx';
|
import Comments from '@components/blog/comments/Comments.tsx';
|
||||||
import RelatedPosts from '@components/blog/RelatedPosts.astro';
|
import RelatedPosts from '@components/blog/RelatedPosts.astro';
|
||||||
import ArticleTableOfContents from '@components/blog/ArticleTableOfContents.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';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
export const prerender = false;
|
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 { likes = 0, dislikes = 0 } = await getPostVotesStats(post.id).catch(() => ({ likes: 0, dislikes: 0 }));
|
||||||
|
const views = await getPostViews(post.id).catch(() => 0);
|
||||||
|
|
||||||
// Конвертируем markdown в HTML
|
// Конвертируем markdown в HTML
|
||||||
const contentHtml = marked(post.content || '');
|
const contentHtml = marked(post.content || '');
|
||||||
|
|
@ -74,6 +75,7 @@ const heroImage = getPostImageUrl(post);
|
||||||
postUrl={currentUrl}
|
postUrl={currentUrl}
|
||||||
initialLikes={likes}
|
initialLikes={likes}
|
||||||
initialDislikes={dislikes}
|
initialDislikes={dislikes}
|
||||||
|
initialViews={views}
|
||||||
>
|
>
|
||||||
<!-- Содержимое статьи в article -->
|
<!-- Содержимое статьи в article -->
|
||||||
<article class="article-content-wrapper" id="post-content">
|
<article class="article-content-wrapper" id="post-content">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue