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,165 @@
|
|
1
|
+
---
|
2
|
+
import { getCollection, render, type CollectionEntry } from 'astro:content';
|
3
|
+
import ArticleMeta from '../../components/ArticleMeta.astro';
|
4
|
+
import Breadcrumbs from '../../components/Breadcrumbs.astro';
|
5
|
+
import ContentFooter from '../../components/ContentFooter.astro';
|
6
|
+
import HeroImage from '../../components/HeroImage.astro';
|
7
|
+
import ProductBannerCard from '../../components/ProductBannerCard.astro';
|
8
|
+
import ProjectPreview from '../../components/ProjectPreview.astro';
|
9
|
+
import SummaryFAQCard from '../../components/SummaryFAQCard.astro';
|
10
|
+
import TableOfContents from '../../components/TableOfContents.astro';
|
11
|
+
import TagsAndShare from '../../components/TagsAndShare.astro';
|
12
|
+
import { maugliConfig } from '../../config/maugli.config';
|
13
|
+
import { LANGUAGES } from '../../i18n/languages';
|
14
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
15
|
+
import { sortItemsByDateDesc } from '../../utils/data-utils';
|
16
|
+
import { calculateReadingTime } from '../../utils/reading-time';
|
17
|
+
|
18
|
+
export async function getStaticPaths() {
|
19
|
+
const projects = (await getCollection('projects')).sort(sortItemsByDateDesc);
|
20
|
+
const projectCount = projects.length;
|
21
|
+
return projects.map((project, index) => ({
|
22
|
+
params: { id: project.id },
|
23
|
+
props: {
|
24
|
+
project,
|
25
|
+
prevProject: index + 1 !== projectCount ? projects[index + 1] : null,
|
26
|
+
nextProject: index !== 0 ? projects[index - 1] : null
|
27
|
+
}
|
28
|
+
}));
|
29
|
+
}
|
30
|
+
|
31
|
+
type Props = {
|
32
|
+
project: CollectionEntry<'projects'>;
|
33
|
+
prevProject: CollectionEntry<'projects'>;
|
34
|
+
nextProject: CollectionEntry<'projects'>;
|
35
|
+
};
|
36
|
+
|
37
|
+
const { href } = Astro.url;
|
38
|
+
const { project, prevProject, nextProject } = Astro.props;
|
39
|
+
const { title, description, seo, image, tags = [], publishDate } = project.data;
|
40
|
+
const { Content, headings } = await render(project);
|
41
|
+
|
42
|
+
// Вычисляем время чтения на основе контента
|
43
|
+
const readingTime = calculateReadingTime(project.body || '');
|
44
|
+
|
45
|
+
// Преобразуем встроенные заголовки Astro в нужный формат
|
46
|
+
const tocItems = headings
|
47
|
+
.filter((heading) => heading.depth === 2 || heading.depth === 3) // Только H2 и H3
|
48
|
+
.map((heading) => ({
|
49
|
+
title: heading.text,
|
50
|
+
id: heading.slug
|
51
|
+
}));
|
52
|
+
|
53
|
+
const productIds = project.data.products || [];
|
54
|
+
const allProducts = await getCollection('products');
|
55
|
+
const products = productIds.map((id) => allProducts.find((p) => p.id === id)).filter(Boolean);
|
56
|
+
|
57
|
+
const lang: string = typeof maugliConfig.defaultLang === 'string' ? maugliConfig.defaultLang : 'en';
|
58
|
+
const languageObj = LANGUAGES.find((l) => l.code === lang) || LANGUAGES.find((l) => l.code === 'en');
|
59
|
+
const fallbackDict = LANGUAGES.find((l) => l.code === 'en')?.dict || {};
|
60
|
+
const dict = languageObj?.dict && Object.keys(languageObj.dict).length > 0 ? languageObj.dict : fallbackDict;
|
61
|
+
const pages = (dict as any).pages || (fallbackDict as any).pages || {};
|
62
|
+
const moreByTag = pages.projects?.moreByTag || 'More cases';
|
63
|
+
---
|
64
|
+
|
65
|
+
<BaseLayout title={seo?.title ?? title} description={seo?.description ?? description} image={seo?.image} pageType="article" showHeader={false} fullWidth={true}>
|
66
|
+
<!-- Расширяем контейнер для двухколоночной структуры -->
|
67
|
+
<div class="w-full max-w-none lg:max-w-[1280px] mx-auto lg:px-8 mt-2">
|
68
|
+
<!-- Хлебные крошки -->
|
69
|
+
<div class="px-4 md:px-0">
|
70
|
+
<Breadcrumbs />
|
71
|
+
</div>
|
72
|
+
|
73
|
+
<!-- Метаинформация над картинкой -->
|
74
|
+
|
75
|
+
<article class="mb-16 sm:mb-24 max-w-3xl mx-auto lg:max-w-none">
|
76
|
+
<HeroImage
|
77
|
+
src={seo?.image?.src || image?.src || '/default.webp'}
|
78
|
+
alt={seo?.image?.alt || image?.alt || title || 'No name project'}
|
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
|
+
<div class="px-4 mt-2 md:px-0">
|
84
|
+
<ArticleMeta publishDate={publishDate} readingTime={readingTime} />
|
85
|
+
</div>
|
86
|
+
|
87
|
+
<header class="mb-8 px-4 mt-12 md:px-0">
|
88
|
+
<h1 class="text-3xl leading-tight font-serif font-[800] sm:text-5xl sm:leading-tight" style="color: var(--text-heading)">{title}</h1>
|
89
|
+
{description && <p class="mt-4 text-lg text-muted leading-relaxed">{description}</p>}
|
90
|
+
</header>
|
91
|
+
</article>
|
92
|
+
|
93
|
+
<!-- 16-колоночная сетка -->
|
94
|
+
<div class="md:grid md:grid-cols-16 md:gap-9">
|
95
|
+
<!-- Боковая панель с Table of Contents -->
|
96
|
+
<aside class="hidden sm:block md:col-span-5 lg:col-span-5 min-h-screen">
|
97
|
+
<div class="sticky top-8">
|
98
|
+
{tocItems.length > 0 && <TableOfContents headings={tocItems} />}
|
99
|
+
<TagsAndShare tags={tags} shareUrl={href} title={title} />
|
100
|
+
{/* Карточка продукта: показываем если есть productLink в проекте */}
|
101
|
+
{
|
102
|
+
project.data.productLink && (
|
103
|
+
<div class="mt-1">
|
104
|
+
<ProductBannerCard
|
105
|
+
product={{
|
106
|
+
data: {
|
107
|
+
productLink: project.data.productLink,
|
108
|
+
image: {
|
109
|
+
src: project.data.productID ? `/public/${project.data.productID}.webp` : maugliConfig.seo.defaultImage,
|
110
|
+
alt: project.data.title || 'Продукт'
|
111
|
+
},
|
112
|
+
title: project.data.title || 'Продукт'
|
113
|
+
}
|
114
|
+
}}
|
115
|
+
/>
|
116
|
+
</div>
|
117
|
+
)
|
118
|
+
}
|
119
|
+
</div>
|
120
|
+
</aside>
|
121
|
+
|
122
|
+
<!-- Основной контент -->
|
123
|
+
<div class="md:col-span-11 px-4 md:px-0 h-auto lg:col-span-11">
|
124
|
+
<!-- Summary/FAQ в начале контента -->
|
125
|
+
{
|
126
|
+
project.data.generativeEngineOptimization?.generated &&
|
127
|
+
(project.data.generativeEngineOptimization.generated.summary ||
|
128
|
+
project.data.generativeEngineOptimization.generated.highlights?.length > 0 ||
|
129
|
+
project.data.generativeEngineOptimization.generated.faq?.length > 0) && (
|
130
|
+
<div class="not-prose mb-8">
|
131
|
+
<SummaryFAQCard
|
132
|
+
summary={project.data.generativeEngineOptimization.generated.summary}
|
133
|
+
highlights={project.data.generativeEngineOptimization.generated.highlights}
|
134
|
+
faq={project.data.generativeEngineOptimization.generated.faq}
|
135
|
+
/>
|
136
|
+
</div>
|
137
|
+
)
|
138
|
+
}
|
139
|
+
|
140
|
+
<div class="prose sm:prose-lg lg:prose-xl max-w-none">
|
141
|
+
<Content />
|
142
|
+
</div>
|
143
|
+
|
144
|
+
<!-- Блок тегов и кнопки поделиться под контентом -->
|
145
|
+
<ContentFooter tags={tags} shareUrl={href} title={title} basePath="/projects" class="hidden sm:block" />
|
146
|
+
|
147
|
+
{/* Мобильный блок: поделиться и "Больше о продукте" в один ряд */}
|
148
|
+
<ContentFooter tags={tags} shareUrl={href} title={title} basePath="/projects" class="sm:hidden" productLink={project.data.productLink} />
|
149
|
+
|
150
|
+
<!-- Блок "Еще по теме" в контенте -->
|
151
|
+
{
|
152
|
+
(prevProject || nextProject) && (
|
153
|
+
<div class="my-16 sm:my-24">
|
154
|
+
<h2 class="mb-12 text-xl font-serif sm:mb-16 sm:text-2xl text-heading">{moreByTag}</h2>
|
155
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
|
156
|
+
{nextProject && <ProjectPreview project={nextProject} headingLevel="h3" />}
|
157
|
+
{prevProject && <ProjectPreview project={prevProject} headingLevel="h3" />}
|
158
|
+
</div>
|
159
|
+
</div>
|
160
|
+
)
|
161
|
+
}
|
162
|
+
</div>
|
163
|
+
</div>
|
164
|
+
</div>
|
165
|
+
</BaseLayout>
|
@@ -0,0 +1,58 @@
|
|
1
|
+
---
|
2
|
+
import type { GetStaticPathsOptions, Page } from 'astro';
|
3
|
+
import { getCollection, type CollectionEntry } from 'astro:content';
|
4
|
+
import Breadcrumbs from '../../../../components/Breadcrumbs.astro';
|
5
|
+
import Pagination from '../../../../components/Pagination.astro';
|
6
|
+
import ProjectPreview from '../../../../components/ProjectPreview.astro';
|
7
|
+
import TagsSection from '../../../../components/TagsSection.astro';
|
8
|
+
import siteConfig from '../../../../data/site-config';
|
9
|
+
import BaseLayout from '../../../../layouts/BaseLayout.astro';
|
10
|
+
import { slugify } from '../../../../utils/common-utils';
|
11
|
+
import { sortItemsByDateDesc } from '../../../../utils/data-utils';
|
12
|
+
|
13
|
+
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
14
|
+
const allProjects = (await getCollection('projects')).sort(sortItemsByDateDesc);
|
15
|
+
const allTags = [...new Set(allProjects.flatMap((project) => project.data.tags || []))];
|
16
|
+
|
17
|
+
return allTags.flatMap((tag) => {
|
18
|
+
const filteredProjects = allProjects.filter((project) => (project.data.tags || []).includes(tag));
|
19
|
+
return paginate(filteredProjects, {
|
20
|
+
params: { id: slugify(tag) },
|
21
|
+
pageSize: siteConfig.projectsPerPage || 6,
|
22
|
+
props: { tag }
|
23
|
+
});
|
24
|
+
});
|
25
|
+
}
|
26
|
+
|
27
|
+
type Props = { page: Page<CollectionEntry<'projects'>>; tag: string };
|
28
|
+
|
29
|
+
const { page, tag } = Astro.props;
|
30
|
+
const projects = page.data;
|
31
|
+
|
32
|
+
// Получаем все теги для отображения в TagsSection
|
33
|
+
const allProjects = await getCollection('projects');
|
34
|
+
const allTags = [...new Set(allProjects.flatMap((project) => project.data.tags || []))];
|
35
|
+
const tagsWithCount = allTags.map((tagName) => ({
|
36
|
+
id: slugify(tagName),
|
37
|
+
name: tagName,
|
38
|
+
count: allProjects.filter((project) => (project.data.tags || []).includes(tagName)).length
|
39
|
+
}));
|
40
|
+
---
|
41
|
+
|
42
|
+
<BaseLayout title={`Сервисы по тегу "${tag}"`} description={`Explore projects tagged with "${tag}"`} showHeader={false} fullWidth={true}>
|
43
|
+
<div class="max-w-[1280px] mx-auto">
|
44
|
+
<Breadcrumbs />
|
45
|
+
|
46
|
+
<h1 class="mb-12 text-3xl leading-tight font-serif font-[800] sm:mb-16 sm:text-5xl" style="color: var(--text-heading)">Сервисы</h1>
|
47
|
+
|
48
|
+
<!-- Блок тегов -->
|
49
|
+
<TagsSection tags={tagsWithCount} activeTagId={slugify(tag)} basePath="/projects" totalCount={allProjects.length} class="mb-12" />
|
50
|
+
|
51
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 mb-16">
|
52
|
+
{projects.map((project) => <ProjectPreview project={project} />)}
|
53
|
+
</div>
|
54
|
+
|
55
|
+
<!-- Пагинация (показывается только если больше одной страницы) -->
|
56
|
+
{page.lastPage > 1 && <Pagination page={page} class="my-16 sm:my-24" />}
|
57
|
+
</div>
|
58
|
+
</BaseLayout>
|
@@ -0,0 +1,110 @@
|
|
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 Pagination from '../../../components/Pagination.astro';
|
7
|
+
import PostPreview from '../../../components/PostPreview.astro';
|
8
|
+
import Subscribe from '../../../components/Subscribe.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 { getAllTags, getPostsByTag, sortItemsWithFeaturedFirst } from '../../../utils/data-utils';
|
15
|
+
|
16
|
+
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
17
|
+
const posts = (await getFilteredCollection('blog')).sort(sortItemsWithFeaturedFirst);
|
18
|
+
const tags = getAllTags(posts);
|
19
|
+
|
20
|
+
return tags.flatMap((tag) => {
|
21
|
+
const filteredPosts = getPostsByTag(posts, tag.id);
|
22
|
+
return paginate(filteredPosts, {
|
23
|
+
params: { id: tag.id },
|
24
|
+
pageSize: siteConfig.postsPerPage || 4
|
25
|
+
});
|
26
|
+
});
|
27
|
+
}
|
28
|
+
|
29
|
+
type Props = { page: Page<CollectionEntry<'blog'>> };
|
30
|
+
|
31
|
+
const { page } = Astro.props;
|
32
|
+
const blog = page.data;
|
33
|
+
const params = Astro.params;
|
34
|
+
const allPosts = await getFilteredCollection('blog');
|
35
|
+
const allTags = getAllTags(allPosts);
|
36
|
+
const allRubrics = await getFilteredCollection('tags');
|
37
|
+
const currentTag = allTags.find((tag) => tag.id === params.id);
|
38
|
+
|
39
|
+
// Получаем все теги с количеством постов для TagsSection
|
40
|
+
let tagsWithCount = allTags.map((tag) => {
|
41
|
+
// Проверяем, есть ли карточка рубрики для этого тега
|
42
|
+
const hasRubricCard = allRubrics.some((rubric) => rubric.data.slug === tag.id);
|
43
|
+
return {
|
44
|
+
id: tag.id,
|
45
|
+
name: tag.name,
|
46
|
+
count: getPostsByTag(allPosts, tag.id).length,
|
47
|
+
isRubric: hasRubricCard
|
48
|
+
};
|
49
|
+
});
|
50
|
+
|
51
|
+
tagsWithCount = [...tagsWithCount.filter((tag) => tag.isRubric), ...tagsWithCount.filter((tag) => !tag.isRubric)];
|
52
|
+
|
53
|
+
// Получаем рубрику из коллекции tags (если есть)
|
54
|
+
const rubric = allRubrics.find((tag) => tag.data.slug === params.id && tag.data.isRubric);
|
55
|
+
let rubricCard = null;
|
56
|
+
if (rubric) {
|
57
|
+
const postCount = getPostsByTag(allPosts, rubric.data.slug).length;
|
58
|
+
const lastPost = allPosts
|
59
|
+
.filter((post) => (post.data.tags || []).includes(rubric.data.slug))
|
60
|
+
.sort((a, b) => new Date(b.data.updatedDate || b.data.publishDate).getTime() - new Date(a.data.updatedDate || a.data.publishDate).getTime())[0];
|
61
|
+
const updatedAt = lastPost ? lastPost.data.updatedDate || lastPost.data.publishDate : undefined;
|
62
|
+
rubricCard = {
|
63
|
+
slug: rubric.data.slug,
|
64
|
+
title: rubric.data.title,
|
65
|
+
description: rubric.data.description,
|
66
|
+
image: rubric.data.image,
|
67
|
+
postCount,
|
68
|
+
updatedAt,
|
69
|
+
quote: rubric.data.quote,
|
70
|
+
isFeatured: rubric.data.isFeatured
|
71
|
+
};
|
72
|
+
}
|
73
|
+
const rubricsSection = await getEntry('pages', 'rubrics');
|
74
|
+
|
75
|
+
const dicts: Record<string, any> = Object.fromEntries(LANGUAGES.map((l) => [l.code, l.dict]));
|
76
|
+
const lang: string = typeof maugliConfig.defaultLang === 'string' && dicts[maugliConfig.defaultLang] ? maugliConfig.defaultLang : 'en';
|
77
|
+
const dict = dicts[lang] || dicts['en'];
|
78
|
+
|
79
|
+
function interpolate(str, vars) {
|
80
|
+
return str.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? '');
|
81
|
+
}
|
82
|
+
|
83
|
+
const tagName = rubricCard ? rubricCard.title : currentTag?.name;
|
84
|
+
const titleTemplate = dict.pages?.tags?.titleWithTag || dicts['en'].pages.tags.titleWithTag || 'Posts tagged {tag}';
|
85
|
+
const descTemplate =
|
86
|
+
dict.pages?.tags?.descriptionWithTag || dicts['en'].pages.tags.descriptionWithTag || 'Explore a curated collection of blog posts under {tag}';
|
87
|
+
const pageTitle = interpolate(titleTemplate, { tag: tagName });
|
88
|
+
const pageDesc = interpolate(descTemplate, { tag: tagName });
|
89
|
+
---
|
90
|
+
|
91
|
+
<BaseLayout title={pageTitle} description={pageDesc} image={{ src: '/dante-preview.jpg', alt: 'The preview of the site' }} showHeader={false} fullWidth={true}>
|
92
|
+
<div class="max-w-[1280px] mx-auto">
|
93
|
+
<Breadcrumbs />
|
94
|
+
|
95
|
+
<div class="mb-12 sm:mb-16">
|
96
|
+
<h1 class="mb-4 text-3xl leading-tight font-serif font-[800] sm:text-5xl" style="color: var(--text-heading)">
|
97
|
+
{currentTag?.name}
|
98
|
+
</h1>
|
99
|
+
</div>
|
100
|
+
<TagsSection tags={tagsWithCount} totalCount={allPosts.length} activeTagId={params.id} />
|
101
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 mb-16">
|
102
|
+
{blog.map((post) => <PostPreview post={post} />)}
|
103
|
+
</div>
|
104
|
+
|
105
|
+
<!-- Пагинация (показывается только если больше одной страницы) -->
|
106
|
+
{page.lastPage > 1 && <Pagination page={page} class="my-16 sm:my-24" />}
|
107
|
+
{/* InfoCard о рубриках перенесён на страницу всех тегов */}
|
108
|
+
</div>
|
109
|
+
<Subscribe class="my-16 sm:my-24" />
|
110
|
+
</BaseLayout>
|
@@ -0,0 +1,124 @@
|
|
1
|
+
---
|
2
|
+
import { getEntry } from 'astro:content';
|
3
|
+
import { getFilteredCollection } from '../../utils/content-loader';
|
4
|
+
import Breadcrumbs from '../../components/Breadcrumbs.astro';
|
5
|
+
import InfoCard from '../../components/InfoCard.astro';
|
6
|
+
import RubricCard from '../../components/RubricCard.astro';
|
7
|
+
import Subscribe from '../../components/Subscribe.astro';
|
8
|
+
import TagPills from '../../components/TagPills.astro';
|
9
|
+
import { maugliConfig } from '../../config/maugli.config';
|
10
|
+
import { LANGUAGES } from '../../i18n/languages';
|
11
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
12
|
+
import { slugify } from '../../utils/common-utils';
|
13
|
+
import { getAllTags, getPostsByTag, sortItemsByDateDesc } from '../../utils/data-utils';
|
14
|
+
|
15
|
+
let posts = (await getFilteredCollection('blog')).sort(sortItemsByDateDesc);
|
16
|
+
const tags = getAllTags(posts).sort((tagA, tagB) => {
|
17
|
+
const postCountTagA = getPostsByTag(posts, tagA.id).length;
|
18
|
+
const postCountTagB = getPostsByTag(posts, tagB.id).length;
|
19
|
+
return postCountTagB - postCountTagA;
|
20
|
+
});
|
21
|
+
|
22
|
+
// Получаем все mdx-файлы рубрик
|
23
|
+
let allRubricFiles = await getFilteredCollection('tags');
|
24
|
+
const rubricSlugs = allRubricFiles.map((r) => r.data.slug);
|
25
|
+
const rubricMeta = Object.fromEntries(allRubricFiles.map((r) => [r.data.slug, r]));
|
26
|
+
|
27
|
+
// Формируем список рубрик только по реально используемым тегам
|
28
|
+
const rubricCards = tags
|
29
|
+
.filter((tag) => rubricSlugs.includes(tag.id))
|
30
|
+
.map((tag) => {
|
31
|
+
const rubric = rubricMeta[tag.id];
|
32
|
+
const postCount = getPostsByTag(posts, tag.id).length;
|
33
|
+
const lastPost = posts
|
34
|
+
.filter((post) => (post.data.tags || []).some((t) => slugify(t) === tag.id))
|
35
|
+
.sort((a, b) => new Date(b.data.publishDate).getTime() - new Date(a.data.publishDate).getTime())[0];
|
36
|
+
const updatedAt = lastPost ? lastPost.data.publishDate?.toString() : undefined;
|
37
|
+
return {
|
38
|
+
slug: rubric.data.slug,
|
39
|
+
title: rubric.data.title,
|
40
|
+
description: rubric.data.description,
|
41
|
+
image: rubric.data.image,
|
42
|
+
postCount,
|
43
|
+
updatedAt,
|
44
|
+
quote: rubric.data.quote,
|
45
|
+
isFeatured: rubric.data.isFeatured
|
46
|
+
};
|
47
|
+
});
|
48
|
+
|
49
|
+
// Обычные теги — только те, для которых нет mdx-файла
|
50
|
+
const simpleTags = tags.filter((tag) => !rubricSlugs.includes(tag.id));
|
51
|
+
|
52
|
+
// Сортируем rubricCards по дате последнего поста (lastPublishDate), самые свежие — выше
|
53
|
+
const rubricCardsSorted = [...rubricCards].sort((a, b) => {
|
54
|
+
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
55
|
+
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
56
|
+
return dateB - dateA;
|
57
|
+
});
|
58
|
+
|
59
|
+
const dicts: Record<string, any> = Object.fromEntries(LANGUAGES.map((l) => [l.code, l.dict]));
|
60
|
+
const lang: string = typeof maugliConfig.defaultLang === 'string' && dicts[maugliConfig.defaultLang] ? maugliConfig.defaultLang : 'en';
|
61
|
+
const dict = dicts[lang] || dicts['en'];
|
62
|
+
const pageTitle = dict.pages?.tags?.title || dicts['en'].pages.tags.title;
|
63
|
+
const pageDesc = dict.pages?.tags?.description || dicts['en'].pages.tags.description;
|
64
|
+
const blogRubrics = dict.pages?.tags?.blogRubrics || dicts['en'].pages.tags.blogRubrics;
|
65
|
+
const moreTags = dict.pages?.tags?.moreTags || dicts['en'].pages.tags.moreTags || 'More tags';
|
66
|
+
|
67
|
+
const rubricsSection = await getEntry('pages', 'rubrics');
|
68
|
+
---
|
69
|
+
|
70
|
+
<BaseLayout title={pageTitle} description={pageDesc} showHeader={false} fullWidth={true}>
|
71
|
+
<div class="max-w-[1280px] mx-auto">
|
72
|
+
<Breadcrumbs />
|
73
|
+
|
74
|
+
<h1 class="mb-12 text-3xl font-serif font-[800] sm:mb-16 sm:text-5xl" style="color: var(--text-heading)">{blogRubrics}</h1>
|
75
|
+
|
76
|
+
{/* Карточки рубрик */}
|
77
|
+
{
|
78
|
+
rubricCardsSorted.length > 0 && (
|
79
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-16">
|
80
|
+
{rubricCardsSorted.map((rubric) => (
|
81
|
+
<RubricCard rubric={rubric} />
|
82
|
+
))}
|
83
|
+
</div>
|
84
|
+
)
|
85
|
+
}
|
86
|
+
|
87
|
+
{/* Обычные теги */}
|
88
|
+
{
|
89
|
+
simpleTags.length > 0 && (
|
90
|
+
<h2 class="mb-8 text-2xl sm:text-3xl font-serif font-[700]" style="color: var(--text-heading)">
|
91
|
+
{moreTags}
|
92
|
+
</h2>
|
93
|
+
)
|
94
|
+
}
|
95
|
+
{
|
96
|
+
simpleTags.length > 0 && (
|
97
|
+
<TagPills
|
98
|
+
tags={simpleTags.map((tag) => ({
|
99
|
+
id: tag.id,
|
100
|
+
name: tag.name,
|
101
|
+
count: getPostsByTag(posts, tag.id).length,
|
102
|
+
isRubric: rubricSlugs.includes(tag.id)
|
103
|
+
}))}
|
104
|
+
basePath="/tags"
|
105
|
+
class="mb-10"
|
106
|
+
/>
|
107
|
+
)
|
108
|
+
}
|
109
|
+
</div>
|
110
|
+
{maugliConfig.subscribe?.enabled && <Subscribe class="my-16 sm:my-24" />}
|
111
|
+
|
112
|
+
{/* InfoCard о рубриках внизу страницы */}
|
113
|
+
{
|
114
|
+
rubricsSection && (
|
115
|
+
<div class="max-w-[1280px] mx-auto">
|
116
|
+
<InfoCard title={rubricsSection.data.title} description={rubricsSection.body} image={rubricsSection.data.image} class="mt-16" />
|
117
|
+
</div>
|
118
|
+
)
|
119
|
+
}
|
120
|
+
</BaseLayout>
|
121
|
+
|
122
|
+
<!-- Пример использования TagsSection:
|
123
|
+
<TagsSection tags={tagsWithRubricFlag} totalCount={posts.length} />
|
124
|
+
-->
|
@@ -0,0 +1,22 @@
|
|
1
|
+
// Автоматически добавляет класс для анимации InfoCard при появлении в viewport
|
2
|
+
export function animateInfoCardsOnView() {
|
3
|
+
if (typeof window === 'undefined') return;
|
4
|
+
const cards = document.querySelectorAll('.info-card-fade-in');
|
5
|
+
if (!('IntersectionObserver' in window) || !cards.length) return;
|
6
|
+
|
7
|
+
const observer = new window.IntersectionObserver((entries, obs) => {
|
8
|
+
entries.forEach(entry => {
|
9
|
+
if (entry.isIntersecting) {
|
10
|
+
entry.target.classList.add('info-card-fade-in--active');
|
11
|
+
obs.unobserve(entry.target);
|
12
|
+
}
|
13
|
+
});
|
14
|
+
}, { threshold: 0.15 });
|
15
|
+
|
16
|
+
cards.forEach(card => observer.observe(card));
|
17
|
+
}
|
18
|
+
|
19
|
+
// Для автоинициализации на всех страницах, если нужно:
|
20
|
+
if (typeof window !== 'undefined') {
|
21
|
+
window.addEventListener('DOMContentLoaded', animateInfoCardsOnView);
|
22
|
+
}
|