From a72d47f6d9a2be0f46c2fa83c101e6033722a8d9 Mon Sep 17 00:00:00 2001 From: Profile Profile Date: Mon, 15 Dec 2025 17:01:51 +0300 Subject: [PATCH] add templates --- astro.config.mjs | 1 + src/components/Pagination.astro | 385 ++++++++++++++++++++++++++ src/layouts/ArchiveLayout.astro | 28 ++ src/lib/api/archiveById.ts | 232 +++++++++------- src/lib/api/categories.ts | 44 +++ src/pages/[...slug].astro | 26 ++ src/pages/[...slug]/page/[page].astro | 0 src/pages/[category]/[...slug].astro | 109 -------- src/pages/index.astro | 7 +- src/pages/tag/[...slug].astro | 148 ---------- src/pages/tag/[slug]/index.astro | 68 +++++ src/templates/CategoryArchive.astro | 27 ++ src/templates/TagArchive.astro | 27 ++ 13 files changed, 749 insertions(+), 353 deletions(-) create mode 100644 src/components/Pagination.astro create mode 100644 src/layouts/ArchiveLayout.astro create mode 100644 src/lib/api/categories.ts create mode 100644 src/pages/[...slug].astro create mode 100644 src/pages/[...slug]/page/[page].astro delete mode 100644 src/pages/[category]/[...slug].astro delete mode 100644 src/pages/tag/[...slug].astro create mode 100644 src/pages/tag/[slug]/index.astro create mode 100644 src/templates/CategoryArchive.astro create mode 100644 src/templates/TagArchive.astro 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.tags.nodes.map(tag => ( - {tag.name} - ))} -
-)} - -{article.featuredImage?.node?.sourceUrl && ( - -)} - -
- - - - - - - - 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; --- - -
- -
-

- Тег: {tag.name} -

- - {tag.description && ( -
- )} - -
- Всего постов: {data.pagination?.totalPosts || 0} -
-
- - - {data.posts && data.posts.length > 0 ? ( - <> -
- {data.posts.map(post => ( -
- {post.featuredImage?.node?.sourceUrl && ( - - {post.featuredImage.node.altText - - )} - -
-

- - {post.title} - -

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

} +
+ + + + +