2025-12-11 01:12:45 +03:00
|
|
|
|
---
|
2026-02-26 02:02:52 +03:00
|
|
|
|
import CategoryBadge from './CategoryBadge.astro';
|
|
|
|
|
|
import Author from '@components/AuthorDisplay.astro';
|
2026-02-02 17:58:36 +03:00
|
|
|
|
|
2025-12-11 01:12:45 +03:00
|
|
|
|
export interface Props {
|
|
|
|
|
|
items: any[];
|
|
|
|
|
|
showCount?: boolean;
|
2026-02-26 02:02:52 +03:00
|
|
|
|
type: 'latest' | 'category' | 'author' | 'tag';
|
|
|
|
|
|
slug?: string;
|
2026-01-28 14:46:51 +03:00
|
|
|
|
pageInfo?: {
|
|
|
|
|
|
hasNextPage: boolean;
|
|
|
|
|
|
endCursor: string | null;
|
|
|
|
|
|
};
|
2026-02-26 02:02:52 +03:00
|
|
|
|
perLoad?: number; // Переименовали first в perLoad
|
2025-12-11 01:12:45 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
|
items = [],
|
2026-01-28 12:02:55 +03:00
|
|
|
|
showCount = false,
|
2026-02-26 02:02:52 +03:00
|
|
|
|
type = 'latest',
|
|
|
|
|
|
slug = '',
|
2026-01-28 14:46:51 +03:00
|
|
|
|
pageInfo = { hasNextPage: false, endCursor: null },
|
2026-02-26 02:02:52 +03:00
|
|
|
|
perLoad = 11 // perLoad на верхнем уровне с дефолтом 11
|
2025-12-11 01:12:45 +03:00
|
|
|
|
} = Astro.props;
|
2026-01-25 21:13:05 +03:00
|
|
|
|
|
2026-02-26 02:02:52 +03:00
|
|
|
|
// Формируем конфиг для sentinel из пропсов верхнего уровня
|
|
|
|
|
|
// Внутри оставляем поле first для совместимости с API и скриптом
|
|
|
|
|
|
const loadMoreConfig = {
|
|
|
|
|
|
type,
|
|
|
|
|
|
slug,
|
|
|
|
|
|
first: perLoad // Маппим perLoad в first для обратной совместимости
|
|
|
|
|
|
};
|
2025-12-11 01:12:45 +03:00
|
|
|
|
|
2026-01-28 12:02:55 +03:00
|
|
|
|
function getCoauthorsNames(coauthors: any[]): string {
|
|
|
|
|
|
if (!coauthors || coauthors.length === 0) return '';
|
|
|
|
|
|
return coauthors
|
|
|
|
|
|
.map((coauthor: any) => coauthor?.node?.name || coauthor?.name)
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join(' ');
|
|
|
|
|
|
}
|
2026-01-28 14:46:51 +03:00
|
|
|
|
|
|
|
|
|
|
function shouldBeLarge(index: number): boolean {
|
|
|
|
|
|
if (index < 8) return false;
|
|
|
|
|
|
return (index - 8) % 11 === 0;
|
|
|
|
|
|
}
|
2026-01-28 12:02:55 +03:00
|
|
|
|
---
|
2026-01-25 21:13:05 +03:00
|
|
|
|
|
2025-12-11 01:12:45 +03:00
|
|
|
|
<section class="posts-section" id="posts-section">
|
|
|
|
|
|
<h2>
|
|
|
|
|
|
{showCount && items.length > 0 && (
|
|
|
|
|
|
<span id="posts-count"> ({items.length})</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="posts-grid" class="posts-grid">
|
|
|
|
|
|
{items.map((item, index) => {
|
|
|
|
|
|
const postUrl = item.uri || `/blog/${item.databaseId}`;
|
|
|
|
|
|
const postDate = new Date(item.date);
|
2026-02-26 02:02:52 +03:00
|
|
|
|
const coauthorsNames = getCoauthorsNames(item.coauthors || []);
|
2026-01-28 14:46:51 +03:00
|
|
|
|
const isLarge = shouldBeLarge(index);
|
2025-12-11 01:12:45 +03:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<article
|
|
|
|
|
|
class={`post-card ${isLarge ? 'post-card-large' : ''}`}
|
2026-02-26 02:02:52 +03:00
|
|
|
|
data-large-position={isLarge ? 'first' : ''}
|
2025-12-11 01:12:45 +03:00
|
|
|
|
data-index={index}
|
|
|
|
|
|
itemscope
|
|
|
|
|
|
itemtype="https://schema.org/BlogPosting"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a href={postUrl} class="post-card-link">
|
|
|
|
|
|
<div class="post-image-container">
|
|
|
|
|
|
{item.featuredImage?.node?.sourceUrl ? (
|
2026-01-28 12:02:55 +03:00
|
|
|
|
<img
|
2025-12-11 01:12:45 +03:00
|
|
|
|
src={item.featuredImage.node.sourceUrl}
|
|
|
|
|
|
alt={item.featuredImage.node.altText || item.title}
|
|
|
|
|
|
width="400"
|
|
|
|
|
|
height="400"
|
|
|
|
|
|
loading="lazy"
|
|
|
|
|
|
class="post-image"
|
|
|
|
|
|
itemprop="image"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div class="post-image-placeholder"></div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-02-18 23:33:35 +03:00
|
|
|
|
<CategoryBadge
|
|
|
|
|
|
name={item.categories?.nodes?.[0]?.name}
|
|
|
|
|
|
color={item.categories?.nodes?.[0]?.color}
|
|
|
|
|
|
/>
|
2025-12-11 01:12:45 +03:00
|
|
|
|
|
|
|
|
|
|
<div class="post-content-overlay">
|
|
|
|
|
|
<div class="post-meta-overlay">
|
|
|
|
|
|
<time
|
|
|
|
|
|
datetime={item.date}
|
|
|
|
|
|
class="post-date-overlay"
|
|
|
|
|
|
itemprop="datePublished"
|
|
|
|
|
|
>
|
|
|
|
|
|
{postDate.toLocaleDateString('ru-RU', {
|
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
|
month: 'short',
|
|
|
|
|
|
year: 'numeric'
|
|
|
|
|
|
}).replace(' г.', '')}
|
|
|
|
|
|
</time>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<h3 class="post-title-overlay" itemprop="headline">
|
|
|
|
|
|
{item.title}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
|
2026-01-28 12:02:55 +03:00
|
|
|
|
{coauthorsNames && (
|
2025-12-11 01:12:45 +03:00
|
|
|
|
<div class="author-name" itemprop="author">
|
2026-01-28 12:02:55 +03:00
|
|
|
|
{coauthorsNames}
|
2025-12-11 01:12:45 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="sr-only">
|
|
|
|
|
|
<h3 itemprop="headline">
|
|
|
|
|
|
<a href={postUrl} itemprop="url">{item.title}</a>
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<time
|
|
|
|
|
|
datetime={item.date}
|
|
|
|
|
|
itemprop="datePublished"
|
|
|
|
|
|
>
|
|
|
|
|
|
{postDate.toLocaleDateString('ru-RU')}
|
|
|
|
|
|
</time>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
2026-01-28 14:46:51 +03:00
|
|
|
|
|
|
|
|
|
|
<!-- Индикатор загрузки -->
|
|
|
|
|
|
<div id="loading-indicator" class="loading-indicator" style="display: none;">
|
|
|
|
|
|
<div class="loading-spinner"></div>
|
|
|
|
|
|
<p>Загрузка...</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Сообщение об окончании -->
|
|
|
|
|
|
<div id="no-more-posts" class="no-more-posts" style="display: none;">
|
|
|
|
|
|
Все статьи загружены
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Sentinel для Intersection Observer -->
|
|
|
|
|
|
{pageInfo.hasNextPage && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
id="infinity-scroll-sentinel"
|
|
|
|
|
|
data-end-cursor={pageInfo.endCursor}
|
|
|
|
|
|
data-load-config={JSON.stringify(loadMoreConfig)}
|
2026-02-26 02:02:52 +03:00
|
|
|
|
data-current-index={items.length}
|
2026-01-28 14:46:51 +03:00
|
|
|
|
></div>
|
|
|
|
|
|
)}
|
2026-01-28 12:02:55 +03:00
|
|
|
|
</section>
|
2026-01-28 14:46:51 +03:00
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
interface PageInfo {
|
|
|
|
|
|
hasNextPage: boolean;
|
|
|
|
|
|
endCursor: string | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface LoadMoreConfig {
|
|
|
|
|
|
type: 'latest' | 'category' | 'author' | 'tag';
|
|
|
|
|
|
slug?: string;
|
2026-02-26 02:02:52 +03:00
|
|
|
|
perLoad?: number; // В скрипте оставляем first для совместимости
|
2026-01-28 14:46:51 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class InfinityScroll {
|
|
|
|
|
|
private grid: HTMLElement | null;
|
|
|
|
|
|
private sentinel: HTMLElement | null;
|
|
|
|
|
|
private loadingIndicator: HTMLElement | null;
|
|
|
|
|
|
private noMorePosts: HTMLElement | null;
|
|
|
|
|
|
private postsCount: HTMLElement | null;
|
|
|
|
|
|
private observer: IntersectionObserver | null = null;
|
|
|
|
|
|
private isLoading = false;
|
|
|
|
|
|
private hasMore = true;
|
|
|
|
|
|
private endCursor: string | null = null;
|
2026-02-26 02:02:52 +03:00
|
|
|
|
private currentIndex: number;
|
2026-01-28 14:46:51 +03:00
|
|
|
|
private loadMoreConfig: LoadMoreConfig;
|
|
|
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
|
this.grid = document.getElementById('posts-grid');
|
|
|
|
|
|
this.sentinel = document.getElementById('infinity-scroll-sentinel');
|
|
|
|
|
|
this.loadingIndicator = document.getElementById('loading-indicator');
|
|
|
|
|
|
this.noMorePosts = document.getElementById('no-more-posts');
|
|
|
|
|
|
this.postsCount = document.getElementById('posts-count');
|
|
|
|
|
|
|
|
|
|
|
|
const defaultConfig: LoadMoreConfig = { type: 'latest', first: 11 };
|
|
|
|
|
|
|
|
|
|
|
|
if (this.sentinel) {
|
|
|
|
|
|
this.endCursor = this.sentinel.dataset.endCursor || null;
|
2026-02-26 02:02:52 +03:00
|
|
|
|
this.currentIndex = parseInt(this.sentinel.dataset.currentIndex || '0');
|
2026-01-28 14:46:51 +03:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.loadMoreConfig = JSON.parse(this.sentinel.dataset.loadConfig || '{}');
|
|
|
|
|
|
this.loadMoreConfig = { ...defaultConfig, ...this.loadMoreConfig };
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
this.loadMoreConfig = defaultConfig;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.loadMoreConfig = defaultConfig;
|
2026-02-26 02:02:52 +03:00
|
|
|
|
this.currentIndex = 0;
|
2026-01-28 14:46:51 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.init();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private init() {
|
|
|
|
|
|
if (!this.sentinel || !this.grid) return;
|
|
|
|
|
|
|
|
|
|
|
|
this.observer = new IntersectionObserver(
|
|
|
|
|
|
(entries) => {
|
|
|
|
|
|
entries.forEach((entry) => {
|
|
|
|
|
|
if (entry.isIntersecting && !this.isLoading && this.hasMore) {
|
|
|
|
|
|
this.loadMorePosts();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
rootMargin: '200px',
|
|
|
|
|
|
threshold: 0.1
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
this.observer.observe(this.sentinel);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async loadMorePosts() {
|
|
|
|
|
|
if (this.isLoading || !this.hasMore) return;
|
|
|
|
|
|
|
|
|
|
|
|
this.isLoading = true;
|
|
|
|
|
|
this.showLoading();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-26 02:02:52 +03:00
|
|
|
|
const response = await fetch('/load-more-posts', {
|
2026-01-28 14:46:51 +03:00
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
2026-02-26 02:02:52 +03:00
|
|
|
|
perLoad: this.loadMoreConfig.perLoad || 11,
|
2026-01-28 14:46:51 +03:00
|
|
|
|
after: this.endCursor,
|
|
|
|
|
|
type: this.loadMoreConfig.type,
|
2026-02-26 02:02:52 +03:00
|
|
|
|
slug: this.loadMoreConfig.slug,
|
|
|
|
|
|
startIndex: this.currentIndex
|
2026-01-28 14:46:51 +03:00
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка загрузки постов');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 02:02:52 +03:00
|
|
|
|
const html = await response.text();
|
|
|
|
|
|
|
|
|
|
|
|
const temp = document.createElement('div');
|
|
|
|
|
|
temp.innerHTML = html;
|
|
|
|
|
|
|
|
|
|
|
|
const newSentinel = temp.querySelector('#infinity-scroll-sentinel');
|
|
|
|
|
|
let newEndCursor = null;
|
|
|
|
|
|
let hasNextPage = false;
|
2026-01-28 14:46:51 +03:00
|
|
|
|
|
2026-02-26 02:02:52 +03:00
|
|
|
|
if (newSentinel) {
|
|
|
|
|
|
newEndCursor = newSentinel.dataset.endCursor || null;
|
|
|
|
|
|
hasNextPage = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const articles = temp.querySelectorAll('article');
|
|
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
|
|
|
|
|
|
|
|
articles.forEach(article => {
|
|
|
|
|
|
fragment.appendChild(article.cloneNode(true));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.grid?.appendChild(fragment);
|
|
|
|
|
|
|
|
|
|
|
|
this.currentIndex += this.loadMoreConfig.first || 11;
|
|
|
|
|
|
this.endCursor = newEndCursor;
|
|
|
|
|
|
this.hasMore = hasNextPage;
|
|
|
|
|
|
|
|
|
|
|
|
if (this.postsCount) {
|
|
|
|
|
|
this.postsCount.textContent = ` (${this.currentIndex})`;
|
|
|
|
|
|
}
|
2026-01-28 14:46:51 +03:00
|
|
|
|
|
2026-02-26 02:02:52 +03:00
|
|
|
|
if (this.sentinel) {
|
|
|
|
|
|
this.sentinel.dataset.currentIndex = String(this.currentIndex);
|
|
|
|
|
|
this.sentinel.dataset.endCursor = newEndCursor || '';
|
|
|
|
|
|
|
|
|
|
|
|
if (!hasNextPage) {
|
2026-01-28 14:46:51 +03:00
|
|
|
|
this.showNoMorePosts();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Ошибка загрузки:', error);
|
|
|
|
|
|
this.hasMore = false;
|
|
|
|
|
|
this.showError();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.isLoading = false;
|
|
|
|
|
|
this.hideLoading();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private showLoading() {
|
|
|
|
|
|
if (this.loadingIndicator) {
|
|
|
|
|
|
this.loadingIndicator.style.display = 'block';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private hideLoading() {
|
|
|
|
|
|
if (this.loadingIndicator) {
|
|
|
|
|
|
this.loadingIndicator.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private showNoMorePosts() {
|
|
|
|
|
|
if (this.sentinel && this.observer) {
|
|
|
|
|
|
this.observer.unobserve(this.sentinel);
|
|
|
|
|
|
this.sentinel.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.noMorePosts) {
|
|
|
|
|
|
this.noMorePosts.style.display = 'block';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private showError() {
|
|
|
|
|
|
if (this.noMorePosts) {
|
|
|
|
|
|
this.noMorePosts.textContent = 'Ошибка загрузки. Попробуйте обновить страницу.';
|
|
|
|
|
|
this.noMorePosts.style.display = 'block';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public destroy() {
|
|
|
|
|
|
if (this.observer && this.sentinel) {
|
|
|
|
|
|
this.observer.unobserve(this.sentinel);
|
|
|
|
|
|
}
|
|
|
|
|
|
this.observer?.disconnect();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let infinityScroll: InfinityScroll | null = null;
|
|
|
|
|
|
|
2026-02-26 02:02:52 +03:00
|
|
|
|
if ('requestIdleCallback' in window) {
|
|
|
|
|
|
requestIdleCallback(() => {
|
|
|
|
|
|
infinityScroll = new InfinityScroll();
|
|
|
|
|
|
}, { timeout: 2000 });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
infinityScroll = new InfinityScroll();
|
|
|
|
|
|
}, 200);
|
|
|
|
|
|
}
|
2026-01-28 14:46:51 +03:00
|
|
|
|
|
|
|
|
|
|
document.addEventListener('astro:before-swap', () => {
|
|
|
|
|
|
infinityScroll?.destroy();
|
|
|
|
|
|
});
|
2026-02-26 02:02:52 +03:00
|
|
|
|
</script>
|