add load-more-posts

This commit is contained in:
Profile Profile
2026-02-26 02:02:52 +03:00
parent b58b35bf47
commit 3da5b48c40
7 changed files with 389 additions and 222 deletions

View File

@@ -22,8 +22,6 @@ if (post?.coauthors && post.coauthors.length > 0) {
} }
--- ---
{authorDisplay ? ( {authorDisplay?.trim() && (
<span set:html={authorDisplay} /> <Fragment set:html={authorDisplay} />
) : (
<span>Автор не указан</span>
)} )}

View File

@@ -1,42 +1,44 @@
--- ---
import CategoryBadge from './CategoryBadge.astro';
import CategoryBadge from './CategoryBadge.astro'; // цветная плитка рубрик import Author from '@components/AuthorDisplay.astro';
export interface Props { export interface Props {
items: any[]; items: any[];
showCount?: boolean; showCount?: boolean;
type: 'latest' | 'category' | 'author' | 'tag';
slug?: string;
pageInfo?: { pageInfo?: {
hasNextPage: boolean; hasNextPage: boolean;
endCursor: string | null; endCursor: string | null;
}; };
loadMoreConfig?: { perLoad?: number; // Переименовали first в perLoad
type: 'latest' | 'category' | 'author' | 'tag';
slug?: string;
first?: number;
};
} }
const { const {
items = [], items = [],
showCount = false, showCount = false,
type = 'latest',
slug = '',
pageInfo = { hasNextPage: false, endCursor: null }, pageInfo = { hasNextPage: false, endCursor: null },
loadMoreConfig = { type: 'latest', first: 11 } perLoad = 11 // perLoad на верхнем уровне с дефолтом 11
} = Astro.props; } = Astro.props;
// Формируем конфиг для sentinel из пропсов верхнего уровня
// Внутри оставляем поле first для совместимости с API и скриптом
const loadMoreConfig = {
type,
slug,
first: perLoad // Маппим perLoad в first для обратной совместимости
};
function getCoauthorsNames(coauthors: any[]): string { function getCoauthorsNames(coauthors: any[]): string {
if (!coauthors || coauthors.length === 0) return ''; if (!coauthors || coauthors.length === 0) return '';
return coauthors return coauthors
.map((coauthor: any) => coauthor?.node?.name || coauthor?.name) .map((coauthor: any) => coauthor?.node?.name || coauthor?.name)
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
} }
// ✅ ИСПРАВЛЕННАЯ функция для определения больших карточек
// Большие карточки на индексах: 8, 19, 30, 41, 52...
// Формула: (index - 8) % 11 === 0
function shouldBeLarge(index: number): boolean { function shouldBeLarge(index: number): boolean {
if (index < 8) return false; if (index < 8) return false;
return (index - 8) % 11 === 0; return (index - 8) % 11 === 0;
@@ -54,18 +56,13 @@ function shouldBeLarge(index: number): boolean {
{items.map((item, index) => { {items.map((item, index) => {
const postUrl = item.uri || `/blog/${item.databaseId}`; const postUrl = item.uri || `/blog/${item.databaseId}`;
const postDate = new Date(item.date); const postDate = new Date(item.date);
const coauthors = item.coauthors || []; const coauthorsNames = getCoauthorsNames(item.coauthors || []);
const coauthorsNames = getCoauthorsNames(coauthors);
// ✅ ИСПРАВЛЕННАЯ логика
const isLarge = shouldBeLarge(index); const isLarge = shouldBeLarge(index);
const largePosition = isLarge ? 'first' : '';
return ( return (
<article <article
class={`post-card ${isLarge ? 'post-card-large' : ''}`} class={`post-card ${isLarge ? 'post-card-large' : ''}`}
data-large-position={largePosition} data-large-position={isLarge ? 'first' : ''}
data-index={index} data-index={index}
itemscope itemscope
itemtype="https://schema.org/BlogPosting" itemtype="https://schema.org/BlogPosting"
@@ -152,6 +149,7 @@ function shouldBeLarge(index: number): boolean {
id="infinity-scroll-sentinel" id="infinity-scroll-sentinel"
data-end-cursor={pageInfo.endCursor} data-end-cursor={pageInfo.endCursor}
data-load-config={JSON.stringify(loadMoreConfig)} data-load-config={JSON.stringify(loadMoreConfig)}
data-current-index={items.length}
></div> ></div>
)} )}
</section> </section>
@@ -165,38 +163,7 @@ function shouldBeLarge(index: number): boolean {
interface LoadMoreConfig { interface LoadMoreConfig {
type: 'latest' | 'category' | 'author' | 'tag'; type: 'latest' | 'category' | 'author' | 'tag';
slug?: string; slug?: string;
first?: number; perLoad?: number; // В скрипте оставляем first для совместимости
}
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 { class InfinityScroll {
@@ -209,7 +176,7 @@ function shouldBeLarge(index: number): boolean {
private isLoading = false; private isLoading = false;
private hasMore = true; private hasMore = true;
private endCursor: string | null = null; private endCursor: string | null = null;
private currentIndex = 0; private currentIndex: number;
private loadMoreConfig: LoadMoreConfig; private loadMoreConfig: LoadMoreConfig;
constructor() { constructor() {
@@ -223,7 +190,7 @@ function shouldBeLarge(index: number): boolean {
if (this.sentinel) { if (this.sentinel) {
this.endCursor = this.sentinel.dataset.endCursor || null; this.endCursor = this.sentinel.dataset.endCursor || null;
this.currentIndex = this.grid?.children.length || 0; this.currentIndex = parseInt(this.sentinel.dataset.currentIndex || '0');
try { try {
this.loadMoreConfig = JSON.parse(this.sentinel.dataset.loadConfig || '{}'); this.loadMoreConfig = JSON.parse(this.sentinel.dataset.loadConfig || '{}');
@@ -233,6 +200,7 @@ function shouldBeLarge(index: number): boolean {
} }
} else { } else {
this.loadMoreConfig = defaultConfig; this.loadMoreConfig = defaultConfig;
this.currentIndex = 0;
} }
this.init(); this.init();
@@ -265,16 +233,17 @@ function shouldBeLarge(index: number): boolean {
this.showLoading(); this.showLoading();
try { try {
const response = await fetch('/api/posts', { const response = await fetch('/load-more-posts', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
first: this.loadMoreConfig.first || 11, perLoad: this.loadMoreConfig.perLoad || 11,
after: this.endCursor, after: this.endCursor,
type: this.loadMoreConfig.type, type: this.loadMoreConfig.type,
slug: this.loadMoreConfig.slug slug: this.loadMoreConfig.slug,
startIndex: this.currentIndex
}) })
}); });
@@ -282,19 +251,44 @@ function shouldBeLarge(index: number): boolean {
throw new Error('Ошибка загрузки постов'); throw new Error('Ошибка загрузки постов');
} }
const data: LoadPostsResponse = await response.json(); const html = await response.text();
if (data.posts && data.posts.length > 0) { const temp = document.createElement('div');
this.appendPosts(data.posts); temp.innerHTML = html;
this.endCursor = data.pageInfo.endCursor;
this.hasMore = data.pageInfo.hasNextPage;
if (!this.hasMore) { const newSentinel = temp.querySelector('#infinity-scroll-sentinel');
let newEndCursor = null;
let hasNextPage = false;
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})`;
}
if (this.sentinel) {
this.sentinel.dataset.currentIndex = String(this.currentIndex);
this.sentinel.dataset.endCursor = newEndCursor || '';
if (!hasNextPage) {
this.showNoMorePosts(); this.showNoMorePosts();
} }
} else {
this.hasMore = false;
this.showNoMorePosts();
} }
} catch (error) { } catch (error) {
console.error('Ошибка загрузки:', error); console.error('Ошибка загрузки:', error);
@@ -306,141 +300,6 @@ function shouldBeLarge(index: number): boolean {
} }
} }
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() { private showLoading() {
if (this.loadingIndicator) { if (this.loadingIndicator) {
this.loadingIndicator.style.display = 'block'; this.loadingIndicator.style.display = 'block';
@@ -457,7 +316,6 @@ function shouldBeLarge(index: number): boolean {
if (this.sentinel && this.observer) { if (this.sentinel && this.observer) {
this.observer.unobserve(this.sentinel); this.observer.unobserve(this.sentinel);
this.sentinel.style.display = 'none'; this.sentinel.style.display = 'none';
this.sentinel.remove();
} }
if (this.noMorePosts) { if (this.noMorePosts) {
@@ -482,9 +340,15 @@ function shouldBeLarge(index: number): boolean {
let infinityScroll: InfinityScroll | null = null; let infinityScroll: InfinityScroll | null = null;
document.addEventListener('DOMContentLoaded', () => { if ('requestIdleCallback' in window) {
infinityScroll = new InfinityScroll(); requestIdleCallback(() => {
}); infinityScroll = new InfinityScroll();
}, { timeout: 2000 });
} else {
setTimeout(() => {
infinityScroll = new InfinityScroll();
}, 200);
}
document.addEventListener('astro:before-swap', () => { document.addEventListener('astro:before-swap', () => {
infinityScroll?.destroy(); infinityScroll?.destroy();

View File

@@ -292,6 +292,7 @@ console.log('Fetching node for URI:', uri);
); );
} }
export async function getCategoryPosts( export async function getCategoryPosts(
categoryId: number, categoryId: number,
page = 1, page = 1,
@@ -386,6 +387,120 @@ export async function getCategoryPosts(
); );
} }
export async function getPostsBySlug(
slug: string,
page = 1,
postsPerPage = 12
) {
const offset = (page - 1) * postsPerPage;
const cacheKey = `posts-by-slug:${slug}:${page}:${postsPerPage}`;
return await cache.wrap(
cacheKey,
async () => {
const query = `
query GetPostsBySlug($slug: String!, $size: Int!, $offset: Int!) {
category(id: $slug, idType: SLUG) {
id
name
slug
databaseId
description
posts(
first: $size,
where: {
offsetPagination: { size: $size, offset: $offset },
orderby: { field: DATE, order: DESC }
}
) {
pageInfo {
offsetPagination {
total
hasMore
hasPrevious
}
}
nodes {
__typename
id
databaseId
title
excerpt
uri
date
featuredImage {
node {
sourceUrl(size: LARGE)
altText
}
}
author {
node {
id
name
firstName
lastName
avatar {
url
}
uri
}
}
categories {
nodes {
id
name
slug
uri
databaseId
}
}
tags {
nodes {
id
name
slug
uri
}
}
}
}
}
}
`;
const data = await fetchGraphQL(query, {
slug,
size: postsPerPage,
offset
});
return {
category: data?.category ? {
id: data.category.id,
databaseId: data.category.databaseId,
name: data.category.name,
slug: data.category.slug,
description: data.category.description
} : null,
posts: data?.category?.posts?.nodes || [],
pageInfo: data?.category?.posts?.pageInfo?.offsetPagination || {
total: 0,
hasMore: false,
hasPrevious: false
}
};
},
{ ttl: CACHE_TTL.POSTS }
);
}
export async function invalidateNodeCache(uri: string): Promise<void> { export async function invalidateNodeCache(uri: string): Promise<void> {
const normalizedUri = uri.startsWith('/') ? uri : `/${uri}`; const normalizedUri = uri.startsWith('/') ? uri : `/${uri}`;
const cacheKey = `node-by-uri:${normalizedUri}`; const cacheKey = `node-by-uri:${normalizedUri}`;

View File

@@ -103,6 +103,98 @@ export async function getLatestPosts(first = 14, after = null) {
} }
export async function getPostsByCategory(slug, first = 14, after = null) {
// Создаем уникальный ключ для кэша
const cacheKey = `category-posts:${slug}:${first}:${after || 'first-page'}`;
return await cache.wrap(
cacheKey,
async () => {
const query = `
query GetPostsByCategory($first: Int!, $after: String, $slug: String!) {
profileArticles(
first: $first
after: $after
where: {
orderby: { field: DATE, order: DESC }
categoryName: $slug
}
) {
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
id
databaseId
title
uri
date
featuredImage {
node {
sourceUrl(size: LARGE)
altText
}
}
author {
node {
id
name
firstName
lastName
avatar {
url
}
uri
}
}
# Соавторы как массив
coauthors {
id
name
firstName
lastName
url
description
}
categories {
nodes {
id
name
color
slug
uri
databaseId
}
}
}
}
}
}
`;
const data = await fetchGraphQL(query, { first, after, slug });
const posts = data.profileArticles?.edges?.map(edge => edge.node) || [];
return {
posts,
pageInfo: data.profileArticles?.pageInfo || { hasNextPage: false, endCursor: null }
};
},
{ ttl: CACHE_TTL.POSTS }
);
}
//последние новости (кэшированная версия) //последние новости (кэшированная версия)
export async function getLatestAnews(count = 12): Promise<AnewsPost[]> { export async function getLatestAnews(count = 12): Promise<AnewsPost[]> {
const cacheKey = `latest-anews:${count}`; const cacheKey = `latest-anews:${count}`;

View File

@@ -1,9 +1,14 @@
--- ---
import MainLayout from '@layouts/MainLayout.astro'; import MainLayout from '@layouts/MainLayout.astro';
import NewsSingle from '@components/NewsSingle.astro'; import NewsSingle from '@components/NewsSingle.astro';
import ContentGrid from '@components/ContentGrid.astro';
import { getNodeByURI } from '@lib/api/all';
import { getProfileArticleById, getPostsByCategory } from '@lib/api/posts'; //логика
import { getCategory } from '@lib/api/categories'; //логика
import { getNodeByURI, getCategoryPosts } from '@lib/api/all';
import { getProfileArticleById } from '@lib/api/posts'; //логика
import { detectPageType } from '@lib/detect-page-type'; import { detectPageType } from '@lib/detect-page-type';
@@ -13,17 +18,29 @@ const pathname = Astro.url.pathname; // "/news/society/chto-sluchilos-nochju-27-
const pageInfo = detectPageType(pathname); //определяем тип страницы const pageInfo = detectPageType(pathname); //определяем тип страницы
let response; let response;
let article = null; let article = null;
let posts = null;
let result = null;
let title = 'Профиль'; //title page
if (pageInfo.type === 'single') { //одиночная статья if (pageInfo.type === 'single') { //одиночная статья
try { try {
article = await getProfileArticleById(pageInfo.postId); //получвем данные поста article = await getProfileArticleById(pageInfo.postId); //получвем данные поста
title=article.titleж
} catch (error) { } catch (error) {
console.error('Error fetching node:', error); console.error('Error fetching node:', error);
} }
} else if (pageInfo.type === 'archive') {
result = await getPostsByCategory(pageInfo.categorySlug, 11); //получвем данные поста
posts = result.posts;
} }
@@ -36,17 +53,29 @@ if (pageInfo.type === 'single') { //одиночная статья
--- ---
<MainLayout <MainLayout
title={article.title} title={title}
description="Информационное агентство Деловой журнал Профиль" description="Информационное агентство Деловой журнал Профиль"
> >
{/* Single post */}
{pageInfo.type === 'single' && article ? ( {pageInfo.type === 'single' && article && (
<NewsSingle post={article} pageInfo={pageInfo} /> <NewsSingle post={article} pageInfo={pageInfo} />
) : (
// Можно добавить fallback контент
<div>Загрузка или другой тип страницы...</div>
)} )}
{/* Category archive */}
{pageInfo.type === 'archive' && posts && (
<ContentGrid
items={posts}
pageInfo={pageInfo}
slug={pageInfo.categorySlug}
showCount={false}
type='category'
perLoad={11}
/>
)}
</MainLayout> </MainLayout>

View File

@@ -33,11 +33,13 @@ export const prerender = false;
<MainLine /> <MainLine />
<ContentGrid <ContentGrid
items={posts} items={posts}
pageInfo={pageInfo} pageInfo={pageInfo}
showCount={true} type="latest"
loadMoreConfig={{ type: 'latest', first: 11 }} perLoad={11}
showCount={true}
/> />

View File

@@ -0,0 +1,67 @@
---
import ContentGrid from '@components/ContentGrid.astro';
export const prerender = false;
// Получаем данные из POST запроса
const rawBody = await Astro.request.text();
let body = {};
try {
body = JSON.parse(rawBody);
} catch (e) {
// Если тело пустое или невалидное
}
// Обновляем деструктуризацию - first заменяем на perLoad
const {
perLoad = 11, // Теперь perLoad
after = null,
type = 'latest',
slug = null,
startIndex = 0
} = body;
console.log('📥 Load more request:', { perLoad, after, type, slug, startIndex });
// Импортируем функции для получения данных
const { getLatestPosts, getPostsByCategory, getAuthorPosts, getTagPosts } =
await import('../lib/api/posts');
let result;
// Получаем данные в зависимости от типа
switch (type) {
case 'category':
if (!slug) throw new Error('Slug required for category');
result = await getPostsByCategory(slug, perLoad, after); // Используем perLoad
break;
case 'author':
if (!slug) throw new Error('Slug required for author');
result = await getAuthorPosts(slug, perLoad, after); // Используем perLoad
break;
case 'tag':
if (!slug) throw new Error('Slug required for tag');
result = await getTagPosts(slug, perLoad, after); // Используем perLoad
break;
case 'latest':
default:
result = await getLatestPosts(perLoad, after); // Используем perLoad
break;
}
if (!result || !result.posts) {
throw new Error('Invalid data structure returned from API');
}
---
<!-- Используем компонент ContentGrid для рендера -->
<ContentGrid
items={result.posts}
showCount={false}
pageInfo={result.pageInfo}
type={type} // Передаем type напрямую
slug={slug || undefined}
perLoad={perLoad} // Передаем perLoad
/>