401 lines
13 KiB
Plaintext
401 lines
13 KiB
Plaintext
---
|
||
import CategoryBadge from './CategoryBadge.astro';
|
||
import Author from '@components/AuthorDisplay.astro';
|
||
|
||
export interface Props {
|
||
items: any[];
|
||
showCount?: boolean;
|
||
type: 'latest' | 'category' | 'author' | 'tag';
|
||
slug?: string;
|
||
pageInfo?: {
|
||
hasNextPage: boolean;
|
||
endCursor: string | null;
|
||
};
|
||
perLoad?: number; // Больше не используется, но оставляем для обратной совместимости
|
||
gridColumns?: 3 | 4;
|
||
}
|
||
|
||
const {
|
||
items = [],
|
||
showCount = false,
|
||
type = 'latest',
|
||
slug = '',
|
||
pageInfo = { hasNextPage: false, endCursor: null },
|
||
perLoad = 11, // Игнорируется
|
||
gridColumns = 4
|
||
} = Astro.props;
|
||
|
||
// Конфиг без perLoad, так как будем вычислять на клиенте
|
||
const loadMoreConfig = {
|
||
type,
|
||
slug,
|
||
gridColumns
|
||
};
|
||
|
||
function getCoauthorsNames(coauthors: any[]): string {
|
||
if (!coauthors || coauthors.length === 0) return '';
|
||
|
||
return coauthors
|
||
.map((coauthor: any) => {
|
||
const name = coauthor?.node?.name || coauthor?.name;
|
||
return name;
|
||
})
|
||
.filter(Boolean)
|
||
.join(', ');
|
||
}
|
||
|
||
function shouldBeLarge(index: number, columns: number): boolean {
|
||
if (columns === 4) {
|
||
// Паттерн для 4 колонок: большие на позициях 8, 19, 30, 41...
|
||
if (index < 8) return false;
|
||
return (index - 8) % 11 === 0;
|
||
} else {
|
||
// Паттерн для 3 колонок: большие на позициях 6, 14, 22, 30...
|
||
if (index < 6) return false;
|
||
return (index - 6) % 8 === 0;
|
||
}
|
||
}
|
||
---
|
||
|
||
<section class="posts-section" id="posts-section">
|
||
|
||
{showCount && items.length > 0 && (
|
||
<h2> <span id="posts-count"> ({items.length})</span></h2>
|
||
)}
|
||
|
||
<div
|
||
id="posts-grid"
|
||
class="posts-grid"
|
||
data-grid-columns={gridColumns}
|
||
class:list={[`posts-grid-${gridColumns}`]}
|
||
>
|
||
{items.map((item, index) => {
|
||
const postUrl = item.uri || `/blog/${item.databaseId}`;
|
||
const postDate = new Date(item.date);
|
||
const isLarge = shouldBeLarge(index, gridColumns);
|
||
|
||
return (
|
||
<article
|
||
class={`post-card ${isLarge ? 'post-card-large' : ''}`}
|
||
data-large={isLarge}
|
||
data-index={index}
|
||
itemscope
|
||
itemtype="https://schema.org/BlogPosting"
|
||
>
|
||
<div class="post-image-container">
|
||
{item.featuredImage?.node?.sourceUrl ? (
|
||
<img
|
||
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>
|
||
)}
|
||
|
||
{item.categories?.nodes?.[0] && (
|
||
<CategoryBadge
|
||
name={item.categories.nodes[0].name}
|
||
color={item.categories.nodes[0].color}
|
||
href={`/${item.categories.nodes[0].slug}`}
|
||
isNews={item.__typename === "ANew"}
|
||
/>
|
||
)}
|
||
|
||
<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>
|
||
|
||
<a href={postUrl} class="post-title-link" itemprop="url">
|
||
<h3 class="post-title-overlay" itemprop="headline">
|
||
{item.title}
|
||
</h3>
|
||
</a>
|
||
|
||
{item.coauthors && item.coauthors.length > 0 && (
|
||
<div class="coauthors-wrapper" itemprop="author">
|
||
{item.coauthors.map((coauthor: any, i: number) => {
|
||
const name = coauthor?.node?.name || coauthor?.name;
|
||
const nickname = coauthor?.node?.nickname || coauthor?.nickname;
|
||
|
||
return (
|
||
<span class="author-name" key={nickname || name}>
|
||
{i > 0 && ', '}
|
||
{nickname ? (
|
||
<a
|
||
href={`/author/${nickname}`}
|
||
class="author-link"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{name}
|
||
</a>
|
||
) : (
|
||
<span class="author-name">{name}</span>
|
||
)}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
{pageInfo.hasNextPage && (
|
||
<div
|
||
id="infinity-scroll-sentinel"
|
||
data-end-cursor={pageInfo.endCursor}
|
||
data-load-config={JSON.stringify(loadMoreConfig)}
|
||
data-current-index={items.length}
|
||
data-grid-columns={gridColumns}
|
||
></div>
|
||
)}
|
||
</section>
|
||
|
||
<script>
|
||
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;
|
||
private currentIndex: number;
|
||
private gridColumns: 3 | 4 = 4;
|
||
|
||
// Константы для разных сеток
|
||
private readonly CYCLE_LENGTH = {
|
||
3: 14, // Полный цикл для 3 колонок: 6 обычных + 1 большая + 6 обычных + 1 большая
|
||
4: 19 // Полный цикл для 4 колонок: 8 обычных + 1 большая + 9 обычных + 1 большая
|
||
};
|
||
|
||
private readonly OPTIMAL_LOADS = {
|
||
3: [9, 12, 15], // 3, 4, 5 полных рядов
|
||
4: [12, 16, 20] // 3, 4, 5 полных рядов
|
||
};
|
||
|
||
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');
|
||
|
||
if (!this.sentinel) return;
|
||
|
||
this.endCursor = this.sentinel.dataset.endCursor || null;
|
||
this.currentIndex = parseInt(this.sentinel.dataset.currentIndex || '0');
|
||
this.gridColumns = parseInt(this.sentinel.dataset.gridColumns || '4') as 3 | 4;
|
||
|
||
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 getOptimalLoadCount(): number {
|
||
const columns = this.gridColumns;
|
||
const cycleLength = this.CYCLE_LENGTH[columns];
|
||
const position = this.currentIndex % cycleLength;
|
||
const options = this.OPTIMAL_LOADS[columns];
|
||
|
||
// Выбираем оптимальное число в зависимости от позиции в цикле
|
||
if (position < columns) {
|
||
// Мы в начале цикла - можно загрузить 3 полных ряда
|
||
return options[0];
|
||
} else if (position < columns * 2) {
|
||
// Мы в середине цикла - лучше загрузить 4 полных ряда
|
||
return options[1];
|
||
} else {
|
||
// Мы ближе к концу цикла - загружаем 5 полных рядов
|
||
return options[2];
|
||
}
|
||
}
|
||
|
||
private async loadMorePosts() {
|
||
if (this.isLoading || !this.hasMore) return;
|
||
|
||
this.isLoading = true;
|
||
this.showLoading();
|
||
|
||
try {
|
||
const loadCount = this.getOptimalLoadCount();
|
||
|
||
const response = await fetch('/load-more-posts', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
perLoad: loadCount, // Используем оптимальное число
|
||
after: this.endCursor,
|
||
type: this.loadMoreConfig?.type || 'latest',
|
||
slug: this.loadMoreConfig?.slug || '',
|
||
startIndex: this.currentIndex,
|
||
gridColumns: this.gridColumns
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Ошибка загрузки: ${response.status}`);
|
||
}
|
||
|
||
const html = await response.text();
|
||
const temp = document.createElement('div');
|
||
temp.innerHTML = html;
|
||
|
||
const newSentinel = temp.querySelector('#infinity-scroll-sentinel');
|
||
const hasNextPage = !!newSentinel;
|
||
const newEndCursor = newSentinel?.dataset.endCursor || null;
|
||
|
||
const articles = temp.querySelectorAll('article');
|
||
|
||
if (articles.length > 0) {
|
||
const fragment = document.createDocumentFragment();
|
||
articles.forEach(article => fragment.appendChild(article.cloneNode(true)));
|
||
this.grid?.appendChild(fragment);
|
||
|
||
this.currentIndex += articles.length;
|
||
this.endCursor = newEndCursor;
|
||
this.hasMore = hasNextPage;
|
||
|
||
if (this.postsCount) {
|
||
this.postsCount.textContent = ` (${this.currentIndex})`;
|
||
}
|
||
|
||
if (this.sentinel) {
|
||
this.sentinel.dataset.currentIndex = String(this.currentIndex);
|
||
this.sentinel.dataset.endCursor = newEndCursor || '';
|
||
|
||
if (!hasNextPage) {
|
||
this.showNoMorePosts();
|
||
}
|
||
}
|
||
} else {
|
||
this.hasMore = false;
|
||
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();
|
||
}
|
||
}
|
||
|
||
// Инициализация
|
||
if (document.getElementById('infinity-scroll-sentinel')) {
|
||
if ('requestIdleCallback' in window) {
|
||
requestIdleCallback(() => {
|
||
new InfinityScroll();
|
||
}, { timeout: 2000 });
|
||
} else {
|
||
setTimeout(() => {
|
||
new InfinityScroll();
|
||
}, 200);
|
||
}
|
||
}
|
||
</script> |