@brumaombra/ui-vintage 0.9.2 → 0.9.3

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/COMPONENTS.md CHANGED
@@ -3466,10 +3466,11 @@ Components for Nuxt Content pages and a complete blog.
3466
3466
 
3467
3467
  ### `@brumaombra/ui-vintage/blog`
3468
3468
 
3469
- Blog building blocks: an animated hero header, a post header (breadcrumbs, category and tags, title, author line, and featured image), a full-bleed featured carousel, post and category cards, tags, table of contents, reading progress, FAQ, share sidebar, and the content renderer. Scroll entrances use AOS: install `aos` in the app and initialize it (the components carry `data-aos="blur-up"`, and the library stylesheet defines that animation). Without AOS the content simply renders without entrances.
3469
+ Complete blog pages and their building blocks. The page components (`BlogIndexPage`, `BlogPostPage`, `BlogCategoriesPage`, `BlogTagsPage`, `BlogPostsPage` for the posts of a category or a tag) render the whole design: the app keeps one thin file per route that queries Nuxt Content, sets the SEO and passes the data and its own copy (titles and descriptions), with the helpers (`slugify`, `normalizeContentPath`, `buildCategoriesFromPosts`, `buildTagsFromPosts`, `sortByStableSeed`) shaping the data. The post page renders the app's `ProseHr` content component as divider and has an `after-article` slot for a call to action. The building blocks: an animated hero header, a post header (breadcrumbs, category and tags, title, author line, and featured image), a full-bleed featured carousel, post and category cards, tags, table of contents, reading progress, FAQ, share sidebar, and the content renderer. Scroll entrances use AOS: install `aos` in the app and initialize it (the components carry `data-aos="blur-up"`, and the library stylesheet defines that animation). Without AOS the content simply renders without entrances.
3470
3470
 
3471
3471
  ```ts
3472
- import { AllPostsSection, BlogContentRenderer, BlogFAQSection, BlogInfoSection, BlogPostHeader, BlogHeaderSection, BlogSectionTitle, BlogTagsSection, CategoriesList, CategoriesSection, CategoryCard, HeaderCarousel, PostCard, PostsList, SocialShareSidebar, TableOfContents } from '@brumaombra/ui-vintage/blog';
3472
+ import { AllPostsSection, BlogContentRenderer, BlogFAQSection, BlogInfoSection, BlogPostHeader, BlogHeaderSection, BlogSectionTitle, BlogTagsSection, CategoriesList, CategoriesSection, CategoryCard, HeaderCarousel, PostCard, PostsList, SocialShareSidebar, TableOfContents, BlogIndexPage, BlogPostPage, BlogCategoriesPage, BlogTagsPage, BlogPostsPage, BlogViewAllLink, slugify, normalizeContentPath, buildCategoriesFromPosts, buildTagsFromPosts, sortByStableSeed } from '@brumaombra/ui-vintage/blog';
3473
+ import type { BlogPostData, BlogCategoryData, BlogTagData } from '@brumaombra/ui-vintage/blog';
3473
3474
  ```
3474
3475
 
3475
3476
  #### `AllPostsSection`
@@ -3611,6 +3612,105 @@ import { AllPostsSection, BlogContentRenderer, BlogFAQSection, BlogInfoSection,
3611
3612
  | --- | --- | --- |
3612
3613
  | `content` *(required)* | `{ body?: { toc?: { links?: BlogTocLink[] } }; toc?: { links?: BlogTocLink[] } }` | |
3613
3614
 
3615
+ #### `BlogIndexPage`
3616
+
3617
+ | Prop | Type | Default |
3618
+ | --- | --- | --- |
3619
+ | `title` *(required)* | `string` | |
3620
+ | `highlight` | `string` | `''` |
3621
+ | `description` *(required)* | `string` | |
3622
+ | `featuredPosts` | `BlogListPost[]` | `[]` |
3623
+ | `categories` | `BlogCategoryData[]` | `[]` |
3624
+ | `tags` | `BlogTagData[]` | `[]` |
3625
+ | `posts` | `BlogListPost[]` | `[]` |
3626
+ | `totalPosts` | `number` | `0` |
3627
+ | `currentPage` | `number` | `1` |
3628
+ | `postsPerPage` | `number` | `9` |
3629
+ | `hasMorePosts` | `boolean` | `false` |
3630
+ | `isLoading` | `boolean` | `false` |
3631
+
3632
+ - **Emits:** `load-more`.
3633
+
3634
+ #### `BlogPostPage`
3635
+
3636
+ | Prop | Type | Default |
3637
+ | --- | --- | --- |
3638
+ | `post` *(required)* | `Record<string, any>` | |
3639
+ | `url` *(required)* | `string` | |
3640
+ | `relatedPosts` | `Array<{ path: string; title: string; description?: string; image?: string; categoryText?: string }>` | `[]` |
3641
+ | `relatedCategories` | `BlogCategoryData[]` | `[]` |
3642
+
3643
+ - **Slots:** `after-article`.
3644
+
3645
+ #### `BlogCategoriesPage`
3646
+
3647
+ | Prop | Type | Default |
3648
+ | --- | --- | --- |
3649
+ | `description` *(required)* | `string` | |
3650
+ | `categories` | `BlogCategoryData[]` | `[]` |
3651
+
3652
+ #### `BlogTagsPage`
3653
+
3654
+ | Prop | Type | Default |
3655
+ | --- | --- | --- |
3656
+ | `description` *(required)* | `string` | |
3657
+ | `tags` | `BlogTagData[]` | `[]` |
3658
+
3659
+ #### `BlogPostsPage`
3660
+
3661
+ | Prop | Type | Default |
3662
+ | --- | --- | --- |
3663
+ | `section` *(required)* | `'categories' \| 'tags'` | |
3664
+ | `title` *(required)* | `string` | |
3665
+ | `description` *(required)* | `string` | |
3666
+ | `posts` | `Array<{ path: string; title: string; description?: string; image?: string; categoryText?: string }>` | `[]` |
3667
+ | `totalPosts` | `number` | `0` |
3668
+ | `currentPage` | `number` | `1` |
3669
+ | `postsPerPage` | `number` | `9` |
3670
+ | `hasMorePosts` | `boolean` | `false` |
3671
+ | `isLoading` | `boolean` | `false` |
3672
+
3673
+ - **Emits:** `load-more`.
3674
+
3675
+ #### `BlogViewAllLink`
3676
+
3677
+ | Prop | Type | Default |
3678
+ | --- | --- | --- |
3679
+ | `to` *(required)* | `string` | |
3680
+ | `label` *(required)* | `string` | |
3681
+
3682
+ #### Functions, constants, and types
3683
+
3684
+ ```ts
3685
+ function slugify(value: unknown)
3686
+ function normalizeContentPath(path: string)
3687
+ function buildCategoriesFromPosts(posts: BlogPostData[], getPath: (slug: string) => string): BlogCategoryData[]
3688
+ function buildTagsFromPosts(posts: BlogPostData[]): BlogTagData[]
3689
+ function sortByStableSeed(items: T[], seed: string, getKey: (item: T) => string)
3690
+ interface BlogPostData {
3691
+ path?: string;
3692
+ title?: string;
3693
+ description?: string;
3694
+ image?: string;
3695
+ categoryText?: string;
3696
+ categorySlug?: string;
3697
+ tags?: string[];
3698
+ [key: string]: unknown;
3699
+ }
3700
+ interface BlogCategoryData {
3701
+ name: string;
3702
+ slug: string;
3703
+ path: string;
3704
+ count: number;
3705
+ image?: string;
3706
+ }
3707
+ interface BlogTagData {
3708
+ name: string;
3709
+ slug: string;
3710
+ count: number;
3711
+ }
3712
+ ```
3713
+
3614
3714
  ---
3615
3715
 
3616
3716
  ### `@brumaombra/ui-vintage/content`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brumaombra/ui-vintage",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Source-published Nuxt UI runtime with shared theme tokens and built-in Nuxt integrations.",
5
5
  "private": false,
6
6
  "author": "Mauro Brambilla",
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from 'vue-i18n';
4
+ import FolderLibraryIcon from '@hugeicons/core-free-icons/FolderLibraryIcon';
5
+ import News01Icon from '@hugeicons/core-free-icons/News01Icon';
6
+ import type { BlogCategoryData } from './blogPageUtils';
7
+ import BlogHeaderSection from './BlogHeaderSection.vue';
8
+ import BlogPageBreadcrumbs from './BlogPageBreadcrumbs.vue';
9
+ import CategoriesList from './CategoriesList.vue';
10
+
11
+ // Props
12
+ const props = withDefaults(defineProps<{
13
+ description: string;
14
+ categories?: BlogCategoryData[];
15
+ }>(), {
16
+ categories: () => []
17
+ });
18
+
19
+ const { t } = useI18n();
20
+
21
+ // Category totals shown under the header
22
+ const headerStats = computed(() => {
23
+ const postCount = props.categories.reduce((total, category) => total + category.count, 0);
24
+ return [
25
+ { icon: FolderLibraryIcon, value: props.categories.length, label: t('uiVintage.blog.stats.categories', props.categories.length) },
26
+ { icon: News01Icon, value: postCount, label: t('uiVintage.blog.stats.posts', postCount) }
27
+ ];
28
+ });
29
+ </script>
30
+
31
+ <template>
32
+ <div>
33
+ <!-- Breadcrumbs -->
34
+ <BlogPageBreadcrumbs :items="[{ label: t('uiVintage.blog.breadcrumbCategories') }]" />
35
+
36
+ <!-- Blog header -->
37
+ <BlogHeaderSection :title="t('uiVintage.blog.categories')"
38
+ :highlight="t('uiVintage.blog.categories')"
39
+ :description="props.description"
40
+ :stats="headerStats"
41
+ class="mb-12 md:mb-16" />
42
+
43
+ <!-- Categories section -->
44
+ <CategoriesList :categories="props.categories" />
45
+ </div>
46
+ </template>
@@ -0,0 +1,118 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from 'vue-i18n';
4
+ import FolderLibraryIcon from '@hugeicons/core-free-icons/FolderLibraryIcon';
5
+ import News01Icon from '@hugeicons/core-free-icons/News01Icon';
6
+ import Tag01Icon from '@hugeicons/core-free-icons/Tag01Icon';
7
+ import type { BlogCategoryData, BlogTagData } from './blogPageUtils';
8
+ import AllPostsSection from './AllPostsSection.vue';
9
+ import BlogHeaderSection from './BlogHeaderSection.vue';
10
+ import BlogSectionTitle from './BlogSectionTitle.vue';
11
+ import BlogTagsSection from './BlogTagsSection.vue';
12
+ import BlogViewAllLink from './BlogViewAllLink.vue';
13
+ import CategoriesSection from './CategoriesSection.vue';
14
+ import HeaderCarousel from './HeaderCarousel.vue';
15
+
16
+ // Post shown in the carousel and in the list (paths already localized)
17
+ type BlogListPost = {
18
+ path: string;
19
+ title: string;
20
+ description?: string;
21
+ image?: string;
22
+ categoryText?: string;
23
+ categoryPath?: string;
24
+ };
25
+
26
+ // Props
27
+ const props = withDefaults(defineProps<{
28
+ title: string;
29
+ highlight?: string;
30
+ description: string;
31
+ featuredPosts?: BlogListPost[];
32
+ categories?: BlogCategoryData[];
33
+ tags?: BlogTagData[];
34
+ posts?: BlogListPost[];
35
+ totalPosts?: number;
36
+ currentPage?: number;
37
+ postsPerPage?: number;
38
+ hasMorePosts?: boolean;
39
+ isLoading?: boolean;
40
+ }>(), {
41
+ highlight: '',
42
+ featuredPosts: () => [],
43
+ categories: () => [],
44
+ tags: () => [],
45
+ posts: () => [],
46
+ totalPosts: 0,
47
+ currentPage: 1,
48
+ postsPerPage: 9,
49
+ hasMorePosts: false,
50
+ isLoading: false
51
+ });
52
+
53
+ // Emits
54
+ const emit = defineEmits<{
55
+ 'load-more': [];
56
+ }>();
57
+
58
+ const { t } = useI18n();
59
+
60
+ // Announce the latest post in the header
61
+ const headerAnnouncement = computed(() => props.featuredPosts[0] ? { text: props.featuredPosts[0].title, to: props.featuredPosts[0].path } : null);
62
+
63
+ // Blog totals shown under the header
64
+ const headerStats = computed(() => [
65
+ { icon: News01Icon, value: props.totalPosts, label: t('uiVintage.blog.stats.posts', props.totalPosts) },
66
+ { icon: FolderLibraryIcon, value: props.categories.length, label: t('uiVintage.blog.stats.categories', props.categories.length) },
67
+ { icon: Tag01Icon, value: props.tags.length, label: t('uiVintage.blog.stats.tags', props.tags.length) }
68
+ ]);
69
+ </script>
70
+
71
+ <template>
72
+ <div class="space-y-16 md:space-y-24">
73
+ <!-- Header section -->
74
+ <BlogHeaderSection :announcement="headerAnnouncement"
75
+ :title="props.title"
76
+ :highlight="props.highlight"
77
+ :description="props.description"
78
+ :stats="headerStats" />
79
+
80
+ <!-- Header carousel -->
81
+ <section>
82
+ <BlogSectionTitle :title="t('uiVintage.blog.latestPosts')" :description="t('uiVintage.blog.latestPostsDescription')" />
83
+ <HeaderCarousel :featured-posts="props.featuredPosts" />
84
+ </section>
85
+
86
+ <!-- Categories section -->
87
+ <section>
88
+ <BlogSectionTitle :title="t('uiVintage.blog.categories')" :description="t('uiVintage.blog.categoriesDescription')">
89
+ <template #action>
90
+ <BlogViewAllLink to="/blog/categories" :label="t('uiVintage.blog.viewAll')" />
91
+ </template>
92
+ </BlogSectionTitle>
93
+ <CategoriesSection :categories="props.categories" />
94
+ </section>
95
+
96
+ <!-- Tags section -->
97
+ <section v-if="props.tags.length">
98
+ <BlogSectionTitle :title="t('uiVintage.blog.tags')" :description="t('uiVintage.blog.tagsDescription')">
99
+ <template #action>
100
+ <BlogViewAllLink to="/blog/tags" :label="t('uiVintage.blog.viewAll')" />
101
+ </template>
102
+ </BlogSectionTitle>
103
+ <BlogTagsSection :tags="props.tags" />
104
+ </section>
105
+
106
+ <!-- All posts section -->
107
+ <section>
108
+ <BlogSectionTitle :title="t('uiVintage.blog.allPosts')" :description="t('uiVintage.blog.allPostsDescription')" />
109
+ <AllPostsSection :posts="props.posts"
110
+ :current-page="props.currentPage"
111
+ :posts-per-page="props.postsPerPage"
112
+ :total-posts="props.totalPosts"
113
+ :has-more-posts="props.hasMorePosts"
114
+ :is-loading="props.isLoading"
115
+ @load-more="emit('load-more')" />
116
+ </section>
117
+ </div>
118
+ </template>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from 'vue-i18n';
4
+ import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '../ui/breadcrumb';
5
+
6
+ // Props (the trail always starts with Home and Blog, the last item is the current page)
7
+ const props = defineProps<{
8
+ items: Array<{
9
+ label: string;
10
+ to?: string;
11
+ }>;
12
+ }>();
13
+
14
+ const { t } = useI18n();
15
+
16
+ // Full trail of the page
17
+ const trail = computed(() => [
18
+ { label: t('uiVintage.blog.breadcrumbHome'), to: '/' },
19
+ { label: t('uiVintage.blog.breadcrumbBlog'), to: '/blog' },
20
+ ...props.items
21
+ ]);
22
+ </script>
23
+
24
+ <template>
25
+ <!-- Breadcrumbs of a blog listing page (the paths are localized) -->
26
+ <Breadcrumb class="mb-2 flex animate-uv-fade-up justify-center">
27
+ <BreadcrumbList>
28
+ <template v-for="(item, index) in trail" :key="item.label">
29
+ <BreadcrumbSeparator v-if="index > 0" />
30
+ <BreadcrumbItem>
31
+ <BreadcrumbLink v-if="item.to && index < trail.length - 1" as-child>
32
+ <NuxtLinkLocale :to="item.to">
33
+ {{ item.label }}
34
+ </NuxtLinkLocale>
35
+ </BreadcrumbLink>
36
+ <BreadcrumbPage v-else>
37
+ {{ item.label }}
38
+ </BreadcrumbPage>
39
+ </BreadcrumbItem>
40
+ </template>
41
+ </BreadcrumbList>
42
+ </Breadcrumb>
43
+ </template>
@@ -0,0 +1,116 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from 'vue-i18n';
4
+ import { slugify } from './blogPageUtils';
5
+ import type { BlogCategoryData } from './blogPageUtils';
6
+ import BlogContentRenderer from './BlogContentRenderer.vue';
7
+ import BlogFAQSection from './BlogFAQSection.vue';
8
+ import BlogInfoSection from './BlogInfoSection.vue';
9
+ import BlogPostHeader from './BlogPostHeader.vue';
10
+ import BlogSectionTitle from './BlogSectionTitle.vue';
11
+ import BlogViewAllLink from './BlogViewAllLink.vue';
12
+ import CategoryCard from './CategoryCard.vue';
13
+ import PostCard from './PostCard.vue';
14
+ import SocialShareSidebar from './SocialShareSidebar.vue';
15
+ import TableOfContents from './TableOfContents.vue';
16
+
17
+ // Props (post is the Nuxt Content entry of the blog collection, url its absolute address for sharing)
18
+ const props = withDefaults(defineProps<{
19
+ post: Record<string, any>;
20
+ url: string;
21
+ relatedPosts?: Array<{
22
+ path: string;
23
+ title: string;
24
+ description?: string;
25
+ image?: string;
26
+ categoryText?: string;
27
+ }>;
28
+ relatedCategories?: BlogCategoryData[];
29
+ }>(), {
30
+ relatedPosts: () => [],
31
+ relatedCategories: () => []
32
+ });
33
+
34
+ const { t } = useI18n();
35
+
36
+ // Tags of the post as badges (tags that can't produce a URL slug are skipped)
37
+ const postTags = computed(() => ((props.post.tags as string[] | undefined) || [])
38
+ .map(tag => ({ name: tag, slug: slugify(tag) }))
39
+ .filter(tag => tag.slug));
40
+ </script>
41
+
42
+ <template>
43
+ <div class="mx-auto max-w-4xl">
44
+ <article>
45
+ <!-- Article header -->
46
+ <BlogPostHeader :title="props.post.title"
47
+ :description="props.post.description"
48
+ :image="props.post.image"
49
+ :author="props.post.author"
50
+ :author-image-url="props.post.authorImageUrl"
51
+ :date-published="props.post.datePublished"
52
+ :category-text="props.post.categoryText"
53
+ :category-slug="props.post.categorySlug"
54
+ :tags="postTags"
55
+ :body="props.post.body" />
56
+
57
+ <!-- Table of contents -->
58
+ <TableOfContents :content="props.post" />
59
+
60
+ <!-- Social share sidebar -->
61
+ <SocialShareSidebar :title="props.post.title" :url="props.url" />
62
+
63
+ <!-- Article content -->
64
+ <BlogContentRenderer :value="props.post" />
65
+
66
+ <!-- Divider (the app's own ProseHr content component, lazy like the copy Nuxt Content loads for markdown) -->
67
+ <LazyProseHr />
68
+
69
+ <!-- FAQ section -->
70
+ <BlogFAQSection v-if="props.post.faqs?.length" :faqs="props.post.faqs" />
71
+
72
+ <!-- Divider -->
73
+ <LazyProseHr />
74
+
75
+ <!-- Blog info section -->
76
+ <BlogInfoSection :author="props.post.author"
77
+ :author-url="props.post.authorUrl"
78
+ :author-image-url="props.post.authorImageUrl"
79
+ :date-published="props.post.datePublished"
80
+ :date-modified="props.post.dateModified" />
81
+
82
+ <!-- App content after the article (e.g. a call to action) -->
83
+ <slot name="after-article" />
84
+ </article>
85
+
86
+ <!-- Related posts -->
87
+ <section v-if="props.relatedPosts.length" class="mt-16 md:mt-24">
88
+ <BlogSectionTitle :title="t('uiVintage.blog.readAlso')" />
89
+ <div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
90
+ <NuxtLinkLocale v-for="(relatedPost, index) in props.relatedPosts" :key="relatedPost.path" :to="relatedPost.path" data-aos="blur-up" :data-aos-delay="(index % 2) * 90" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
91
+ <PostCard :image="relatedPost.image"
92
+ :category="relatedPost.categoryText"
93
+ :title="relatedPost.title"
94
+ :description="relatedPost.description"
95
+ class="h-full" />
96
+ </NuxtLinkLocale>
97
+ </div>
98
+ </section>
99
+
100
+ <!-- Related categories -->
101
+ <section v-if="props.relatedCategories.length" class="mt-16 md:mt-24">
102
+ <BlogSectionTitle :title="t('uiVintage.blog.exploreCategories')">
103
+ <template #action>
104
+ <BlogViewAllLink to="/blog/categories" :label="t('uiVintage.blog.exploreAllCategories')" />
105
+ </template>
106
+ </BlogSectionTitle>
107
+ <div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
108
+ <NuxtLinkLocale v-for="(category, index) in props.relatedCategories" :key="category.slug" :to="`/blog/categories/${category.slug}`" data-aos="blur-up" :data-aos-delay="(index % 2) * 90" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
109
+ <CategoryCard :name="category.name"
110
+ :image="category.image"
111
+ :count="category.count" />
112
+ </NuxtLinkLocale>
113
+ </div>
114
+ </section>
115
+ </div>
116
+ </template>
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from 'vue-i18n';
4
+ import News01Icon from '@hugeicons/core-free-icons/News01Icon';
5
+ import { LoadMoreButton } from '../load-more-button';
6
+ import BlogHeaderSection from './BlogHeaderSection.vue';
7
+ import BlogPageBreadcrumbs from './BlogPageBreadcrumbs.vue';
8
+ import PostsList from './PostsList.vue';
9
+
10
+ // Props (the posts of a category or a tag, their paths already localized)
11
+ const props = withDefaults(defineProps<{
12
+ section: 'categories' | 'tags';
13
+ title: string;
14
+ description: string;
15
+ posts?: Array<{
16
+ path: string;
17
+ title: string;
18
+ description?: string;
19
+ image?: string;
20
+ categoryText?: string;
21
+ }>;
22
+ totalPosts?: number;
23
+ currentPage?: number;
24
+ postsPerPage?: number;
25
+ hasMorePosts?: boolean;
26
+ isLoading?: boolean;
27
+ }>(), {
28
+ posts: () => [],
29
+ totalPosts: 0,
30
+ currentPage: 1,
31
+ postsPerPage: 9,
32
+ hasMorePosts: false,
33
+ isLoading: false
34
+ });
35
+
36
+ // Emits
37
+ const emit = defineEmits<{
38
+ 'load-more': [];
39
+ }>();
40
+
41
+ const { t } = useI18n();
42
+
43
+ // Breadcrumbs: the section list, then the category or tag
44
+ const breadcrumbs = computed(() => [
45
+ props.section === 'tags' ? { label: t('uiVintage.blog.breadcrumbTags'), to: '/blog/tags' } : { label: t('uiVintage.blog.breadcrumbCategories'), to: '/blog/categories' },
46
+ { label: props.title }
47
+ ]);
48
+
49
+ // Number of posts shown under the header
50
+ const headerStats = computed(() => [{ icon: News01Icon, value: props.totalPosts, label: t('uiVintage.blog.stats.posts', props.totalPosts) }]);
51
+ </script>
52
+
53
+ <template>
54
+ <div>
55
+ <!-- Breadcrumbs -->
56
+ <BlogPageBreadcrumbs :items="breadcrumbs" />
57
+
58
+ <!-- Blog header -->
59
+ <BlogHeaderSection :title="props.title"
60
+ :highlight="props.title"
61
+ :description="props.description"
62
+ :stats="headerStats"
63
+ class="mb-12 md:mb-16" />
64
+
65
+ <!-- Posts -->
66
+ <PostsList :posts="props.posts" />
67
+
68
+ <!-- Load more button -->
69
+ <LoadMoreButton v-if="props.hasMorePosts" :busy="props.isLoading" :text="t('uiVintage.blog.loadMoreWithNumbers', {
70
+ current: Math.min(props.currentPage * props.postsPerPage, props.totalPosts),
71
+ total: props.totalPosts
72
+ })" @load-more="emit('load-more')" class="mt-8" data-aos="blur-up" />
73
+ </div>
74
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from 'vue-i18n';
4
+ import News01Icon from '@hugeicons/core-free-icons/News01Icon';
5
+ import Tag01Icon from '@hugeicons/core-free-icons/Tag01Icon';
6
+ import type { BlogTagData } from './blogPageUtils';
7
+ import BlogHeaderSection from './BlogHeaderSection.vue';
8
+ import BlogPageBreadcrumbs from './BlogPageBreadcrumbs.vue';
9
+ import BlogTagsSection from './BlogTagsSection.vue';
10
+
11
+ // Props
12
+ const props = withDefaults(defineProps<{
13
+ description: string;
14
+ tags?: BlogTagData[];
15
+ }>(), {
16
+ tags: () => []
17
+ });
18
+
19
+ const { t } = useI18n();
20
+
21
+ // Tag totals shown under the header
22
+ const headerStats = computed(() => {
23
+ const tagUses = props.tags.reduce((total, tag) => total + (tag.count || 0), 0);
24
+ return [
25
+ { icon: Tag01Icon, value: props.tags.length, label: t('uiVintage.blog.stats.tags', props.tags.length) },
26
+ { icon: News01Icon, value: tagUses, label: t('uiVintage.blog.stats.uses', tagUses) }
27
+ ];
28
+ });
29
+ </script>
30
+
31
+ <template>
32
+ <div>
33
+ <!-- Breadcrumbs -->
34
+ <BlogPageBreadcrumbs :items="[{ label: t('uiVintage.blog.breadcrumbTags') }]" />
35
+
36
+ <!-- Blog header -->
37
+ <BlogHeaderSection :title="t('uiVintage.blog.tags')"
38
+ :highlight="t('uiVintage.blog.tags')"
39
+ :description="props.description"
40
+ :stats="headerStats"
41
+ class="mb-12 md:mb-16" />
42
+
43
+ <!-- Tags list -->
44
+ <BlogTagsSection :tags="props.tags" class="justify-center" />
45
+ </div>
46
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
3
+ import { HugeiconsIcon } from '@hugeicons/vue';
4
+
5
+ // Props
6
+ const props = defineProps<{
7
+ to: string;
8
+ label: string;
9
+ }>();
10
+ </script>
11
+
12
+ <template>
13
+ <!-- Link to the full list of a blog section (the path is localized) -->
14
+ <NuxtLinkLocale :to="props.to" class="group/view inline-flex items-center gap-1.5 rounded px-1 text-xs font-semibold text-primary outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45 sm:text-sm">
15
+ {{ props.label }}
16
+ <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/view:translate-x-1" />
17
+ </NuxtLinkLocale>
18
+ </template>
@@ -0,0 +1,98 @@
1
+ // Post fields read by the blog page helpers (a Nuxt Content blog collection entry)
2
+ export interface BlogPostData {
3
+ path?: string;
4
+ title?: string;
5
+ description?: string;
6
+ image?: string;
7
+ categoryText?: string;
8
+ categorySlug?: string;
9
+ tags?: string[];
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ // Category card data built from the posts
14
+ export interface BlogCategoryData {
15
+ name: string;
16
+ slug: string;
17
+ path: string;
18
+ count: number;
19
+ image?: string;
20
+ }
21
+
22
+ // Tag badge data built from the posts
23
+ export interface BlogTagData {
24
+ name: string;
25
+ slug: string;
26
+ count: number;
27
+ }
28
+
29
+ // Turn a label into a URL slug (e.g. "Dark Mode" -> "dark-mode")
30
+ export const slugify = (value: unknown) => {
31
+ return String(value || '')
32
+ .trim()
33
+ .toLowerCase()
34
+ .replace(/[^a-z0-9]+/g, '-')
35
+ .replace(/^-+|-+$/g, '');
36
+ };
37
+
38
+ // Remove the trailing slashes of a route path, since content paths never have one ("/blog/post/" -> "/blog/post")
39
+ export const normalizeContentPath = (path: string) => path.replace(/(.)\/+$/, '$1');
40
+
41
+ // Build the unique categories of the posts, with their post count and the image of their first post
42
+ export const buildCategoriesFromPosts = (posts: BlogPostData[], getPath: (slug: string) => string): BlogCategoryData[] => {
43
+ const categoriesList = posts.map(post => post.categorySlug);
44
+ const uniqueCategories = categoriesList.filter((category, index, self): category is string => !!category && self.indexOf(category) === index);
45
+
46
+ // Map unique categories to category data
47
+ return uniqueCategories.map(category => {
48
+ const firstPost = posts.find(post => post.categorySlug === category);
49
+
50
+ // Return category data
51
+ return {
52
+ name: firstPost?.categoryText || category,
53
+ slug: category,
54
+ path: getPath(category),
55
+ count: categoriesList.filter(item => item === category).length,
56
+ image: firstPost?.image
57
+ };
58
+ });
59
+ };
60
+
61
+ // Build the unique tags of the posts, with their number of uses (the first label wins for tags that share a slug)
62
+ export const buildTagsFromPosts = (posts: BlogPostData[]): BlogTagData[] => {
63
+ const tagsBySlug = new Map<string, BlogTagData>();
64
+
65
+ // Loop through each post and tag
66
+ for (const post of posts) {
67
+ for (const tag of post.tags || []) {
68
+ const slug = slugify(tag);
69
+ if (!slug) continue; // Skip tags that cannot produce a URL slug
70
+
71
+ // Preserve the first label while counting the duplicates
72
+ const existingTag = tagsBySlug.get(slug);
73
+ tagsBySlug.set(slug, {
74
+ name: existingTag?.name || tag,
75
+ slug,
76
+ count: (existingTag?.count || 0) + 1
77
+ });
78
+ }
79
+ }
80
+
81
+ // Return unique tags with their post counts
82
+ return [...tagsBySlug.values()];
83
+ };
84
+
85
+ // Create a deterministic hash for stable related content ordering (FNV-1a: a linear hash would give every seed the same order)
86
+ const hashString = (value: string) => {
87
+ let hash = 2166136261;
88
+ for (let index = 0; index < value.length; index++) {
89
+ hash ^= value.charCodeAt(index);
90
+ hash = Math.imul(hash, 16777619);
91
+ }
92
+ return hash >>> 0;
93
+ };
94
+
95
+ // Sort a list by a deterministic score derived from a seed (the same post always shows the same related items, on the server and in the browser)
96
+ export const sortByStableSeed = <T>(items: T[], seed: string, getKey: (item: T) => string) => {
97
+ return [...items].sort((itemA, itemB) => hashString(`${seed}-${getKey(itemA)}`) - hashString(`${seed}-${getKey(itemB)}`));
98
+ };
@@ -13,4 +13,15 @@ export { default as HeaderCarousel } from './HeaderCarousel.vue';
13
13
  export { default as PostCard } from './PostCard.vue';
14
14
  export { default as PostsList } from './PostsList.vue';
15
15
  export { default as SocialShareSidebar } from './SocialShareSidebar.vue';
16
- export { default as TableOfContents } from './TableOfContents.vue';
16
+ export { default as TableOfContents } from './TableOfContents.vue';
17
+ // Complete blog pages (the app fetches the content and sets the SEO, these render the page)
18
+ export { default as BlogIndexPage } from './BlogIndexPage.vue';
19
+ export { default as BlogPostPage } from './BlogPostPage.vue';
20
+ export { default as BlogCategoriesPage } from './BlogCategoriesPage.vue';
21
+ export { default as BlogTagsPage } from './BlogTagsPage.vue';
22
+ export { default as BlogPostsPage } from './BlogPostsPage.vue';
23
+ export { default as BlogViewAllLink } from './BlogViewAllLink.vue';
24
+
25
+ // Helpers for the data of the blog pages
26
+ export { slugify, normalizeContentPath, buildCategoriesFromPosts, buildTagsFromPosts, sortByStableSeed } from './blogPageUtils';
27
+ export type { BlogPostData, BlogCategoryData, BlogTagData } from './blogPageUtils';
package/src/i18n/de.json CHANGED
@@ -108,6 +108,27 @@
108
108
  "telegram": "Auf Telegram teilen",
109
109
  "copy": "Link kopieren",
110
110
  "copied": "Link kopiert"
111
+ },
112
+ "latestPosts": "Neueste Beiträge",
113
+ "latestPostsDescription": "Von der Redaktion ausgewählte Beiträge.",
114
+ "categories": "Kategorien",
115
+ "categoriesDescription": "Spring direkt zu den Themen, die dich interessieren.",
116
+ "tags": "Tags",
117
+ "tagsDescription": "Kleinere Themen, die sich durch alle Kategorien ziehen.",
118
+ "allPosts": "Alle Beiträge",
119
+ "allPostsDescription": "Alles, was wir veröffentlicht haben, das Neueste zuerst.",
120
+ "viewAll": "Alle ansehen",
121
+ "readAlso": "Lies auch",
122
+ "exploreCategories": "Kategorien entdecken",
123
+ "exploreAllCategories": "Alle Kategorien entdecken",
124
+ "breadcrumbHome": "Startseite",
125
+ "breadcrumbCategories": "Kategorien",
126
+ "breadcrumbTags": "Tags",
127
+ "stats": {
128
+ "posts": "Beitrag | Beiträge",
129
+ "categories": "Kategorie | Kategorien",
130
+ "tags": "Tag | Tags",
131
+ "uses": "Verwendung | Verwendungen"
111
132
  }
112
133
  },
113
134
  "errorPage": {
package/src/i18n/en.json CHANGED
@@ -108,6 +108,27 @@
108
108
  "telegram": "Share on Telegram",
109
109
  "copy": "Copy link",
110
110
  "copied": "Link copied"
111
+ },
112
+ "latestPosts": "Latest posts",
113
+ "latestPostsDescription": "Hand-picked stories from the editorial desk.",
114
+ "categories": "Categories",
115
+ "categoriesDescription": "Jump straight to the topics you care about.",
116
+ "tags": "Tags",
117
+ "tagsDescription": "Smaller threads that run across categories.",
118
+ "allPosts": "All posts",
119
+ "allPostsDescription": "Everything we have published, newest first.",
120
+ "viewAll": "View all",
121
+ "readAlso": "Read also",
122
+ "exploreCategories": "Explore categories",
123
+ "exploreAllCategories": "Explore all categories",
124
+ "breadcrumbHome": "Home",
125
+ "breadcrumbCategories": "Categories",
126
+ "breadcrumbTags": "Tags",
127
+ "stats": {
128
+ "posts": "post | posts",
129
+ "categories": "category | categories",
130
+ "tags": "tag | tags",
131
+ "uses": "use | uses"
111
132
  }
112
133
  },
113
134
  "errorPage": {
package/src/i18n/es.json CHANGED
@@ -108,6 +108,27 @@
108
108
  "telegram": "Compartir en Telegram",
109
109
  "copy": "Copiar enlace",
110
110
  "copied": "Enlace copiado"
111
+ },
112
+ "latestPosts": "Últimas publicaciones",
113
+ "latestPostsDescription": "Una selección de artículos elegidos por la redacción.",
114
+ "categories": "Categorías",
115
+ "categoriesDescription": "Ve directo a los temas que te interesan.",
116
+ "tags": "Etiquetas",
117
+ "tagsDescription": "Hilos más pequeños que atraviesan las categorías.",
118
+ "allPosts": "Todas las publicaciones",
119
+ "allPostsDescription": "Todo lo que hemos publicado, de lo más reciente a lo más antiguo.",
120
+ "viewAll": "Ver todo",
121
+ "readAlso": "Lee también",
122
+ "exploreCategories": "Explora las categorías",
123
+ "exploreAllCategories": "Explora todas las categorías",
124
+ "breadcrumbHome": "Inicio",
125
+ "breadcrumbCategories": "Categorías",
126
+ "breadcrumbTags": "Etiquetas",
127
+ "stats": {
128
+ "posts": "publicación | publicaciones",
129
+ "categories": "categoría | categorías",
130
+ "tags": "etiqueta | etiquetas",
131
+ "uses": "uso | usos"
111
132
  }
112
133
  },
113
134
  "errorPage": {
package/src/i18n/fr.json CHANGED
@@ -108,6 +108,27 @@
108
108
  "telegram": "Partager sur Telegram",
109
109
  "copy": "Copier le lien",
110
110
  "copied": "Lien copié"
111
+ },
112
+ "latestPosts": "Derniers articles",
113
+ "latestPostsDescription": "Une sélection d'articles choisis par la rédaction.",
114
+ "categories": "Catégories",
115
+ "categoriesDescription": "Accédez directement aux sujets qui vous intéressent.",
116
+ "tags": "Étiquettes",
117
+ "tagsDescription": "Des fils plus petits qui traversent les catégories.",
118
+ "allPosts": "Tous les articles",
119
+ "allPostsDescription": "Tout ce que nous avons publié, du plus récent au plus ancien.",
120
+ "viewAll": "Tout voir",
121
+ "readAlso": "À lire aussi",
122
+ "exploreCategories": "Explorer les catégories",
123
+ "exploreAllCategories": "Explorer toutes les catégories",
124
+ "breadcrumbHome": "Accueil",
125
+ "breadcrumbCategories": "Catégories",
126
+ "breadcrumbTags": "Étiquettes",
127
+ "stats": {
128
+ "posts": "article | articles",
129
+ "categories": "catégorie | catégories",
130
+ "tags": "étiquette | étiquettes",
131
+ "uses": "utilisation | utilisations"
111
132
  }
112
133
  },
113
134
  "errorPage": {
package/src/i18n/it.json CHANGED
@@ -108,6 +108,27 @@
108
108
  "telegram": "Condividi su Telegram",
109
109
  "copy": "Copia link",
110
110
  "copied": "Link copiato"
111
+ },
112
+ "latestPosts": "Ultimi post",
113
+ "latestPostsDescription": "Una selezione di articoli scelti dalla redazione.",
114
+ "categories": "Categorie",
115
+ "categoriesDescription": "Vai dritto agli argomenti che ti interessano.",
116
+ "tags": "Tag",
117
+ "tagsDescription": "Fili più piccoli che attraversano le categorie.",
118
+ "allPosts": "Tutti i post",
119
+ "allPostsDescription": "Tutto quello che abbiamo pubblicato, dal più recente.",
120
+ "viewAll": "Vedi tutti",
121
+ "readAlso": "Leggi anche",
122
+ "exploreCategories": "Esplora le categorie",
123
+ "exploreAllCategories": "Esplora tutte le categorie",
124
+ "breadcrumbHome": "Home",
125
+ "breadcrumbCategories": "Categorie",
126
+ "breadcrumbTags": "Tag",
127
+ "stats": {
128
+ "posts": "post | post",
129
+ "categories": "categoria | categorie",
130
+ "tags": "tag | tag",
131
+ "uses": "utilizzo | utilizzi"
111
132
  }
112
133
  },
113
134
  "errorPage": {
package/src/i18n/ja.json CHANGED
@@ -108,6 +108,27 @@
108
108
  "telegram": "Telegram で共有",
109
109
  "copy": "リンクをコピー",
110
110
  "copied": "リンクをコピーしました"
111
+ },
112
+ "latestPosts": "最新の記事",
113
+ "latestPostsDescription": "編集部が選んだ記事です。",
114
+ "categories": "カテゴリー",
115
+ "categoriesDescription": "気になるトピックへすぐに移動できます。",
116
+ "tags": "タグ",
117
+ "tagsDescription": "カテゴリーを横断する小さなテーマです。",
118
+ "allPosts": "すべての記事",
119
+ "allPostsDescription": "これまでに公開したすべての記事を新しい順に。",
120
+ "viewAll": "すべて見る",
121
+ "readAlso": "あわせて読みたい",
122
+ "exploreCategories": "カテゴリーを見る",
123
+ "exploreAllCategories": "すべてのカテゴリーを見る",
124
+ "breadcrumbHome": "ホーム",
125
+ "breadcrumbCategories": "カテゴリー",
126
+ "breadcrumbTags": "タグ",
127
+ "stats": {
128
+ "posts": "件の記事",
129
+ "categories": "カテゴリー",
130
+ "tags": "タグ",
131
+ "uses": "回使用"
111
132
  }
112
133
  },
113
134
  "errorPage": {
package/src/i18n/pt.json CHANGED
@@ -108,6 +108,27 @@
108
108
  "telegram": "Partilhar no Telegram",
109
109
  "copy": "Copiar link",
110
110
  "copied": "Link copiado"
111
+ },
112
+ "latestPosts": "Publicações recentes",
113
+ "latestPostsDescription": "Uma seleção de artigos escolhidos pela redação.",
114
+ "categories": "Categorias",
115
+ "categoriesDescription": "Vá direto aos temas que lhe interessam.",
116
+ "tags": "Tags",
117
+ "tagsDescription": "Fios menores que atravessam as categorias.",
118
+ "allPosts": "Todas as publicações",
119
+ "allPostsDescription": "Tudo o que publicamos, do mais recente ao mais antigo.",
120
+ "viewAll": "Ver tudo",
121
+ "readAlso": "Leia também",
122
+ "exploreCategories": "Explore as categorias",
123
+ "exploreAllCategories": "Explore todas as categorias",
124
+ "breadcrumbHome": "Início",
125
+ "breadcrumbCategories": "Categorias",
126
+ "breadcrumbTags": "Tags",
127
+ "stats": {
128
+ "posts": "publicação | publicações",
129
+ "categories": "categoria | categorias",
130
+ "tags": "tag | tags",
131
+ "uses": "uso | usos"
111
132
  }
112
133
  },
113
134
  "errorPage": {
package/src/i18n/ru.json CHANGED
@@ -108,6 +108,27 @@
108
108
  "telegram": "Поделиться в Telegram",
109
109
  "copy": "Копировать ссылку",
110
110
  "copied": "Ссылка скопирована"
111
+ },
112
+ "latestPosts": "Последние статьи",
113
+ "latestPostsDescription": "Подборка статей от редакции.",
114
+ "categories": "Категории",
115
+ "categoriesDescription": "Переходите сразу к интересующим вас темам.",
116
+ "tags": "Теги",
117
+ "tagsDescription": "Небольшие темы, проходящие через разные категории.",
118
+ "allPosts": "Все статьи",
119
+ "allPostsDescription": "Всё, что мы опубликовали, сначала новые.",
120
+ "viewAll": "Смотреть все",
121
+ "readAlso": "Читайте также",
122
+ "exploreCategories": "Исследуйте категории",
123
+ "exploreAllCategories": "Все категории",
124
+ "breadcrumbHome": "Главная",
125
+ "breadcrumbCategories": "Категории",
126
+ "breadcrumbTags": "Теги",
127
+ "stats": {
128
+ "posts": "статья | статей",
129
+ "categories": "категория | категорий",
130
+ "tags": "тег | тегов",
131
+ "uses": "использование | использований"
111
132
  }
112
133
  },
113
134
  "errorPage": {
package/src/i18n/zh.json CHANGED
@@ -108,6 +108,27 @@
108
108
  "telegram": "分享到 Telegram",
109
109
  "copy": "复制链接",
110
110
  "copied": "链接已复制"
111
+ },
112
+ "latestPosts": "最新文章",
113
+ "latestPostsDescription": "编辑精选的文章。",
114
+ "categories": "分类",
115
+ "categoriesDescription": "直接跳转到你关心的主题。",
116
+ "tags": "标签",
117
+ "tagsDescription": "贯穿各个分类的小主题。",
118
+ "allPosts": "全部文章",
119
+ "allPostsDescription": "我们发布的所有内容,最新的在前。",
120
+ "viewAll": "查看全部",
121
+ "readAlso": "延伸阅读",
122
+ "exploreCategories": "浏览分类",
123
+ "exploreAllCategories": "浏览全部分类",
124
+ "breadcrumbHome": "首页",
125
+ "breadcrumbCategories": "分类",
126
+ "breadcrumbTags": "标签",
127
+ "stats": {
128
+ "posts": "篇文章",
129
+ "categories": "个分类",
130
+ "tags": "个标签",
131
+ "uses": "次使用"
111
132
  }
112
133
  },
113
134
  "errorPage": {