core-maugli 1.2.4 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/img/default/autor_default.webp +0 -0
- package/public/img/default/blog_default.webp +0 -0
- package/public/img/default/default.webp +0 -0
- package/public/img/default/product_default.webp +0 -0
- package/public/img/default/project_default.webp +0 -0
- package/public/img/default/rubric_default.webp +0 -0
- package/public/img/default/test.webp +0 -0
- package/public/img/default/test2.webp +0 -0
- package/scripts/update-components.js +14 -5
- package/src/i18n/de.json +126 -0
- package/src/i18n/en.json +126 -0
- package/src/i18n/es.json +126 -0
- package/src/i18n/fr.json +126 -0
- package/src/i18n/index.ts +10 -0
- package/src/i18n/ja.json +126 -0
- package/src/i18n/languages.ts +23 -0
- package/src/i18n/pt.json +126 -0
- package/src/i18n/ru.json +123 -0
- package/src/i18n/zh.json +126 -0
- package/src/icons/ArrowLeft.astro +13 -0
- package/src/icons/ArrowRight.astro +13 -0
- package/src/icons/flags/brazil.svg +14 -0
- package/src/icons/flags/china.svg +15 -0
- package/src/icons/flags/france.svg +12 -0
- package/src/icons/flags/germany.svg +12 -0
- package/src/icons/flags/japan.svg +11 -0
- package/src/icons/flags/russia.svg +12 -0
- package/src/icons/flags/spain.svg +12 -0
- package/src/icons/flags/united arab emirates.svg +13 -0
- package/src/icons/flags/united states.svg +15 -0
- package/src/icons/socials/BlueskyIcon.astro +9 -0
- package/src/icons/socials/EmailIcon.astro +8 -0
- package/src/icons/socials/LinkedinIcon.astro +9 -0
- package/src/icons/socials/MastodonIcon.astro +9 -0
- package/src/icons/socials/MediumIcon.astro +9 -0
- package/src/icons/socials/RedditIcon.astro +11 -0
- package/src/icons/socials/TelegramIcon.astro +11 -0
- package/src/icons/socials/TwitterIcon.astro +9 -0
- package/src/layouts/BaseLayout.astro +59 -0
- package/src/pages/404.astro +24 -0
- package/src/pages/[...id].astro +50 -0
- package/src/pages/about.astro +0 -0
- package/src/pages/authors/[...page].astro +105 -0
- package/src/pages/authors/[id].astro +175 -0
- package/src/pages/blog/[...page].astro +59 -0
- package/src/pages/blog/[id].astro +175 -0
- package/src/pages/index.astro +90 -0
- package/src/pages/products/[...page].astro +50 -0
- package/src/pages/products/[id].astro +221 -0
- package/src/pages/projects/[...page].astro +74 -0
- package/src/pages/projects/[id].astro +165 -0
- package/src/pages/projects/tags/[id]/[...page].astro +58 -0
- package/src/pages/rss.xml.js +5 -0
- package/src/pages/tags/[id]/[...page].astro +110 -0
- package/src/pages/tags/index.astro +124 -0
- package/src/scripts/infoCardFadeIn.js +22 -0
- package/src/styles/global.css +273 -0
- package/src/utils/common-utils.ts +0 -0
- package/src/utils/content-loader.ts +14 -0
- package/src/utils/data-utils.ts +49 -0
- package/src/utils/featuredManager.ts +118 -0
- package/src/utils/posts.ts +43 -0
- package/src/utils/reading-time.ts +28 -0
- package/src/utils/remark-slugify.js +8 -0
- package/src/utils/rss.ts +23 -0
@@ -0,0 +1,175 @@
|
|
1
|
+
---
|
2
|
+
import { type CollectionEntry, render } from 'astro:content';
|
3
|
+
import { getFilteredCollection } from '../../utils/content-loader';
|
4
|
+
import ArticleMeta from '../../components/ArticleMeta.astro';
|
5
|
+
import ContentFooter from '../../components/ContentFooter.astro';
|
6
|
+
import HeroImage from '../../components/HeroImage.astro';
|
7
|
+
import PostPreview from '../../components/PostPreview.astro';
|
8
|
+
import ProductBannerCard from '../../components/ProductBannerCard.astro';
|
9
|
+
import Subscribe from '../../components/Subscribe.astro';
|
10
|
+
import SummaryFAQCard from '../../components/SummaryFAQCard.astro';
|
11
|
+
import TableOfContents from '../../components/TableOfContents.astro';
|
12
|
+
import TagsAndShare from '../../components/TagsAndShare.astro';
|
13
|
+
import { maugliConfig } from '../../config/maugli.config';
|
14
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
15
|
+
import { sortItemsByDateDesc } from '../../utils/data-utils';
|
16
|
+
import { calculateReadingTime } from '../../utils/reading-time';
|
17
|
+
|
18
|
+
import { LANGUAGES } from '../../i18n/languages';
|
19
|
+
const dicts: Record<string, any> = Object.fromEntries(LANGUAGES.map((l) => [l.code, l.dict]));
|
20
|
+
const lang: string = typeof maugliConfig.defaultLang === 'string' && dicts[maugliConfig.defaultLang] ? maugliConfig.defaultLang : 'en';
|
21
|
+
const dict = dicts[lang] || dicts['en'];
|
22
|
+
const morePostsTitle = dict.buttons?.morePosts || dicts['en'].buttons.morePosts;
|
23
|
+
|
24
|
+
export async function getStaticPaths() {
|
25
|
+
const posts = (await getFilteredCollection('blog')).sort(sortItemsByDateDesc);
|
26
|
+
const postCount = posts.length;
|
27
|
+
return posts.map((post, index) => ({
|
28
|
+
params: { id: post.id },
|
29
|
+
props: {
|
30
|
+
post,
|
31
|
+
prevPost: index + 1 !== postCount ? posts[index + 1] : null,
|
32
|
+
nextPost: index !== 0 ? posts[index - 1] : null
|
33
|
+
}
|
34
|
+
}));
|
35
|
+
}
|
36
|
+
|
37
|
+
type Props = { post: CollectionEntry<'blog'>; prevPost: CollectionEntry<'blog'>; nextPost: CollectionEntry<'blog'> };
|
38
|
+
|
39
|
+
const { href } = Astro.url;
|
40
|
+
const { post, prevPost, nextPost } = Astro.props;
|
41
|
+
const { title, publishDate, updatedDate, excerpt, tags = [], seo } = post.data;
|
42
|
+
const { Content, headings } = await render(post);
|
43
|
+
|
44
|
+
// Вычисляем время чтения на основе контента
|
45
|
+
const readingTime = calculateReadingTime(post.body || '');
|
46
|
+
|
47
|
+
// Преобразуем встроенные заголовки Astro в нужный формат
|
48
|
+
const tocItems = headings
|
49
|
+
.filter((heading) => heading.depth === 2 || heading.depth === 3) // Только H2 и H3
|
50
|
+
.map((heading) => ({
|
51
|
+
title: heading.text,
|
52
|
+
id: heading.slug
|
53
|
+
}));
|
54
|
+
|
55
|
+
// Получаем картинку для баннера по productID (если есть)
|
56
|
+
let bannerImage = { src: '/default.webp', alt: title };
|
57
|
+
if (post.data.productID) {
|
58
|
+
const allProducts = await getFilteredCollection('products');
|
59
|
+
const product = allProducts.find((p) => p.id === post.data.productID);
|
60
|
+
if (product && product.data.image) {
|
61
|
+
bannerImage = {
|
62
|
+
...product.data.image,
|
63
|
+
alt: product.data.image.alt || product.data.title || 'Product'
|
64
|
+
};
|
65
|
+
} else if (product) {
|
66
|
+
bannerImage = { src: '/default.webp', alt: product.data.title || 'Product' };
|
67
|
+
}
|
68
|
+
}
|
69
|
+
---
|
70
|
+
|
71
|
+
<BaseLayout
|
72
|
+
title={seo?.title ?? title}
|
73
|
+
description={seo?.description ?? excerpt}
|
74
|
+
image={seo?.image || post.data.image || { src: '/default.webp', alt: title }}
|
75
|
+
pageType="article"
|
76
|
+
showHeader={false}
|
77
|
+
fullWidth={true}
|
78
|
+
>
|
79
|
+
<!-- Расширяем контейнер для двухколоночной структуры -->
|
80
|
+
<div class="w-full max-w-none lg:max-w-[1280px] mx-auto lg:px-8 -mt-0">
|
81
|
+
<!-- Метаинформация над картинкой -->
|
82
|
+
|
83
|
+
<article class="mb-16 sm:mb-24 max-w-3xl mx-auto lg:max-w-none">
|
84
|
+
<header class="mb-8">
|
85
|
+
{
|
86
|
+
(post.data.seo?.image?.src || post.data.image?.src) && (
|
87
|
+
<HeroImage
|
88
|
+
src={post.data.seo?.image?.src || post.data.image?.src || ''}
|
89
|
+
alt={post.data.seo?.image?.alt || post.data.image?.alt || title}
|
90
|
+
caption={post.data.seo?.image?.caption || post.data.image?.caption}
|
91
|
+
width={post.data.seo?.image?.width || post.data.image?.width || 1200}
|
92
|
+
height={post.data.seo?.image?.height || post.data.image?.height || 630}
|
93
|
+
/>
|
94
|
+
)
|
95
|
+
}
|
96
|
+
<div class="px-4 mt-2 md:px-0">
|
97
|
+
<ArticleMeta publishDate={publishDate} readingTime={readingTime} post={post} />
|
98
|
+
</div>
|
99
|
+
<h1 class="mt-25 text-3xl px-4 md:px-0 font-serif font-[800] sm:text-5xl tracking-tight !leading-[1.05]" style="color: var(--text-heading)">
|
100
|
+
{title}
|
101
|
+
</h1>
|
102
|
+
</header>
|
103
|
+
</article>
|
104
|
+
|
105
|
+
<!-- 16-колоночная сетка -->
|
106
|
+
<div class="md:grid md:grid-cols-16 md:gap-9">
|
107
|
+
<!-- Боковая панель с Table of Contents -->
|
108
|
+
<aside class="hidden sm:block md:col-span-5 lg:col-span-5 min-h-screen">
|
109
|
+
<div class="sticky top-8">
|
110
|
+
{tocItems.length > 0 && <TableOfContents headings={tocItems} />}
|
111
|
+
<TagsAndShare tags={tags} shareUrl={href} title={title} basePath="/blog" />
|
112
|
+
{/* Баннер продукта, если есть productLink в посте */}
|
113
|
+
{
|
114
|
+
post.data.productLink && (
|
115
|
+
<div class="mt-8">
|
116
|
+
<ProductBannerCard
|
117
|
+
product={{
|
118
|
+
data: {
|
119
|
+
productLink: post.data.productLink,
|
120
|
+
image: bannerImage,
|
121
|
+
title: post.data.title || 'Продукт'
|
122
|
+
}
|
123
|
+
}}
|
124
|
+
/>
|
125
|
+
</div>
|
126
|
+
)
|
127
|
+
}
|
128
|
+
</div>
|
129
|
+
</aside>
|
130
|
+
|
131
|
+
<!-- Основной контент -->
|
132
|
+
<div class="md:col-span-11 px-4 md:px-0 h-auto lg:col-span-11">
|
133
|
+
<!-- Summary/FAQ в начале контента -->
|
134
|
+
{
|
135
|
+
post.data.generativeEngineOptimization?.generated &&
|
136
|
+
(post.data.generativeEngineOptimization.generated.summary ||
|
137
|
+
post.data.generativeEngineOptimization.generated.highlights?.length > 0 ||
|
138
|
+
post.data.generativeEngineOptimization.generated.faq?.length > 0) && (
|
139
|
+
<div class="not-prose mb-8">
|
140
|
+
<SummaryFAQCard
|
141
|
+
summary={post.data.generativeEngineOptimization.generated.summary}
|
142
|
+
highlights={post.data.generativeEngineOptimization.generated.highlights}
|
143
|
+
faq={post.data.generativeEngineOptimization.generated.faq}
|
144
|
+
/>
|
145
|
+
</div>
|
146
|
+
)
|
147
|
+
}
|
148
|
+
|
149
|
+
<!-- Контент с prose стилями -->
|
150
|
+
<div class="prose sm:prose-lg lg:prose-xl max-w-none">
|
151
|
+
<Content />
|
152
|
+
</div>
|
153
|
+
|
154
|
+
<!-- Блок тегов и кнопки поделиться под контентом (только на мобилке) -->
|
155
|
+
<ContentFooter tags={tags} shareUrl={href} title={title} basePath="/blog" productLink={post.data.productLink} />
|
156
|
+
|
157
|
+
{
|
158
|
+
(prevPost || nextPost) && (
|
159
|
+
<div class="my-16 sm:my-24">
|
160
|
+
<h2 class="mb-12 text-xl font-serif sm:mb-16 sm:text-2xl text-heading">{morePostsTitle}</h2>
|
161
|
+
<div class="max-w-[1280px] mx-auto">
|
162
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
|
163
|
+
{nextPost && <PostPreview post={nextPost} headingLevel="h3" />}
|
164
|
+
{prevPost && <PostPreview post={prevPost} headingLevel="h3" />}
|
165
|
+
</div>
|
166
|
+
</div>
|
167
|
+
</div>
|
168
|
+
)
|
169
|
+
}
|
170
|
+
|
171
|
+
{maugliConfig.subscribe?.enabled && <Subscribe class="my-16 sm:my-24" />}
|
172
|
+
</div>
|
173
|
+
</div>
|
174
|
+
</div>
|
175
|
+
</BaseLayout>
|
@@ -0,0 +1,90 @@
|
|
1
|
+
---
|
2
|
+
import { getEntry } from 'astro:content';
|
3
|
+
import { getFilteredCollection } from '../utils/content-loader';
|
4
|
+
import Button from '../components/Button.astro';
|
5
|
+
import InfoCard from '../components/InfoCard.astro';
|
6
|
+
import PostPreview from '../components/PostPreview.astro';
|
7
|
+
import Subscribe from '../components/Subscribe.astro';
|
8
|
+
import TagsSection from '../components/TagsSection.astro';
|
9
|
+
import { maugliConfig } from '../config/maugli.config';
|
10
|
+
import siteConfig from '../data/site-config';
|
11
|
+
import BaseLayout from '../layouts/BaseLayout.astro';
|
12
|
+
import { getAllTags, getPostsByTag, sortItemsWithFeaturedFirst } from '../utils/data-utils';
|
13
|
+
|
14
|
+
import { LANGUAGES } from '../i18n/languages';
|
15
|
+
|
16
|
+
const lang = typeof maugliConfig.defaultLang === 'string' ? maugliConfig.defaultLang : 'en';
|
17
|
+
const languageObj = LANGUAGES.find((l) => l.code === lang) || LANGUAGES.find((l) => l.code === 'en');
|
18
|
+
const fallbackDict = LANGUAGES.find((l) => l.code === 'en')?.dict || {};
|
19
|
+
const dict = languageObj?.dict && Object.keys(languageObj.dict).length > 0 ? languageObj.dict : fallbackDict;
|
20
|
+
const pages = (dict as any).pages || (fallbackDict as any).pages || {};
|
21
|
+
const buttons = (dict as any).buttons || (fallbackDict as any).buttons || {};
|
22
|
+
|
23
|
+
// Получаем все посты и показываем первые N постов
|
24
|
+
let allPosts = (await getFilteredCollection('blog')).sort(sortItemsWithFeaturedFirst);
|
25
|
+
const displayPosts = allPosts.slice(0, siteConfig.postsPerPage || 4);
|
26
|
+
|
27
|
+
// Получаем все рубрики
|
28
|
+
let allRubrics = await getFilteredCollection('tags');
|
29
|
+
const rubricSlugs = allRubrics.map((r) => r.data.slug);
|
30
|
+
|
31
|
+
// Получаем все теги с количеством постов и флагом рубрики
|
32
|
+
let tagsWithCount = getAllTags(allPosts).map((tag) => ({
|
33
|
+
id: tag.id,
|
34
|
+
name: tag.name,
|
35
|
+
count: getPostsByTag(allPosts, tag.id).length,
|
36
|
+
isRubric: rubricSlugs.includes(tag.id)
|
37
|
+
}));
|
38
|
+
if (maugliConfig.showOnlyRubricsTags) {
|
39
|
+
tagsWithCount = tagsWithCount.filter((tag) => tag.isRubric);
|
40
|
+
} else {
|
41
|
+
tagsWithCount = [...tagsWithCount.filter((tag) => tag.isRubric), ...tagsWithCount.filter((tag) => !tag.isRubric)];
|
42
|
+
}
|
43
|
+
|
44
|
+
// Проверяем, есть ли еще посты для пагинации
|
45
|
+
const hasMorePosts = allPosts.length > (siteConfig.postsPerPage || 4);
|
46
|
+
|
47
|
+
// Получаем данные для секции блога
|
48
|
+
const blogSection = await getEntry('pages', 'blog');
|
49
|
+
---
|
50
|
+
|
51
|
+
<BaseLayout
|
52
|
+
title={maugliConfig.indexTitle || pages.index?.title || ''}
|
53
|
+
description={pages.index?.description || ''}
|
54
|
+
image={{ src: '/tr-prewiew.png', alt: 'The preview of the site' }}
|
55
|
+
showHeader={false}
|
56
|
+
fullWidth={true}
|
57
|
+
>
|
58
|
+
<div class="max-w-[1280px] mx-auto">
|
59
|
+
<h1 class="mb-12 text-3xl leading-tight font-serif font-[800] sm:mb-16 sm:text-5xl" style="color: var(--text-heading)">
|
60
|
+
{pages.index?.articles || ''}
|
61
|
+
</h1>
|
62
|
+
<TagsSection tags={tagsWithCount} totalCount={allPosts.length} />
|
63
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 mb-16">
|
64
|
+
{displayPosts.map((post) => <PostPreview post={post} />)}
|
65
|
+
</div>
|
66
|
+
|
67
|
+
{/* Кнопка "Ещё статьи" если есть еще посты */}
|
68
|
+
{
|
69
|
+
hasMorePosts && (
|
70
|
+
<div class="text-center mb-8">
|
71
|
+
<Button href="/blog/2" style="color: var(--text-heading)">
|
72
|
+
{buttons.morePosts || ''}
|
73
|
+
</Button>
|
74
|
+
</div>
|
75
|
+
)
|
76
|
+
}
|
77
|
+
{
|
78
|
+
blogSection && (
|
79
|
+
<InfoCard
|
80
|
+
title={blogSection.data.title}
|
81
|
+
description={blogSection.body}
|
82
|
+
image={blogSection.data.image}
|
83
|
+
jsonld={blogSection.data.jsonld}
|
84
|
+
class="mb-16"
|
85
|
+
/>
|
86
|
+
)
|
87
|
+
}
|
88
|
+
</div>
|
89
|
+
{maugliConfig.subscribe?.enabled && <Subscribe class="my-16 sm:my-24" />}
|
90
|
+
</BaseLayout>
|
@@ -0,0 +1,50 @@
|
|
1
|
+
---
|
2
|
+
import type { GetStaticPathsOptions, Page } from 'astro';
|
3
|
+
import { getEntry, type CollectionEntry } from 'astro:content';
|
4
|
+
import { getFilteredCollection } from '../../utils/content-loader';
|
5
|
+
import Breadcrumbs from '../../components/Breadcrumbs.astro';
|
6
|
+
import InfoCard from '../../components/InfoCard.astro';
|
7
|
+
import Pagination from '../../components/Pagination.astro';
|
8
|
+
import ProductPreview from '../../components/ProductPreview.astro';
|
9
|
+
import { maugliConfig } from '../../config/maugli.config';
|
10
|
+
import siteConfig from '../../data/site-config';
|
11
|
+
import { LANGUAGES } from '../../i18n/languages';
|
12
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
13
|
+
|
14
|
+
const dicts: Record<string, any> = Object.fromEntries(LANGUAGES.map((l) => [l.code, l.dict]));
|
15
|
+
const lang: string = typeof maugliConfig.defaultLang === 'string' && dicts[maugliConfig.defaultLang] ? maugliConfig.defaultLang : 'en';
|
16
|
+
const dict = dicts[lang] || dicts['en'];
|
17
|
+
|
18
|
+
const productsTitle = maugliConfig.pageTitles?.products?.trim() || dict.pages?.products?.title || dicts['en'].pages.products.title;
|
19
|
+
const productsDescription = maugliConfig.productsDescription?.trim() || dict.pages?.products?.description || dicts['en'].pages.products.description;
|
20
|
+
|
21
|
+
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
22
|
+
const products = (await getFilteredCollection('products')).sort((a, b) => {
|
23
|
+
if (b.data.isFeatured !== a.data.isFeatured) return b.data.isFeatured - a.data.isFeatured;
|
24
|
+
return (b.data.publishDate?.getTime?.() || 0) - (a.data.publishDate?.getTime?.() || 0);
|
25
|
+
});
|
26
|
+
return paginate(products, { pageSize: siteConfig.productsPerPage || 6 });
|
27
|
+
}
|
28
|
+
|
29
|
+
type Props = { page: Page<CollectionEntry<'products'>> };
|
30
|
+
|
31
|
+
const { page } = Astro.props;
|
32
|
+
let productList = page.data;
|
33
|
+
const productsSection = await getEntry('pages', 'products');
|
34
|
+
---
|
35
|
+
|
36
|
+
<BaseLayout title={productsTitle} description={productsDescription} showHeader={false} fullWidth={true}>
|
37
|
+
<div class="max-w-[1280px] mx-auto">
|
38
|
+
<Breadcrumbs />
|
39
|
+
<h1 class="mb-12 text-3xl leading-tight font-serif font-[800] sm:mb-16 sm:text-5xl" style="color: var(--text-heading)">{productsTitle}</h1>
|
40
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 mb-16">
|
41
|
+
{productList.map((product) => <ProductPreview product={product} headingLevel="h2" />)}
|
42
|
+
</div>
|
43
|
+
{page.lastPage > 1 && <Pagination page={page} class="my-16 sm:my-24" />}
|
44
|
+
{
|
45
|
+
productsSection && (
|
46
|
+
<InfoCard title={productsSection.data.title} description={productsSection.body} jsonld={productsSection.data.jsonld} class="mt-16" />
|
47
|
+
)
|
48
|
+
}
|
49
|
+
</div>
|
50
|
+
</BaseLayout>
|
@@ -0,0 +1,221 @@
|
|
1
|
+
---
|
2
|
+
import { render, type CollectionEntry } from 'astro:content';
|
3
|
+
import { getFilteredCollection } from '../../utils/content-loader';
|
4
|
+
import ArticleMeta from '../../components/ArticleMeta.astro';
|
5
|
+
import Breadcrumbs from '../../components/Breadcrumbs.astro';
|
6
|
+
import ContentFooter from '../../components/ContentFooter.astro';
|
7
|
+
import HeroImage from '../../components/HeroImage.astro';
|
8
|
+
import PostPreview from '../../components/PostPreview.astro';
|
9
|
+
import ProjectPreview from '../../components/ProjectPreview.astro';
|
10
|
+
import SummaryFAQCard from '../../components/SummaryFAQCard.astro';
|
11
|
+
import TableOfContents from '../../components/TableOfContents.astro';
|
12
|
+
import TagsAndShare from '../../components/TagsAndShare.astro';
|
13
|
+
import { maugliConfig } from '../../config/maugli.config';
|
14
|
+
import { LANGUAGES } from '../../i18n/languages';
|
15
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
16
|
+
import { sortItemsByDateDesc } from '../../utils/data-utils';
|
17
|
+
import { calculateReadingTime } from '../../utils/reading-time';
|
18
|
+
|
19
|
+
const dicts: Record<string, any> = Object.fromEntries(LANGUAGES.map((l) => [l.code, l.dict]));
|
20
|
+
const lang: string = typeof maugliConfig.defaultLang === 'string' && dicts[maugliConfig.defaultLang] ? maugliConfig.defaultLang : 'en';
|
21
|
+
const dict = dicts[lang] || dicts['en'];
|
22
|
+
const articlesByTag = dict.pages?.productsId?.articlesByTag || dicts['en'].pages.productsId.articlesByTag;
|
23
|
+
const casesByTag = dict.pages?.productsId?.casesByTag || dicts['en'].pages.productsId.casesByTag;
|
24
|
+
|
25
|
+
export async function getStaticPaths() {
|
26
|
+
const products = (await getFilteredCollection('products')).sort(sortItemsByDateDesc);
|
27
|
+
return products.map((product) => ({
|
28
|
+
params: { id: product.id },
|
29
|
+
props: { product }
|
30
|
+
}));
|
31
|
+
}
|
32
|
+
|
33
|
+
type Props = {
|
34
|
+
product: CollectionEntry<'products'>;
|
35
|
+
};
|
36
|
+
|
37
|
+
const { href } = Astro.url;
|
38
|
+
const { product } = Astro.props;
|
39
|
+
const { title, description, seo, image, tags = [], publishDate } = product.data;
|
40
|
+
const { Content, headings } = await render(product);
|
41
|
+
|
42
|
+
// Вычисляем время чтения на основе контента
|
43
|
+
const readingTime = calculateReadingTime(product.body || '');
|
44
|
+
|
45
|
+
// Преобразуем встроенные заголовки Astro в нужный формат
|
46
|
+
const tocItems = headings
|
47
|
+
.filter((heading) => heading.depth === 2 || heading.depth === 3)
|
48
|
+
.map((heading) => ({
|
49
|
+
title: heading.text,
|
50
|
+
id: heading.slug
|
51
|
+
}));
|
52
|
+
|
53
|
+
// Получаем связанные статьи (blog) и кейсы (projects), где productID совпадает с productID продукта
|
54
|
+
const relatedPosts = (await getFilteredCollection('blog')).filter((post) => post.data.productID === product.data.productID);
|
55
|
+
const relatedProjects = (await getFilteredCollection('projects')).filter((project) => project.data.productID === product.data.productID);
|
56
|
+
|
57
|
+
// Сортировка: сначала featured, потом по дате
|
58
|
+
const sortByFeaturedAndDate = (a, b) => {
|
59
|
+
if ((b.data.isFeatured ? 1 : 0) !== (a.data.isFeatured ? 1 : 0)) return (b.data.isFeatured ? 1 : 0) - (a.data.isFeatured ? 1 : 0);
|
60
|
+
return (b.data.publishDate?.getTime?.() || 0) - (a.data.publishDate?.getTime?.() || 0);
|
61
|
+
};
|
62
|
+
|
63
|
+
const sortedPosts = relatedPosts.sort(sortByFeaturedAndDate);
|
64
|
+
const sortedProjects = relatedProjects.sort(sortByFeaturedAndDate);
|
65
|
+
const moreLabel = dict.buttons?.more || dicts['en'].buttons.more || 'More';
|
66
|
+
---
|
67
|
+
|
68
|
+
<BaseLayout title={seo?.title ?? title} description={seo?.description ?? description} image={seo?.image} pageType="article" showHeader={false} fullWidth={true}>
|
69
|
+
<div class="w-full max-w-none lg:max-w-[1280px] mx-auto lg:px-8 mt-2">
|
70
|
+
<div class="px-4 md:px-0">
|
71
|
+
<Breadcrumbs />
|
72
|
+
</div>
|
73
|
+
<article class="mb-16 sm:mb-24 max-w-3xl mx-auto lg:max-w-none">
|
74
|
+
{
|
75
|
+
(seo?.image?.src || image?.src) && (
|
76
|
+
<HeroImage
|
77
|
+
src={seo?.image?.src || image?.src}
|
78
|
+
alt={seo?.image?.alt || image?.alt || title || 'Продукт без названия'}
|
79
|
+
width={seo?.image?.width || image?.width || 1200}
|
80
|
+
height={seo?.image?.height || image?.height || 630}
|
81
|
+
caption={seo?.image?.caption || image?.caption}
|
82
|
+
/>
|
83
|
+
)
|
84
|
+
}
|
85
|
+
<div class="px-4 mt-2 md:px-0">
|
86
|
+
<ArticleMeta publishDate={publishDate} readingTime={readingTime} />
|
87
|
+
</div>
|
88
|
+
<header class="mb-8 px-4 md:px-0">
|
89
|
+
<h1 class="text-3xl leading-tight font-serif font-[800] sm:text-5xl sm:leading-tight" style="color: var(--text-heading)">{title}</h1>
|
90
|
+
{description && <p class="mt-4 text-lg text-muted leading-relaxed">{description}</p>}
|
91
|
+
</header>
|
92
|
+
</article>
|
93
|
+
<div class="md:grid md:grid-cols-16 md:gap-9">
|
94
|
+
<aside class="hidden sm:block md:col-span-5 lg:col-span-5 min-h-screen">
|
95
|
+
<div class="sticky top-8">
|
96
|
+
{tocItems.length > 0 && <TableOfContents headings={tocItems} />}
|
97
|
+
<TagsAndShare tags={tags} shareUrl={href} title={title} />
|
98
|
+
</div>
|
99
|
+
</aside>
|
100
|
+
<div class="md:col-span-11 px-4 md:px-0 h-auto lg:col-span-11">
|
101
|
+
{/* Summary/FAQ для продукта */}
|
102
|
+
{
|
103
|
+
product.data.generativeEngineOptimization?.generated &&
|
104
|
+
(product.data.generativeEngineOptimization.generated.summary ||
|
105
|
+
product.data.generativeEngineOptimization.generated.highlights?.length > 0 ||
|
106
|
+
product.data.generativeEngineOptimization.generated.faq?.length > 0) && (
|
107
|
+
<div class="not-prose mb-8">
|
108
|
+
<SummaryFAQCard
|
109
|
+
summary={product.data.generativeEngineOptimization.generated.summary}
|
110
|
+
highlights={product.data.generativeEngineOptimization.generated.highlights}
|
111
|
+
faq={product.data.generativeEngineOptimization.generated.faq}
|
112
|
+
/>
|
113
|
+
</div>
|
114
|
+
)
|
115
|
+
}
|
116
|
+
<div class="prose sm:prose-lg lg:prose-xl max-w-none">
|
117
|
+
<Content />
|
118
|
+
</div>
|
119
|
+
<ContentFooter tags={tags} shareUrl={href} title={title} basePath="/products" />
|
120
|
+
{/* Связанные статьи */}
|
121
|
+
{
|
122
|
+
sortedPosts.length > 0 && (
|
123
|
+
<div class="my-16">
|
124
|
+
<h2 class="mb-6 text-xl font-serif text-heading">{articlesByTag}</h2>
|
125
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
126
|
+
{sortedPosts.slice(0, 2).map((post) => (
|
127
|
+
<PostPreview post={post} />
|
128
|
+
))}
|
129
|
+
</div>
|
130
|
+
{sortedPosts.length > 2 && (
|
131
|
+
<details class="mt-4 group">
|
132
|
+
<summary class="cursor-pointer text-brand font-semibold flex items-center gap-2">
|
133
|
+
{moreLabel} ({sortedPosts.length - 2})
|
134
|
+
<svg
|
135
|
+
class="transition-transform duration-300 group-open:rotate-90"
|
136
|
+
width="16"
|
137
|
+
height="16"
|
138
|
+
viewBox="0 0 16 16"
|
139
|
+
fill="none"
|
140
|
+
xmlns="http://www.w3.org/2000/svg"
|
141
|
+
>
|
142
|
+
<g clip-path="url(#clip0_3589_7018)">
|
143
|
+
<path
|
144
|
+
d="M6.16116 0.382763L12.6315 7.09276C13.1219 7.60137 13.122 8.42598 12.6315 8.93458L12.6136 8.95319C12.1231 9.4618 11.328 9.4618 10.8375 8.95319L4.36717 2.24319C3.87673 1.73459 3.87673 0.909975 4.36717 0.40137L4.38511 0.382763C4.87555 -0.125843 5.67071 -0.125842 6.16116 0.382763Z"
|
145
|
+
fill="var(--brand-color)"
|
146
|
+
/>
|
147
|
+
<path
|
148
|
+
d="M12.6136 7.04876L12.6315 7.06737C13.122 7.57597 13.122 8.40059 12.6315 8.90919L6.16116 15.6192C5.67071 16.1278 4.87555 16.1278 4.38511 15.6192L4.36717 15.6006C3.87673 15.092 3.87673 14.2674 4.36717 13.7588L10.8375 7.04876C11.328 6.54016 12.1231 6.54016 12.6136 7.04876Z"
|
149
|
+
fill="var(--brand-color)"
|
150
|
+
/>
|
151
|
+
</g>
|
152
|
+
<defs>
|
153
|
+
<clipPath id="clip0_3589_7018">
|
154
|
+
<rect width="9" height="16" fill="white" transform="matrix(-1 0 0 1 13 0)" />
|
155
|
+
</clipPath>
|
156
|
+
</defs>
|
157
|
+
</svg>
|
158
|
+
</summary>
|
159
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
160
|
+
{sortedPosts.slice(2).map((post) => (
|
161
|
+
<PostPreview post={post} />
|
162
|
+
))}
|
163
|
+
</div>
|
164
|
+
</details>
|
165
|
+
)}
|
166
|
+
</div>
|
167
|
+
)
|
168
|
+
}
|
169
|
+
{/* Связанные кейсы */}
|
170
|
+
{
|
171
|
+
sortedProjects.length > 0 && (
|
172
|
+
<div class="my-16">
|
173
|
+
<h2 class="mb-6 text-xl font-serif text-heading">{casesByTag}</h2>
|
174
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
175
|
+
{sortedProjects.slice(0, 2).map((project) => (
|
176
|
+
<ProjectPreview project={project} />
|
177
|
+
))}
|
178
|
+
</div>
|
179
|
+
{sortedProjects.length > 2 && (
|
180
|
+
<details class="mt-4 group">
|
181
|
+
<summary class="cursor-pointer text-brand font-semibold flex items-center gap-2">
|
182
|
+
{moreLabel} ({sortedProjects.length - 2})
|
183
|
+
<svg
|
184
|
+
class="transition-transform duration-300 group-open:rotate-90"
|
185
|
+
width="16"
|
186
|
+
height="16"
|
187
|
+
viewBox="0 0 16 16"
|
188
|
+
fill="none"
|
189
|
+
xmlns="http://www.w3.org/2000/svg"
|
190
|
+
>
|
191
|
+
<g clip-path="url(#clip0_3589_7018)">
|
192
|
+
<path
|
193
|
+
d="M6.16116 0.382763L12.6315 7.09276C13.1219 7.60137 13.122 8.42598 12.6315 8.93458L12.6136 8.95319C12.1231 9.4618 11.328 9.4618 10.8375 8.95319L4.36717 2.24319C3.87673 1.73459 3.87673 0.909975 4.36717 0.40137L4.38511 0.382763C4.87555 -0.125843 5.67071 -0.125842 6.16116 0.382763Z"
|
194
|
+
fill="var(--brand-color)"
|
195
|
+
/>
|
196
|
+
<path
|
197
|
+
d="M12.6136 7.04876L12.6315 7.06737C13.122 7.57597 13.122 8.40059 12.6315 8.90919L6.16116 15.6192C5.67071 16.1278 4.87555 16.1278 4.38511 15.6192L4.36717 15.6006C3.87673 15.092 3.87673 14.2674 4.36717 13.7588L10.8375 7.04876C11.328 6.54016 12.1231 6.54016 12.6136 7.04876Z"
|
198
|
+
fill="var(--brand-color)"
|
199
|
+
/>
|
200
|
+
</g>
|
201
|
+
<defs>
|
202
|
+
<clipPath id="clip0_3589_7018">
|
203
|
+
<rect width="9" height="16" fill="white" transform="matrix(-1 0 0 1 13 0)" />
|
204
|
+
</clipPath>
|
205
|
+
</defs>
|
206
|
+
</svg>
|
207
|
+
</summary>
|
208
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
209
|
+
{sortedProjects.slice(2).map((project) => (
|
210
|
+
<ProjectPreview project={project} />
|
211
|
+
))}
|
212
|
+
</div>
|
213
|
+
</details>
|
214
|
+
)}
|
215
|
+
</div>
|
216
|
+
)
|
217
|
+
}
|
218
|
+
</div>
|
219
|
+
</div>
|
220
|
+
</div>
|
221
|
+
</BaseLayout>
|
@@ -0,0 +1,74 @@
|
|
1
|
+
---
|
2
|
+
import type { GetStaticPathsOptions, Page } from 'astro';
|
3
|
+
import { getEntry, type CollectionEntry } from 'astro:content';
|
4
|
+
import { getFilteredCollection } from '../../utils/content-loader';
|
5
|
+
import Breadcrumbs from '../../components/Breadcrumbs.astro';
|
6
|
+
import InfoCard from '../../components/InfoCard.astro';
|
7
|
+
import Pagination from '../../components/Pagination.astro';
|
8
|
+
import ProjectPreview from '../../components/ProjectPreview.astro';
|
9
|
+
import TagsSection from '../../components/TagsSection.astro';
|
10
|
+
import { maugliConfig } from '../../config/maugli.config';
|
11
|
+
import siteConfig from '../../data/site-config';
|
12
|
+
import { LANGUAGES } from '../../i18n/languages';
|
13
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
14
|
+
import { slugify } from '../../utils/common-utils';
|
15
|
+
import { sortItemsByDateDesc } from '../../utils/data-utils';
|
16
|
+
|
17
|
+
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
18
|
+
const projects = (await getFilteredCollection('projects')).sort(sortItemsByDateDesc);
|
19
|
+
return paginate(projects, { pageSize: siteConfig.projectsPerPage || 6 });
|
20
|
+
}
|
21
|
+
|
22
|
+
type Props = { page: Page<CollectionEntry<'projects'>> };
|
23
|
+
|
24
|
+
const { page } = Astro.props;
|
25
|
+
let portfolio = page.data;
|
26
|
+
let allProjects = await getFilteredCollection('projects');
|
27
|
+
// Получаем все уникальные теги проектов
|
28
|
+
const allTags = [...new Set(allProjects.flatMap((project) => project.data.tags || []))];
|
29
|
+
const tagsWithCount = allTags.map((tag) => ({
|
30
|
+
id: slugify(tag),
|
31
|
+
name: tag,
|
32
|
+
count: allProjects.filter((project) => (project.data.tags || []).includes(tag)).length
|
33
|
+
}));
|
34
|
+
const projectsSection = await getEntry('pages', 'projects');
|
35
|
+
|
36
|
+
const dicts: Record<string, any> = Object.fromEntries(LANGUAGES.map((l) => [l.code, l.dict]));
|
37
|
+
const lang: string = typeof maugliConfig.defaultLang === 'string' && dicts[maugliConfig.defaultLang] ? maugliConfig.defaultLang : 'en';
|
38
|
+
const dict = dicts[lang] || dicts['en'];
|
39
|
+
|
40
|
+
// Получаем мультиязычный title для проектов (кейсов)
|
41
|
+
const projectsTitle = maugliConfig.pageTitles?.projects?.trim() || dict.pages?.projects?.title || dicts['en'].pages.projects.title;
|
42
|
+
// Описание для проектов (кейсов) — если появится в словаре или конфиге
|
43
|
+
const projectsDescription = maugliConfig.pageTitles?.projectsDescription?.trim?.() || '';
|
44
|
+
---
|
45
|
+
|
46
|
+
<BaseLayout
|
47
|
+
title={projectsTitle}
|
48
|
+
description={projectsDescription}
|
49
|
+
image={{ src: '/dante-preview.jpg', alt: 'The preview of the site' }}
|
50
|
+
showHeader={false}
|
51
|
+
fullWidth={true}
|
52
|
+
>
|
53
|
+
<div class="max-w-[1280px] mx-auto">
|
54
|
+
<Breadcrumbs />
|
55
|
+
|
56
|
+
<h1 class="mb-12 text-3xl leading-tight font-serif font-[800] sm:mb-16 sm:text-5xl" style="color: var(--text-heading)">{projectsTitle}</h1>
|
57
|
+
|
58
|
+
<!-- Блок тегов -->
|
59
|
+
{tagsWithCount.length > 0 && <TagsSection tags={tagsWithCount} basePath="/projects" totalCount={allProjects.length} class="mb-12" />}
|
60
|
+
|
61
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 mb-16">
|
62
|
+
{portfolio.map((project) => <ProjectPreview project={project} />)}
|
63
|
+
</div>
|
64
|
+
|
65
|
+
<!-- Пагинация (показывается только если больше одной страницы) -->
|
66
|
+
{page.lastPage > 1 && <Pagination page={page} class="my-16 sm:my-24" />}
|
67
|
+
|
68
|
+
{
|
69
|
+
projectsSection && (
|
70
|
+
<InfoCard title={projectsSection.data.title} description={projectsSection.body} jsonld={projectsSection.data.jsonld} class="mt-16" />
|
71
|
+
)
|
72
|
+
}
|
73
|
+
</div>
|
74
|
+
</BaseLayout>
|