Files
profile-front/src/components/ContentGrid.astro

400 lines
12 KiB
Plaintext
Raw Normal View History

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-27 00:12:33 +03:00
perLoad?: number;
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-27 00:12:33 +03:00
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
const loadMoreConfig = {
type,
slug,
2026-03-02 23:10:31 +03:00
perLoad
2026-02-26 02:02:52 +03:00
};
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 '';
2026-03-02 23:10:31 +03:00
2026-01-28 12:02:55 +03:00
return coauthors
2026-03-02 23:10:31 +03:00
.map((coauthor: any) => {
const name = coauthor?.node?.name || coauthor?.name;
const nickname = coauthor?.node?.nickname || coauthor?.nickname;
return name; // Возвращаем только имя, ссылки будут в шаблоне
})
2026-01-28 12:02:55 +03:00
.filter(Boolean)
2026-03-02 23:10:31 +03:00
.join(', ');
2026-01-28 12:02:55 +03:00
}
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-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"
>
2026-03-02 23:10:31 +03:00
<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"
2026-02-18 23:33:35 +03:00
/>
2026-03-02 23:10:31 +03:00
) : (
<div class="post-image-placeholder"></div>
)}
{item.categories?.nodes?.[0] && (
<a
href={`/${item.categories.nodes[0].slug}`}
class="category-badge-link"
onClick={(e) => e.stopPropagation()}
>
<CategoryBadge
name={item.categories.nodes[0].name}
color={item.categories.nodes[0].color}
/>
</a>
)}
<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>
2025-12-11 01:12:45 +03:00
2026-03-02 23:10:31 +03:00
<a href={postUrl} class="post-title-link" itemprop="url">
2025-12-11 01:12:45 +03:00
<h3 class="post-title-overlay" itemprop="headline">
{item.title}
</h3>
2026-03-02 23:10:31 +03:00
</a>
2025-12-11 01:12:45 +03:00
2026-03-02 23:10:31 +03:00
{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;
console.log(nickname);
return (
2026-03-08 16:07:43 +03:00
<span class="author-name" key={nickname || name}>
2026-03-02 23:10:31 +03:00
{i > 0 && ', '}
{nickname ? (
<a
href={`/author/${nickname}`}
class="author-link"
onClick={(e) => e.stopPropagation()}
>
{name}
</a>
) : (
<span class="author-name">{name}</span>
)}
</span>
);
})}
</div>
)}
2025-12-11 01:12:45 +03:00
</div>
2026-03-02 23:10:31 +03:00
</div>
2025-12-11 01:12:45 +03:00
<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-27 00:12:33 +03:00
perLoad: number; // Только perLoad, никакого 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');
2026-02-27 00:12:33 +03:00
// Дефолтный конфиг только с perLoad
const defaultConfig: LoadMoreConfig = {
type: 'latest',
perLoad: 11
};
2026-01-28 14:46:51 +03:00
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 {
2026-02-27 00:12:33 +03:00
// Парсим конфиг из data-атрибута
const parsedConfig = JSON.parse(this.sentinel.dataset.loadConfig || '{}');
this.loadMoreConfig = {
...defaultConfig,
...parsedConfig,
// Убеждаемся, что perLoad определен
perLoad: parsedConfig.perLoad || defaultConfig.perLoad
};
2026-01-28 14:46:51 +03:00
} 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-27 00:12:33 +03:00
// Отправляем только perLoad (никакого first)
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-27 00:12:33 +03:00
perLoad: this.loadMoreConfig.perLoad,
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) {
2026-02-27 00:12:33 +03:00
throw new Error(`Ошибка загрузки постов: ${response.status}`);
2026-01-28 14:46:51 +03:00
}
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);
2026-02-27 00:12:33 +03:00
// Используем perLoad для увеличения индекса
this.currentIndex += this.loadMoreConfig.perLoad;
2026-02-26 02:02:52 +03:00
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>