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

528 lines
16 KiB
Plaintext
Raw Normal View History

2025-12-11 01:12:45 +03:00
---
2026-02-02 17:58:36 +03:00
2025-12-11 01:12:45 +03:00
export interface Props {
items: any[];
showCount?: boolean;
2026-01-28 14:46:51 +03:00
pageInfo?: {
hasNextPage: boolean;
endCursor: string | null;
};
loadMoreConfig?: {
type: 'latest' | 'category' | 'author' | 'tag';
slug?: string;
first?: number;
};
2025-12-11 01:12:45 +03:00
}
const {
items = [],
2026-01-28 12:02:55 +03:00
showCount = false,
2026-01-28 14:46:51 +03:00
pageInfo = { hasNextPage: false, endCursor: null },
loadMoreConfig = { type: 'latest', first: 11 }
2025-12-11 01:12:45 +03:00
} = Astro.props;
2026-01-25 21:13:05 +03:00
function extractColorClass(colorString: string): string {
if (!colorString) return 'bg-blue';
if (colorString.includes('фон меню:')) {
const parts = colorString.split(':');
const color = parts[1]?.trim();
const validColors = [
'black', 'yellow', 'blue', 'green', 'red', 'orange', 'gray',
'indigo', 'purple', 'pink', 'teal', 'cyan', 'white',
'gray-dark', 'light', 'dark'
];
if (color && validColors.includes(color)) {
return `bg-${color}`;
}
}
if (colorString.startsWith('bg-')) {
return colorString;
}
const simpleColor = colorString.toLowerCase();
switch(simpleColor) {
case 'black': case 'yellow': case 'blue': case 'green':
case 'red': case 'orange': case 'gray': case 'indigo':
case 'purple': case 'pink': case 'teal': case 'cyan':
case 'white': case 'dark': case 'light':
return `bg-${simpleColor}`;
case 'gray-dark': return 'bg-gray-dark';
default: return 'bg-blue';
}
}
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
// ✅ ИСПРАВЛЕННАЯ функция для определения больших карточек
// Большие карточки на индексах: 8, 19, 30, 41, 52...
// Формула: (index - 8) % 11 === 0
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 12:02:55 +03:00
const coauthors = item.coauthors || [];
const coauthorsNames = getCoauthorsNames(coauthors);
2026-01-25 21:13:05 +03:00
const rawColor = item.categories?.nodes?.[0]?.color || '';
const categoryBgClass = extractColorClass(rawColor);
2025-12-11 01:12:45 +03:00
2026-01-28 14:46:51 +03:00
// ✅ ИСПРАВЛЕННАЯ логика
const isLarge = shouldBeLarge(index);
const largePosition = isLarge ? 'first' : '';
2025-12-11 01:12:45 +03:00
return (
<article
class={`post-card ${isLarge ? 'post-card-large' : ''}`}
2026-01-28 14:46:51 +03:00
data-large-position={largePosition}
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>
)}
{item.categories?.nodes?.[0]?.name && (
2026-01-25 21:13:05 +03:00
<div class={`post-category-badge ${categoryBgClass}`}>
2025-12-11 01:12:45 +03:00
{item.categories.nodes[0].name}
</div>
)}
<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)}
></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;
first?: number;
}
interface Post {
id: string;
databaseId: number;
title: string;
uri: string;
date: string;
featuredImage?: {
node?: {
sourceUrl: string;
altText: string;
};
};
coauthors?: Array<{
name?: string;
node?: {
name: string;
};
}>;
categories?: {
nodes?: Array<{
name: string;
color: string;
}>;
};
}
interface LoadPostsResponse {
posts: Post[];
pageInfo: PageInfo;
}
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 = 0;
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;
this.currentIndex = this.grid?.children.length || 0;
try {
this.loadMoreConfig = JSON.parse(this.sentinel.dataset.loadConfig || '{}');
this.loadMoreConfig = { ...defaultConfig, ...this.loadMoreConfig };
} catch {
this.loadMoreConfig = defaultConfig;
}
} else {
this.loadMoreConfig = defaultConfig;
}
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 {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
first: this.loadMoreConfig.first || 11,
after: this.endCursor,
type: this.loadMoreConfig.type,
slug: this.loadMoreConfig.slug
})
});
if (!response.ok) {
throw new Error('Ошибка загрузки постов');
}
const data: LoadPostsResponse = await response.json();
if (data.posts && data.posts.length > 0) {
this.appendPosts(data.posts);
this.endCursor = data.pageInfo.endCursor;
this.hasMore = data.pageInfo.hasNextPage;
if (!this.hasMore) {
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 appendPosts(posts: Post[]) {
if (!this.grid) return;
posts.forEach((post) => {
const article = this.createPostCard(post, this.currentIndex);
this.grid?.appendChild(article);
this.currentIndex++;
});
if (this.postsCount) {
this.postsCount.textContent = ` (${this.currentIndex})`;
}
}
private createPostCard(post: Post, index: number): HTMLElement {
const article = document.createElement('article');
article.className = 'post-card';
article.setAttribute('itemscope', '');
article.setAttribute('itemtype', 'https://schema.org/BlogPosting');
article.dataset.index = String(index);
// ✅ ИСПРАВЛЕННАЯ логика для больших карточек
// Большие карточки на индексах: 8, 19, 30, 41, 52, 63...
// Формула: (index - 8) % 11 === 0
const isLarge = index >= 8 && (index - 8) % 11 === 0;
if (isLarge) {
article.classList.add('post-card-large');
article.dataset.largePosition = 'first';
console.log(`[Large card] Index: ${index}`);
}
const postUrl = post.uri || `/blog/${post.databaseId}`;
const postDate = new Date(post.date);
const coauthorsNames = this.getCoauthorsNames(post.coauthors || []);
const categoryName = post.categories?.nodes?.[0]?.name || '';
const categoryColor = post.categories?.nodes?.[0]?.color || '';
const categoryClass = this.extractColorClass(categoryColor);
const imageUrl = post.featuredImage?.node?.sourceUrl;
const imageAlt = post.featuredImage?.node?.altText || post.title;
article.innerHTML = `
<a href="${postUrl}" class="post-card-link">
<div class="post-image-container">
${imageUrl
? `<img src="${imageUrl}" alt="${imageAlt}" width="400" height="400" loading="lazy" class="post-image" itemprop="image" />`
: '<div class="post-image-placeholder"></div>'
}
${categoryName
? `<div class="post-category-badge ${categoryClass}">${categoryName}</div>`
: ''
}
<div class="post-content-overlay">
<div class="post-meta-overlay">
<time datetime="${post.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">
${post.title}
</h3>
${coauthorsNames
? `<div class="author-name" itemprop="author">${coauthorsNames}</div>`
: ''
}
</div>
</div>
</a>
<div class="sr-only">
<h3 itemprop="headline">
<a href="${postUrl}" itemprop="url">${post.title}</a>
</h3>
<time datetime="${post.date}" itemprop="datePublished">
${postDate.toLocaleDateString('ru-RU')}
</time>
</div>
`;
return article;
}
private getCoauthorsNames(coauthors: any[]): string {
if (!coauthors || coauthors.length === 0) return '';
return coauthors
.map(c => c?.node?.name || c?.name)
.filter(Boolean)
.join(' ');
}
private extractColorClass(colorString: string): string {
if (!colorString) return 'bg-blue';
if (colorString.includes('фон меню:')) {
const parts = colorString.split(':');
const color = parts[1]?.trim();
const validColors = [
'black', 'yellow', 'blue', 'green', 'red', 'orange', 'gray',
'indigo', 'purple', 'pink', 'teal', 'cyan', 'white',
'gray-dark', 'light', 'dark'
];
if (color && validColors.includes(color)) {
return `bg-${color}`;
}
}
if (colorString.startsWith('bg-')) {
return colorString;
}
const simpleColor = colorString.toLowerCase();
const colorMap: Record<string, string> = {
'black': 'bg-black', 'yellow': 'bg-yellow', 'blue': 'bg-blue',
'green': 'bg-green', 'red': 'bg-red', 'orange': 'bg-orange',
'gray': 'bg-gray', 'indigo': 'bg-indigo', 'purple': 'bg-purple',
'pink': 'bg-pink', 'teal': 'bg-teal', 'cyan': 'bg-cyan',
'white': 'bg-white', 'dark': 'bg-dark', 'light': 'bg-light',
'gray-dark': 'bg-gray-dark'
};
return colorMap[simpleColor] || 'bg-blue';
}
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';
this.sentinel.remove();
}
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;
document.addEventListener('DOMContentLoaded', () => {
infinityScroll = new InfinityScroll();
});
document.addEventListener('astro:before-swap', () => {
infinityScroll?.destroy();
});
</script>