diff --git a/astro.config.mjs b/astro.config.mjs
index 1de5d1b..fae997e 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -13,6 +13,7 @@ export default defineConfig({
'@lib': '/src/lib',
'@utils': '/src/lib/utils',
'@layouts': '/src/layouts',
+ '@templates': '/src/templates',
'@components': '/src/components',
'@api': '/src/lib/api'
}
diff --git a/src/components/Pagination.astro b/src/components/Pagination.astro
new file mode 100644
index 0000000..7adcb49
--- /dev/null
+++ b/src/components/Pagination.astro
@@ -0,0 +1,385 @@
+---
+
+export interface Props {
+ /** Текущая страница */
+ currentPage: number;
+ /** Всего страниц */
+ totalPages: number;
+ /** URL для навигации */
+ baseUrl: string;
+ /** Показывать кнопки "Назад/Вперед" */
+ showPrevNext?: boolean;
+ /** Показывать кнопки "Первая/Последняя" */
+ showFirstLast?: boolean;
+ /** Максимум видимых номеров страниц */
+ maxVisible?: number;
+ /** Текст для кнопки "Назад" */
+ prevText?: string;
+ /** Текст для кнопки "Вперед" */
+ nextText?: string;
+ /** Текст для кнопки "Первая" */
+ firstText?: string;
+ /** Текст для кнопки "Последняя" */
+ lastText?: string;
+ /** CSS класс для контейнера */
+ className?: string;
+}
+
+const {
+ currentPage = 1,
+ totalPages = 1,
+ baseUrl = '/',
+ showPrevNext = true,
+ showFirstLast = true,
+ maxVisible = 7,
+ prevText = '‹ Назад',
+ nextText = 'Вперед ›',
+ firstText = '««',
+ lastText = '»»',
+ className = ''
+} = Astro.props;
+
+// Проверка валидности данных
+if (currentPage < 1 || totalPages < 1 || currentPage > totalPages) {
+ console.warn('Invalid pagination data:', { currentPage, totalPages });
+}
+
+// Функция для генерации массива видимых страниц
+function getVisiblePages(current, total, max) {
+ if (total <= max) {
+ return Array.from({ length: total }, (_, i) => i + 1);
+ }
+
+ const half = Math.floor(max / 2);
+ let start = current - half;
+ let end = current + half;
+
+ if (start < 1) {
+ start = 1;
+ end = max;
+ }
+
+ if (end > total) {
+ end = total;
+ start = total - max + 1;
+ }
+
+ const pages = [];
+
+ // Добавляем первую страницу и многоточие если нужно
+ if (start > 1) {
+ pages.push(1);
+ if (start > 2) pages.push('...');
+ }
+
+ // Основные страницы
+ for (let i = start; i <= end; i++) {
+ pages.push(i);
+ }
+
+ // Добавляем многоточие и последнюю страницу если нужно
+ if (end < total) {
+ if (end < total - 1) pages.push('...');
+ pages.push(total);
+ }
+
+ return pages;
+}
+
+// Функция для построения URL страницы
+function getPageUrl(page) {
+ if (page === 1) {
+ return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
+ }
+
+ // Если baseUrl уже содержит /page/, заменяем номер
+ if (baseUrl.includes('/page/')) {
+ return baseUrl.replace(/\/page\/\d+\/?$/, `/page/${page}/`);
+ }
+
+ // Добавляем /page/ перед номером
+ return `${baseUrl.replace(/\/$/, '')}/page/${page}/`;
+}
+
+const visiblePages = getVisiblePages(currentPage, totalPages, maxVisible);
+const hasPrev = currentPage > 1;
+const hasNext = currentPage < totalPages;
+---
+
+
+
+
\ No newline at end of file
diff --git a/src/layouts/ArchiveLayout.astro b/src/layouts/ArchiveLayout.astro
new file mode 100644
index 0000000..01dd555
--- /dev/null
+++ b/src/layouts/ArchiveLayout.astro
@@ -0,0 +1,28 @@
+---
+const {
+ title,
+ page,
+ hasNextPage,
+ baseUrl,
+} = Astro.props;
+---
+
+
{title}
+
+
+
+
diff --git a/src/lib/api/archiveById.ts b/src/lib/api/archiveById.ts
index 5833adf..c29e8e1 100644
--- a/src/lib/api/archiveById.ts
+++ b/src/lib/api/archiveById.ts
@@ -4,133 +4,179 @@ import { fetchGraphQL } from '@lib/graphql-client.js';
import { cache } from '@lib/cache/manager.js';
import { CACHE_TTL } from '@lib/cache/cache-ttl';
-type ArchiveType = 'tag' | 'category' | 'author' | 'postType';
+/**
+ * Типы архивов
+ */
+export type ArchiveType = 'tag' | 'category' | 'author' | 'postType';
-interface ArchiveParams {
+export interface ArchiveParams {
type: ArchiveType;
- id?: number; // для tag/category/author
- postType?: string; // для CPT
- page?: number;
- perPage?: number;
+ id?: string; // ID ВСЕГДА строка
+ postType?: string; // для CPT
+ page: number;
+ perPage: number;
}
/**
- * Универсальный helper архивов с cursor-based пагинацией по ID
+ * WHERE builder
+ * ⚠️ Все ID — строки
*/
-export async function getArchivePostsById(params: ArchiveParams) {
- const page = params.page ?? 1;
- const perPage = params.perPage ?? 12;
- const after = await getCursorForPage(params, page, perPage);
+function buildWhere(params: ArchiveParams): string {
+ const { type, id } = params;
- const cacheKey = `archive:${params.type}:${params.id || params.postType || 'all'}:${page}:${perPage}`;
+ switch (type) {
+ case 'tag':
+ return `tagId: "${id}"`;
+
+ case 'category':
+ return `categoryId: "${id}"`;
+
+ case 'author':
+ return `authorId: "${id}"`;
+
+ case 'postType':
+ // profileArticles — твой CPT
+ return '';
+
+ default:
+ return '';
+ }
+}
+
+/**
+ * Получаем курсор для страницы N
+ * page 1 → cursor = null
+ */
+async function getCursorForPage(
+ params: ArchiveParams,
+ page: number,
+ perPage: number
+): Promise {
+ if (page <= 1) return null;
+
+ const cacheKey = `archive-cursor:${params.type}:${params.id || 'all'}:${page}:${perPage}`;
return await cache.wrap(
cacheKey,
async () => {
- // Формируем where через переменные
- const whereVars: Record = {};
- let whereClause = '';
-
- if (params.type === 'tag') {
- whereClause = 'tagId: $id';
- whereVars.id = String(params.id);
- } else if (params.type === 'category') {
- whereClause = 'categoryId: $id';
- whereVars.id = String(params.id);
- } else if (params.type === 'author') {
- whereClause = 'authorId: $id';
- whereVars.id = String(params.id);
- } else if (params.type === 'postType' && params.postType) {
- whereClause = '__typename: $postType';
- whereVars.postType = params.postType;
- }
+ const first = perPage * (page - 1);
+ const where = buildWhere(params);
const query = `
- query GetArchivePosts($first: Int!, $after: String${whereVars.id ? ', $id: ID!' : ''}${whereVars.postType ? ', $postType: String!' : ''}) {
- profileArticles(first: $first, after: $after, where: { ${whereClause}, orderby: { field: DATE, order: DESC } }) {
- pageInfo {
- hasNextPage
- endCursor
+ query GetCursor($first: Int!) {
+ profileArticles(
+ first: $first,
+ where: {
+ ${where}
+ orderby: { field: DATE, order: DESC }
}
+ ) {
edges {
cursor
- node {
- id
- databaseId
- title
- 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 variables = {
- first: perPage,
- after,
- ...whereVars
- };
+ const data = await fetchGraphQL(query, { first });
- const data = await fetchGraphQL(query, variables);
- const posts = data.profileArticles?.edges?.map((edge: any) => edge.node) || [];
-
- return {
- posts,
- pageInfo: data.profileArticles?.pageInfo || { hasNextPage: false, endCursor: null },
- currentPage: page,
- perPage,
- };
+ const edges = data.profileArticles?.edges || [];
+ return edges.length ? edges.at(-1).cursor : null;
},
{ ttl: CACHE_TTL.POSTS }
);
}
/**
- * Получение курсора для страницы N
+ * Основной helper для архивов
*/
-async function getCursorForPage(params: ArchiveParams, page: number, perPage: number): Promise {
- if (page <= 1) return null;
+export async function getArchivePostsById(params: ArchiveParams) {
+ const { page, perPage } = params;
- const cacheKey = `archive-cursor:${params.type}:${params.id || params.postType || 'all'}:${page}:${perPage}`;
+ const cursor = await getCursorForPage(params, page, perPage);
- return await cache.wrap(cacheKey, async () => {
- const first = perPage * (page - 1);
+ const cacheKey = `archive:${params.type}:${params.id || 'all'}:${page}:${perPage}`;
- const whereVars: Record = {};
- let whereClause = '';
+ return await cache.wrap(
+ cacheKey,
+ async () => {
+ const where = buildWhere(params);
- if (params.type === 'tag') {
- whereClause = 'tagId: $id';
- whereVars.id = String(params.id);
- } else if (params.type === 'category') {
- whereClause = 'categoryId: $id';
- whereVars.id = String(params.id);
- } else if (params.type === 'author') {
- whereClause = 'authorId: $id';
- whereVars.id = String(params.id);
- } else if (params.type === 'postType' && params.postType) {
- whereClause = '__typename: $postType';
- whereVars.postType = params.postType;
- }
-
- const query = `
- query GetCursor($first: Int!${whereVars.id ? ', $id: ID!' : ''}${whereVars.postType ? ', $postType: String!' : ''}) {
- profileArticles(first: $first, where: { ${whereClause}, orderby: { field: DATE, order: DESC } }) {
- edges { cursor }
+ const query = `
+ query GetArchive(
+ $first: Int!
+ $after: String
+ ) {
+ profileArticles(
+ first: $first
+ after: $after
+ where: {
+ ${where}
+ orderby: { field: DATE, order: DESC }
+ }
+ ) {
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ edges {
+ node {
+ id
+ databaseId
+ title
+ uri
+ date
+ featuredImage {
+ node {
+ sourceUrl(size: LARGE)
+ altText
+ }
+ }
+ author {
+ node {
+ id
+ name
+ uri
+ avatar {
+ url
+ }
+ }
+ }
+ categories {
+ nodes {
+ id
+ name
+ uri
+ }
+ }
+ tags {
+ nodes {
+ id
+ name
+ uri
+ }
+ }
+ }
+ }
+ }
}
- }
- `;
+ `;
- const variables = { first, ...whereVars };
- const result = await fetchGraphQL(query, variables);
+ const data = await fetchGraphQL(query, {
+ first: perPage,
+ after: cursor,
+ });
- // используем profileArticles, а не posts
- const edges = result.profileArticles?.edges || [];
- return edges.at(-1)?.cursor ?? null;
- }, { ttl: CACHE_TTL.POSTS });
+ const edges = data.profileArticles?.edges || [];
+
+ return {
+ posts: edges.map((e: any) => e.node),
+ pageInfo: data.profileArticles?.pageInfo ?? {
+ hasNextPage: false,
+ endCursor: null,
+ },
+ };
+ },
+ { ttl: CACHE_TTL.POSTS }
+ );
}
diff --git a/src/lib/api/categories.ts b/src/lib/api/categories.ts
new file mode 100644
index 0000000..71b9134
--- /dev/null
+++ b/src/lib/api/categories.ts
@@ -0,0 +1,44 @@
+import { fetchGraphQL } from '@lib/graphql-client.js';
+
+
+//кэширование
+import { cache } from '@lib/cache/manager.js';
+import { CACHE_TTL } from '@lib/cache/cache-ttl';
+
+export interface Category {
+ id: string;
+ databaseId: number;
+ name: string;
+ slug: string;
+ description: string;
+ count: number;
+ parentId?: number | null;
+}
+
+export async function getCategory(slug: string): Promise {
+ const cacheKey = `category:${slug}`;
+
+ return await cache.wrap(
+ cacheKey,
+ async () => {
+ const query = `
+ query GetCategory($slug: ID!) {
+ category(id: $slug, idType: SLUG) {
+ id
+ databaseId
+ name
+ slug
+ description
+ count
+ parentId
+ }
+ }
+ `;
+
+ const data = await fetchGraphQL(query, { slug });
+ return data?.category || null;
+ },
+ { ttl: CACHE_TTL.TAXONOMY }
+ );
+}
+
diff --git a/src/pages/[...slug].astro b/src/pages/[...slug].astro
new file mode 100644
index 0000000..cbfd333
--- /dev/null
+++ b/src/pages/[...slug].astro
@@ -0,0 +1,26 @@
+---
+import { fetchCategory } from '@api/categories';
+
+import MainLayout from '@layouts/MainLayout.astro';
+import CategoryArchive from '@templates/CategoryArchive.astro';
+import { getCategory } from '@api/categories';
+import { getArchivePostsById } from '@api/archiveById';
+
+export const prerender = false; // ISR
+//export const revalidate = 60;
+
+const { slug } = Astro.params;
+const pathArray = Array.isArray(slug) ? slug : [slug];
+
+
+// Получаем категорию по цепочке slug
+const category = await getCategory(pathArray);
+if (!category) return Astro.redirect('/404');
+
+const perPage = 20;
+
+---
+
+
+ {category.name}
+
diff --git a/src/pages/[...slug]/page/[page].astro b/src/pages/[...slug]/page/[page].astro
new file mode 100644
index 0000000..e69de29
diff --git a/src/pages/[category]/[...slug].astro b/src/pages/[category]/[...slug].astro
deleted file mode 100644
index 37e4061..0000000
--- a/src/pages/[category]/[...slug].astro
+++ /dev/null
@@ -1,109 +0,0 @@
----
-
-import { getProfileArticleById } from '@api/posts.js';
-
-import MainLayout from '@layouts/MainLayout.astro';
-
-export const prerender = false; // динамический роутинг
-const { category, slug } = Astro.params;
-
-// ищем ID поста
-function findPostId(slug) {
-
- const lastItem = Array.isArray(slug) ? slug[slug.length - 1] : slug;
-
- // Находим последний дефис
- const dashIndex = lastItem.lastIndexOf('-');
- if (dashIndex === -1) return null;
-
- // Берем всё после дефиса
- const idStr = lastItem.substring(dashIndex + 1);
-
- // Преобразуем в число
- const id = Number(idStr);
- return Number.isInteger(id) ? id : null;
-}
-
-const postId = findPostId(slug);
-
-let article;
-
-try {
- article = await getProfileArticleById(postId);
-} catch (error) {
- return Astro.redirect('/404');
-}
-
-if (!article) {
- return Astro.redirect('/404');
-}
-
-// Валидация: проверяем категорию
-const articleCategory = article.categories?.nodes?.[0]?.slug;
-
-
-// Если категория не совпадает, делаем редирект
-if (articleCategory && category !== articleCategory) {
- debugLog(`Редирект: категория не совпадает (${category} != ${articleCategory})`);
-
- // Строим правильный URL
- const correctUri = article.uri
- .replace(/^\//, '')
- .replace(/\/$/, '');
-
- return Astro.redirect(`/${correctUri}/`, 301);
-}
-
-// Валидация: проверяем полный путь
-const currentPath = `${category}/${Array.isArray(slug) ? slug.join('/') : slug}`;
-const correctPath = article.uri
- .replace(/^\//, '')
- .replace(/\/$/, '');
-
-if (currentPath !== correctPath) {
- debugLog(`Редирект: путь не совпадает (${currentPath} != ${correctPath})`);
- return Astro.redirect(`/${correctPath}/`, 301);
-}
-
----
-
-
-
-
-{article.title}
-
-{article.tags?.nodes?.length > 0 && (
-
-)}
-
-{article.featuredImage?.node?.sourceUrl && (
-
-
- {article.featuredImage.node.caption && (
-
- )}
-
-)}
-
-
-
-
-
-
-
-
-
-
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 2c95bf7..753f4a1 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -10,9 +10,10 @@ import MainLayout from '@layouts/MainLayout.astro';
import ContentGrid from '@components/ContentGrid.astro';
import EndnewsList from '@components/EndnewsList.astro';
-//export const prerender = {
-// isr: { expiration: 3 } // ISR: обновлять раз в 3 секунды
-//};
+
+//ISR
+export const prerender = false;
+//export const revalidate = 1;
---
-
-
-
-
-
-
- {data.posts && data.posts.length > 0 ? (
- <>
-
- {data.posts.map(post => (
-
- {post.featuredImage?.node?.sourceUrl && (
-
-
-
- )}
-
-
-
-
- {post.excerpt && (
-
- )}
-
-
-
-
- {post.categories?.nodes?.length > 0 && (
-
- {post.categories.nodes.slice(0, 2).map(cat => (
-
- {cat.name}
-
- ))}
-
- )}
-
-
-
- ))}
-
-
-
- {data.pagination && data.pagination.totalPages > 1 && (
-
- )}
- >
- ) : (
-
-
Постов пока нет
-
В этом теге еще нет опубликованных статей.
-
- )}
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/pages/tag/[slug]/index.astro b/src/pages/tag/[slug]/index.astro
new file mode 100644
index 0000000..12349c3
--- /dev/null
+++ b/src/pages/tag/[slug]/index.astro
@@ -0,0 +1,68 @@
+---
+
+import { slugParse } from '@utils/slugParser';
+
+
+//api
+import { getTag, getTagWithPostsById } from '@api/tags.js';
+import { getArchivePostsById } from '@/lib/api/archiveById';
+
+import MainLayout from '@layouts/MainLayout.astro';
+import TagArchive from '@/templates/TagArchive.astro';
+
+//ISR
+export const prerender = false;
+
+const { slug } = Astro.params;
+
+// Используем функцию (правильное название)
+const { slug: tagSlug, page: currentPage } = slugParse(slug);
+
+// Если slug пустой - 404
+if (!tagSlug) {
+ return Astro.redirect('/404');
+}
+
+
+// Получаем данные тега
+const tag = await getTag(tagSlug);
+
+if (!tag) {
+ return Astro.redirect('/404');
+}
+
+
+// ----------------------------
+// fetch archive
+// ----------------------------
+
+const perPage = 35;
+
+const { posts, pageInfo } = await getArchivePostsById({
+ type: 'tag',
+ id: tag.databaseId,
+ page: currentPage,
+ perPage,
+});
+
+// 404 если страница невалидна
+if (!posts.length && currentPage > 1) {
+ return Astro.redirect('/404');
+}
+
+---
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/CategoryArchive.astro b/src/templates/CategoryArchive.astro
new file mode 100644
index 0000000..8fb0168
--- /dev/null
+++ b/src/templates/CategoryArchive.astro
@@ -0,0 +1,27 @@
+---
+import ContentGrid from '@components/ContentGrid.astro';
+import ArchivePagination from '@/components/ArchivePagination.astro';
+
+const {
+ tag,
+ posts,
+ page,
+ hasNextPage,
+ baseUrl,
+} = Astro.props;
+---
+
+
+
+ {tag.name}
+ {tag.description && {tag.description}
}
+
+
+
+
+
+
diff --git a/src/templates/TagArchive.astro b/src/templates/TagArchive.astro
new file mode 100644
index 0000000..8fb0168
--- /dev/null
+++ b/src/templates/TagArchive.astro
@@ -0,0 +1,27 @@
+---
+import ContentGrid from '@components/ContentGrid.astro';
+import ArchivePagination from '@/components/ArchivePagination.astro';
+
+const {
+ tag,
+ posts,
+ page,
+ hasNextPage,
+ baseUrl,
+} = Astro.props;
+---
+
+
+
+ {tag.name}
+ {tag.description && {tag.description}
}
+
+
+
+
+
+