Files
profile-front/src/components/ContentGrid.astro
Profile Profile 1b4be186a7 add components
2026-03-09 22:02:36 +03:00

401 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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>