From 80fb06e42040ce5b6f207984edb000552d54683d Mon Sep 17 00:00:00 2001 From: Andrey Kuvshinov Date: Sat, 13 Dec 2025 23:29:25 +0300 Subject: [PATCH] add tags --- astro.config.mjs | 18 +- src/layouts/MainLayout.astro | 2 +- src/lib/api/menu.ts | 101 +++++---- src/lib/api/posts.ts | 296 ++++++++++++++++++++++++++- src/lib/utils/slugParser.js | 70 +++++++ src/pages/[category]/[...slug].astro | 109 ++++++++++ src/pages/index.astro | 14 +- src/pages/tag/[...slug].astro | 24 +++ 8 files changed, 574 insertions(+), 60 deletions(-) create mode 100644 src/lib/utils/slugParser.js create mode 100644 src/pages/[category]/[...slug].astro create mode 100644 src/pages/tag/[...slug].astro diff --git a/astro.config.mjs b/astro.config.mjs index 53de8b3..99ac264 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,11 +1,21 @@ -// @ts-check import { defineConfig } from 'astro/config'; - import node from '@astrojs/node'; -// https://astro.build/config export default defineConfig({ adapter: node({ mode: 'standalone' - }) + }), + + vite: { + resolve: { + alias: { + '@': '/src', + '@utils': '/src/lib/utils', + '@layouts': '/src/layouts', + '@components': '/src/components', + '@api': '/src/lib/api' + } + } + } + }); \ No newline at end of file diff --git a/src/layouts/MainLayout.astro b/src/layouts/MainLayout.astro index 95f87e5..2aa5b46 100644 --- a/src/layouts/MainLayout.astro +++ b/src/layouts/MainLayout.astro @@ -15,7 +15,7 @@ import '../styles/global.css'; - {`${title}`} + {`${title}`} - Деловой журнал Профиль diff --git a/src/lib/api/menu.ts b/src/lib/api/menu.ts index 77d703c..1c09103 100644 --- a/src/lib/api/menu.ts +++ b/src/lib/api/menu.ts @@ -1,50 +1,71 @@ import { fetchGraphQL } from './graphql-client.js'; -import type { MenuItem } from '../types/graphql.js'; +interface MenuItemNode { + uri: string; + url: string; + order: number; + label: string; +} +interface MenuNode { + name: string; + menuItems: { + nodes: MenuItemNode[]; + }; +} -// Функция ДЛЯ ГЛАВНОГО МЕНЮ (максимум 5 пунктов) -export async function getMainHeaderMenu(): Promise { - const query = ` - query GetMainHeaderMenu { - menu(id: "103245", idType: DATABASE_ID) { - menuItems( - first: 5, # Берем только 5 - where: {parentId: null} # Только верхний уровень - ) { - nodes { - id - label - url - target - order - cssClasses +interface MenusResponse { + menus: { + nodes: MenuNode[]; + }; +} + +/** + * Get navigation menu from WordPress + */ +export async function navQuery(): Promise { + try { + const query = `{ + menus(where: {location: PRIMARY}) { + nodes { + name + menuItems { + nodes { + uri + url + order + label + } } } } - } - `; + }`; - try { - const data = await fetchGraphQL(query); - const items = data.menu?.menuItems?.nodes || []; - - // Сортируем по order и гарантируем максимум 5 - return items - .sort((a: MenuItem, b: MenuItem) => a.order - b.order) - .slice(0, 5); - - } catch (error) { - console.error('Ошибка загрузки главного меню:', error); - - // Запасной вариант на случай ошибки (ровно 5 пунктов) - return [ - { id: '1', label: 'Главная', url: '/', order: 0 }, - { id: '2', label: 'Каталог', url: '/catalog', order: 1 }, - { id: '3', label: 'О нас', url: '/about', order: 2 }, - { id: '4', label: 'Контакты', url: '/contacts', order: 3 }, - { id: '5', label: 'Блог', url: '/blog', order: 4 } - ]; - } + return await executeQuery(query, {}, "navigation"); + } catch (error) { + log.error("Error fetching nav: " + error); + // Return fallback data for development + return { + menus: { + nodes: [ + { + name: "Primary", + menuItems: { + nodes: [ + { uri: "/", url: "/", order: 1, label: "Home" }, + { uri: "/about/", url: "/about/", order: 2, label: "About" }, + { + uri: "/contact/", + url: "/contact/", + order: 3, + label: "Contact", + }, + ], + }, + }, + ], + }, + }; + } } \ No newline at end of file diff --git a/src/lib/api/posts.ts b/src/lib/api/posts.ts index 77fe15a..182405c 100644 --- a/src/lib/api/posts.ts +++ b/src/lib/api/posts.ts @@ -1,7 +1,13 @@ import { fetchGraphQL } from './graphql-client.js'; import type { ProfileArticle } from '../types/graphql.js'; -// lib/api/posts.js +export interface AnewsPost { + title: string; + uri: string; + date: string; +} + +//последние статьи export async function getLatestPosts(first = 12, after = null) { const query = ` query GetLatestProfileArticles($first: Int!, $after: String) { @@ -75,12 +81,7 @@ export async function getLatestPosts(first = 12, after = null) { } -export interface AnewsPost { - title: string; - uri: string; - date: string; -} - +//последние новости export async function getLatestAnews(count = 12): Promise { const query = ` query GetAnews($count: Int!) { @@ -96,4 +97,283 @@ export async function getLatestAnews(count = 12): Promise { const data = await fetchGraphQL(query, { count }); return data.aNews?.nodes || []; // Исправлено: aNews вместо anews -} \ No newline at end of file +} + +// Получить ProfileArticle по databaseId +export async function getProfileArticleById(databaseId) { + const query = ` + query GetProfileArticleById($id: ID!) { + profileArticle(id: $id, idType: DATABASE_ID) { + id + databaseId + title + content + excerpt + uri + slug + date + modified + status + featuredImage { + node { + id + sourceUrl + altText + caption + mediaDetails { + width + height + } + } + } + author { + node { + id + name + firstName + lastName + avatar { + url + } + description + uri + } + } + categories { + nodes { + id + name + slug + uri + description + } + } + tags { + nodes { + id + name + slug + uri + } + } + } + } + `; + + const data = await fetchGraphQL(query, { id: databaseId }); + return data?.profileArticle || null; +} + + + + + + +/** + * Получить тег по slug + */ +export async function getTagBySlug(slug) { + const query = ` + query GetTagBySlug($slug: ID!) { + tag(id: $slug, idType: SLUG) { + id + databaseId + name + slug + description + count + seo { + title + metaDesc + canonical + opengraphTitle + opengraphDescription + opengraphImage { + sourceUrl + } + } + } + } + `; + + try { + const data = await fetchGraphQL(query, { slug }); + return data?.tag || null; + } catch (error) { + console.error('Error fetching tag:', error); + return null; + } +} + + + +/** + * Получить посты тега с пагинацией (offset-based) + */ +export async function getTagPostsPaginated(tagSlug, perPage = 12, page = 1) { + const offset = (page - 1) * perPage; + + const query = ` + query GetTagPostsPaginated($slug: ID!, $first: Int!, $offset: Int!) { + tag(id: $slug, idType: SLUG) { + id + databaseId + name + slug + description + count + seo { + title + metaDesc + canonical + } + posts( + first: $first + offset: $offset + where: { + orderby: { field: DATE, order: DESC } + } + ) { + nodes { + id + databaseId + title + excerpt + uri + slug + date + modified + featuredImage { + node { + sourceUrl(size: MEDIUM_LARGE) + altText + caption + mediaDetails { + width + height + } + } + } + author { + node { + id + name + firstName + lastName + avatar { + url + } + } + } + categories { + nodes { + id + name + slug + uri + } + } + tags { + nodes { + id + name + slug + } + } + seo { + title + metaDesc + } + } + pageInfo { + offsetPagination { + total + } + } + } + } + } + `; + + try { + const data = await fetchGraphQL(query, { + slug: tagSlug, + first: perPage, + offset + }); + + const tag = data?.tag; + + if (!tag) { + return { + tag: null, + posts: [], + total: 0, + totalPages: 0, + currentPage: page, + hasNext: false, + hasPrevious: false + }; + } + + const posts = tag.posts?.nodes || []; + const total = tag.posts?.pageInfo?.offsetPagination?.total || 0; + const totalPages = Math.ceil(total / perPage); + + return { + tag, + posts, + total, + totalPages, + currentPage: page, + hasNext: page < totalPages, + hasPrevious: page > 1, + perPage + }; + + } catch (error) { + console.error('Error fetching tag posts:', error); + return { + tag: null, + posts: [], + total: 0, + totalPages: 0, + currentPage: page, + hasNext: false, + hasPrevious: false + }; + } +} + +/** + * Получить общее количество постов для всех тегов + */ +export async function getAllTagsWithCount(first = 100) { + const query = ` + query GetAllTagsWithCount($first: Int!) { + tags(first: $first, where: { hideEmpty: true }) { + nodes { + id + databaseId + name + slug + count + posts(first: 1) { + pageInfo { + offsetPagination { + total + } + } + } + } + } + } + `; + + const data = await fetchGraphQL(query, { first }); + return data?.tags?.nodes || []; +} + + + + diff --git a/src/lib/utils/slugParser.js b/src/lib/utils/slugParser.js new file mode 100644 index 0000000..5d7632e --- /dev/null +++ b/src/lib/utils/slugParser.js @@ -0,0 +1,70 @@ +/** + * Функция для разбора slug тегов и авторов + */ + +export interface SlugParseResult { + slug: string; + page: number; +} + +export function slugParse(slug: string | string[]): SlugParseResult { + + // Если ничего нет + if (!slug) return { slug: '', page: 1 }; + + // Если массив + if (Array.isArray(slug)) { + if (slug.length === 0) return { slug: '', page: 1 }; + + // Берем последний элемент + const last = slug[slug.length - 1]; + const num = Number(last); + + // Если последний - положительное целое число + if (Number.isInteger(num) && num > 0) { + // Убираем номер страницы + const slugWithoutPage = slug.slice(0, -1); + return { + slug: slugWithoutPage.join('/'), + page: num + }; + } + + // Весь массив - это slug + return { + slug: slug.join('/'), + page: 1 + }; + } + + // Если строка + if (typeof slug === 'string') { + if (slug === '') return { slug: '', page: 1 }; + + // Делим строку + const parts = slug.split('/').filter(p => p !== ''); + if (parts.length === 0) return { slug: '', page: 1 }; + + // Смотрим на последнюю часть + const last = parts[parts.length - 1]; + const num = Number(last); + + if (Number.isInteger(num) && num > 0) { + // Убираем номер страницы + const slugWithoutPage = parts.slice(0, -1).join('/'); + return { + slug: slugWithoutPage, + page: num + }; + } + + // Вся строка - это slug + return { + slug: slug, + page: 1 + }; + } + + // Если другой тип + return { slug: '', page: 1 }; +} \ No newline at end of file diff --git a/src/pages/[category]/[...slug].astro b/src/pages/[category]/[...slug].astro new file mode 100644 index 0000000..37e4061 --- /dev/null +++ b/src/pages/[category]/[...slug].astro @@ -0,0 +1,109 @@ +--- + +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 eb1473c..2c95bf7 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,18 +1,18 @@ --- import { getSiteInfo } from "../lib/wp-api.js"; -import { getLatestPosts } from '../lib/api/posts.js'; +import { getLatestPosts } from '@api/posts.js'; const site = await getSiteInfo(); const initialPosts = await getLatestPosts(36); // Начальная загрузка 12 постов // визуальные компоненты -import MainLayout from '../layouts/MainLayout.astro'; -import ContentGrid from '../components/ContentGrid.astro'; -import EndnewsList from '../components/EndnewsList.astro'; +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 секунды -}; +//export const prerender = { +// isr: { expiration: 3 } // ISR: обновлять раз в 3 секунды +//}; --- + +

Current page:{currentPage}

+ +
\ No newline at end of file