@brumaombra/ui-vintage 0.9.2 → 0.9.4

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/AGENTS.md CHANGED
@@ -33,6 +33,7 @@ Only build something new when no entry point fits, and compose it from library p
33
33
  - Icons are Hugeicons definitions: import each one from its own module (`import Rocket01Icon from '@hugeicons/core-free-icons/Rocket01Icon'`, never the `@hugeicons/core-free-icons` barrel, which makes the build parse the whole icon set) and pass them to `:icon` props, or render them with `HugeiconsIcon` from `@hugeicons/vue`. Both packages are peer dependencies, so the app must list them in its own `package.json`.
34
34
  - Form controls use `v-model`. Mark invalid fields with `aria-invalid` and show messages with `FieldError`.
35
35
  - Library strings are translated through vue-i18n (`uiVintage.*` keys). App copy belongs in the app's own locale files.
36
+ - Pass links as plain paths (`to: '/settings'`, `app-link-to="/"`), never `localePath()` results: the components render `NuxtLinkLocale`, which adds the language prefix and navigates client-side. External URLs work in the same props. For your own link buttons, use `<Button as-child>` around a `NuxtLinkLocale`.
36
37
 
37
38
  ## 4. Check your work
38
39
 
package/COMPONENTS.md CHANGED
@@ -255,7 +255,7 @@ import { LoadMoreButton } from '@brumaombra/ui-vintage/load-more-button';
255
255
 
256
256
  ### `@brumaombra/ui-vintage/text-link`
257
257
 
258
- Inline text link that renders an anchor or a router link component (pass `NuxtLink` as `link-component`).
258
+ Inline text link for an internal path (localized with `NuxtLinkLocale`) or an external URL.
259
259
 
260
260
  ```ts
261
261
  import { TextLink } from '@brumaombra/ui-vintage/text-link';
@@ -267,7 +267,6 @@ import { TextLink } from '@brumaombra/ui-vintage/text-link';
267
267
  | --- | --- | --- |
268
268
  | `to` *(required)* | `string` | |
269
269
  | `text` *(required)* | `string` | |
270
- | `linkComponent` | `Component \| string` | `'a'` |
271
270
 
272
271
  - **Emits:** `click` (event: MouseEvent).
273
272
 
@@ -3057,14 +3056,14 @@ import { BackgroundGrid } from '@brumaombra/ui-vintage/background-grid';
3057
3056
 
3058
3057
  ### `@brumaombra/ui-vintage/dashboard-shell`
3059
3058
 
3060
- Complete dashboard layout: collapsible sidebar built from `sidebar-sections`, mobile sheet, sticky topbar, and grid background. Pass `NuxtLink` as `sidebar-link-component` for client-side navigation.
3059
+ Complete dashboard layout: collapsible sidebar built from `sidebar-sections`, mobile sheet, sticky topbar, and grid background. Item `to` paths are localized and navigate client-side (`NuxtLinkLocale`).
3061
3060
 
3062
3061
  ```ts
3063
3062
  import { DashboardShell } from '@brumaombra/ui-vintage/dashboard-shell';
3064
3063
  ```
3065
3064
 
3066
3065
  ```vue
3067
- <DashboardShell title="Projects" :sidebar-sections="sections" :sidebar-link-component="NuxtLink">
3066
+ <DashboardShell title="Projects" :sidebar-sections="sections">
3068
3067
  <template #topbar-trailing>
3069
3068
  <ThemeSelector />
3070
3069
  </template>
@@ -3084,8 +3083,7 @@ import { DashboardShell } from '@brumaombra/ui-vintage/dashboard-shell';
3084
3083
  | `description` | `string` | `''` |
3085
3084
  | `collapsible` | `'offcanvas' \| 'icon' \| 'none'` | `'offcanvas'` |
3086
3085
  | `compact` | `boolean` | `false` |
3087
- | `sidebarSections` | `Array<{ id: string; label?: string; items: Array<{ id: string; label: string; description?: string; icon?: HugeiconsIconDefinition \| null; href?: string; to?: unknown; active?: boolean; newTab?: boolean }> }>` | `[]` |
3088
- | `sidebarLinkComponent` | `Component \| string` | `'a'` |
3086
+ | `sidebarSections` | `Array<{ id: string; label?: string; items: Array<{ id: string; label: string; description?: string; icon?: HugeiconsIconDefinition \| null; to: string; active?: boolean; newTab?: boolean }> }>` | `[]` |
3089
3087
  | `showBackground` | `boolean` | `true` |
3090
3088
  | `insetClass` | `HTMLAttributes['class']` | |
3091
3089
  | `topbarClass` | `HTMLAttributes['class']` | |
@@ -3178,7 +3176,6 @@ import type { LandingFooterLink, LandingFooterSection } from '@brumaombra/ui-vin
3178
3176
  | `logoClass` | `HTMLAttributes['class']` | |
3179
3177
  | `appDescription` | `string` | `''` |
3180
3178
  | `sections` | `LandingFooterSection[]` | `[]` |
3181
- | `linkComponent` | `Component \| string` | `'a'` |
3182
3179
  | `authorName` | `string` | `''` |
3183
3180
  | `authorLink` | `string` | `''` |
3184
3181
  | `year` | `number` | `new Date().getFullYear()` |
@@ -3196,8 +3193,7 @@ import type { LandingFooterLink, LandingFooterSection } from '@brumaombra/ui-vin
3196
3193
  interface LandingFooterLink {
3197
3194
  id: string;
3198
3195
  label: string;
3199
- href?: string;
3200
- to?: unknown;
3196
+ to: string;
3201
3197
  newTab?: boolean;
3202
3198
  }
3203
3199
  interface LandingFooterSection {
@@ -3244,7 +3240,6 @@ import type { LandingFooterLink, LandingFooterSection } from '@brumaombra/ui-vin
3244
3240
  | `logoClass` | `HTMLAttributes['class']` | |
3245
3241
  | `appDescription` | `string` | `''` |
3246
3242
  | `sections` | `LandingFooterSection[]` | `[]` |
3247
- | `linkComponent` | `Component \| string` | `'a'` |
3248
3243
  | `authorName` | `string` | `''` |
3249
3244
  | `authorLink` | `string` | `''` |
3250
3245
  | `year` | `number` | `new Date().getFullYear()` |
@@ -3262,8 +3257,7 @@ import type { LandingFooterLink, LandingFooterSection } from '@brumaombra/ui-vin
3262
3257
  interface LandingFooterLink {
3263
3258
  id: string;
3264
3259
  label: string;
3265
- href?: string;
3266
- to?: unknown;
3260
+ to: string;
3267
3261
  newTab?: boolean;
3268
3262
  }
3269
3263
  interface LandingFooterSection {
@@ -3466,10 +3460,11 @@ Components for Nuxt Content pages and a complete blog.
3466
3460
 
3467
3461
  ### `@brumaombra/ui-vintage/blog`
3468
3462
 
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.
3463
+ 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
3464
 
3471
3465
  ```ts
3472
- import { AllPostsSection, BlogContentRenderer, BlogFAQSection, BlogInfoSection, BlogPostHeader, BlogHeaderSection, BlogSectionTitle, BlogTagsSection, CategoriesList, CategoriesSection, CategoryCard, HeaderCarousel, PostCard, PostsList, SocialShareSidebar, TableOfContents } from '@brumaombra/ui-vintage/blog';
3466
+ 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';
3467
+ import type { BlogPostData, BlogCategoryData, BlogTagData } from '@brumaombra/ui-vintage/blog';
3473
3468
  ```
3474
3469
 
3475
3470
  #### `AllPostsSection`
@@ -3611,6 +3606,105 @@ import { AllPostsSection, BlogContentRenderer, BlogFAQSection, BlogInfoSection,
3611
3606
  | --- | --- | --- |
3612
3607
  | `content` *(required)* | `{ body?: { toc?: { links?: BlogTocLink[] } }; toc?: { links?: BlogTocLink[] } }` | |
3613
3608
 
3609
+ #### `BlogIndexPage`
3610
+
3611
+ | Prop | Type | Default |
3612
+ | --- | --- | --- |
3613
+ | `title` *(required)* | `string` | |
3614
+ | `highlight` | `string` | `''` |
3615
+ | `description` *(required)* | `string` | |
3616
+ | `featuredPosts` | `BlogListPost[]` | `[]` |
3617
+ | `categories` | `BlogCategoryData[]` | `[]` |
3618
+ | `tags` | `BlogTagData[]` | `[]` |
3619
+ | `posts` | `BlogListPost[]` | `[]` |
3620
+ | `totalPosts` | `number` | `0` |
3621
+ | `currentPage` | `number` | `1` |
3622
+ | `postsPerPage` | `number` | `9` |
3623
+ | `hasMorePosts` | `boolean` | `false` |
3624
+ | `isLoading` | `boolean` | `false` |
3625
+
3626
+ - **Emits:** `load-more`.
3627
+
3628
+ #### `BlogPostPage`
3629
+
3630
+ | Prop | Type | Default |
3631
+ | --- | --- | --- |
3632
+ | `post` *(required)* | `Record<string, any>` | |
3633
+ | `url` *(required)* | `string` | |
3634
+ | `relatedPosts` | `Array<{ path: string; title: string; description?: string; image?: string; categoryText?: string }>` | `[]` |
3635
+ | `relatedCategories` | `BlogCategoryData[]` | `[]` |
3636
+
3637
+ - **Slots:** `after-article`.
3638
+
3639
+ #### `BlogCategoriesPage`
3640
+
3641
+ | Prop | Type | Default |
3642
+ | --- | --- | --- |
3643
+ | `description` *(required)* | `string` | |
3644
+ | `categories` | `BlogCategoryData[]` | `[]` |
3645
+
3646
+ #### `BlogTagsPage`
3647
+
3648
+ | Prop | Type | Default |
3649
+ | --- | --- | --- |
3650
+ | `description` *(required)* | `string` | |
3651
+ | `tags` | `BlogTagData[]` | `[]` |
3652
+
3653
+ #### `BlogPostsPage`
3654
+
3655
+ | Prop | Type | Default |
3656
+ | --- | --- | --- |
3657
+ | `section` *(required)* | `'categories' \| 'tags'` | |
3658
+ | `title` *(required)* | `string` | |
3659
+ | `description` *(required)* | `string` | |
3660
+ | `posts` | `Array<{ path: string; title: string; description?: string; image?: string; categoryText?: string }>` | `[]` |
3661
+ | `totalPosts` | `number` | `0` |
3662
+ | `currentPage` | `number` | `1` |
3663
+ | `postsPerPage` | `number` | `9` |
3664
+ | `hasMorePosts` | `boolean` | `false` |
3665
+ | `isLoading` | `boolean` | `false` |
3666
+
3667
+ - **Emits:** `load-more`.
3668
+
3669
+ #### `BlogViewAllLink`
3670
+
3671
+ | Prop | Type | Default |
3672
+ | --- | --- | --- |
3673
+ | `to` *(required)* | `string` | |
3674
+ | `label` *(required)* | `string` | |
3675
+
3676
+ #### Functions, constants, and types
3677
+
3678
+ ```ts
3679
+ function slugify(value: unknown)
3680
+ function normalizeContentPath(path: string)
3681
+ function buildCategoriesFromPosts(posts: BlogPostData[], getPath: (slug: string) => string = slug => `/blog/categories/${slug}`): BlogCategoryData[]
3682
+ function buildTagsFromPosts(posts: BlogPostData[]): BlogTagData[]
3683
+ function sortByStableSeed(items: T[], seed: string, getKey: (item: T) => string)
3684
+ interface BlogPostData {
3685
+ path?: string;
3686
+ title?: string;
3687
+ description?: string;
3688
+ image?: string;
3689
+ categoryText?: string;
3690
+ categorySlug?: string;
3691
+ tags?: string[];
3692
+ [key: string]: unknown;
3693
+ }
3694
+ interface BlogCategoryData {
3695
+ name: string;
3696
+ slug: string;
3697
+ path: string;
3698
+ count: number;
3699
+ image?: string;
3700
+ }
3701
+ interface BlogTagData {
3702
+ name: string;
3703
+ slug: string;
3704
+ count: number;
3705
+ }
3706
+ ```
3707
+
3614
3708
  ---
3615
3709
 
3616
3710
  ### `@brumaombra/ui-vintage/content`
package/README.md CHANGED
@@ -140,7 +140,7 @@ if (confirmed) {
140
140
 
141
141
  ### Build a layout
142
142
 
143
- Shells own the layout and responsive behavior; your app owns the navigation data and the content:
143
+ Shells own the layout and responsive behavior; your app owns the navigation data and the content. Links take plain paths (`to: '/settings'`): the components render `NuxtLinkLocale`, which adds the current language prefix and navigates client-side, and leaves external URLs as they are:
144
144
 
145
145
  ```vue
146
146
  <script setup>
@@ -151,8 +151,8 @@ const sidebarSections = [{
151
151
  id: 'workspace',
152
152
  label: 'Workspace',
153
153
  items: [
154
- { id: 'overview', label: 'Overview', href: '/', active: true },
155
- { id: 'settings', label: 'Settings', href: '/settings' }
154
+ { id: 'overview', label: 'Overview', to: '/', active: true },
155
+ { id: 'settings', label: 'Settings', to: '/settings' }
156
156
  ]
157
157
  }];
158
158
  </script>
@@ -402,7 +402,7 @@ New entry points need an export in `package.json` and a description and category
402
402
  - Nuxt 4 and Vue 3.5 or newer.
403
403
  - Tailwind CSS v4 with `@tailwindcss/vite` registered in `vite.plugins`.
404
404
  - `@hugeicons/core-free-icons` 4.3 or newer and `@hugeicons/vue` 1 (the icons you pass to `:icon` props, and the ones the components render). Import each icon from its own module, as in the example above: the package barrel makes the build parse the whole icon set.
405
- - `vue-i18n` 9, 10, or 11 with an i18n setup, such as `@nuxtjs/i18n` (the components call `useI18n()`).
405
+ - `@nuxtjs/i18n` 10 (the components call `useI18n()`, and every link renders `NuxtLinkLocale`).
406
406
  - `@nuxt/image` 2 for image components (the module installs it when needed).
407
407
  - `@nuxt/content` 3, only for the `blog` and `content` components.
408
408
 
@@ -411,7 +411,7 @@ New entry points need an export in `package.json` and a description and category
411
411
  - **Components can't resolve Nuxt imports:** check that the app runs Nuxt 4 and that `@brumaombra/ui-vintage` is registered in `nuxt.config`.
412
412
  - **Styles are missing:** register the module once, add `tailwindcss()` from `@tailwindcss/vite` to `vite.plugins`, and restart the dev server after changing `nuxt.config`.
413
413
  - **`NuxtImg` is unavailable:** install `@nuxt/image`, or let the module install it during setup.
414
- - **`useI18n` errors or missing translations:** register `@nuxtjs/i18n` (or install Vue I18n yourself); the components need an i18n composer, and the library messages are merged into it.
414
+ - **`useI18n` errors, missing translations, or `NuxtLinkLocale` failing to resolve:** register `@nuxtjs/i18n`; the components need its composer and link component, and the library messages are merged into it.
415
415
  - **An import fails:** use the public subpath from `package.json`, such as `@brumaombra/ui-vintage/button`, never an internal `src/` path.
416
416
 
417
417
  ## License
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.4",
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>
@@ -81,7 +81,7 @@ const underlineDelay = computed(() => `${beforeWords.value.length * 55 + 600}ms`
81
81
  <template>
82
82
  <header :class="cn('relative flex flex-col gap-6 pt-6 md:pt-12', alignClass.header)">
83
83
  <!-- Announcement (links to something new, such as the latest post) -->
84
- <NuxtLink v-if="props.announcement" :to="props.announcement.to" class="group/announce max-w-full animate-uv-fade-up rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
84
+ <NuxtLinkLocale v-if="props.announcement" :to="props.announcement.to" class="group/announce max-w-full animate-uv-fade-up rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
85
85
  <span class="inline-flex max-w-full items-center gap-2 rounded border border-warning/35 bg-warning/10 px-3 py-1 text-xs text-foreground transition-colors duration-150 group-hover/announce:border-warning/70">
86
86
  <span aria-hidden="true" class="relative flex size-1.5 shrink-0 text-warning">
87
87
  <span class="absolute inline-flex size-full animate-uv-ping-soft rounded-full bg-current opacity-60" />
@@ -91,7 +91,7 @@ const underlineDelay = computed(() => `${beforeWords.value.length * 55 + 600}ms`
91
91
  <span class="truncate opacity-80">{{ props.announcement.text }}</span>
92
92
  <HugeiconsIcon :icon="ArrowRight01Icon" class="size-3.5 shrink-0 transition-transform duration-300 ease-spring group-hover/announce:translate-x-0.5" />
93
93
  </span>
94
- </NuxtLink>
94
+ </NuxtLinkLocale>
95
95
 
96
96
  <!-- Badges -->
97
97
  <div v-if="props.badges.length > 0" :class="cn('flex flex-wrap gap-2', alignClass.row)">
@@ -0,0 +1,124 @@
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 (the links localize the paths)
17
+ type BlogListPost = {
18
+ path: string;
19
+ title: string;
20
+ description?: string;
21
+ image?: string;
22
+ categoryText?: string;
23
+ categorySlug?: 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
+ // Featured posts with the link to their category page
61
+ const carouselPosts = computed(() => props.featuredPosts.map(post => ({
62
+ ...post,
63
+ categoryPath: post.categorySlug ? `/blog/categories/${post.categorySlug}` : undefined
64
+ })));
65
+
66
+ // Announce the latest post in the header
67
+ const headerAnnouncement = computed(() => props.featuredPosts[0] ? { text: props.featuredPosts[0].title, to: props.featuredPosts[0].path } : null);
68
+
69
+ // Blog totals shown under the header
70
+ const headerStats = computed(() => [
71
+ { icon: News01Icon, value: props.totalPosts, label: t('uiVintage.blog.stats.posts', props.totalPosts) },
72
+ { icon: FolderLibraryIcon, value: props.categories.length, label: t('uiVintage.blog.stats.categories', props.categories.length) },
73
+ { icon: Tag01Icon, value: props.tags.length, label: t('uiVintage.blog.stats.tags', props.tags.length) }
74
+ ]);
75
+ </script>
76
+
77
+ <template>
78
+ <div class="space-y-16 md:space-y-24">
79
+ <!-- Header section -->
80
+ <BlogHeaderSection :announcement="headerAnnouncement"
81
+ :title="props.title"
82
+ :highlight="props.highlight"
83
+ :description="props.description"
84
+ :stats="headerStats" />
85
+
86
+ <!-- Header carousel -->
87
+ <section>
88
+ <BlogSectionTitle :title="t('uiVintage.blog.latestPosts')" :description="t('uiVintage.blog.latestPostsDescription')" />
89
+ <HeaderCarousel :featured-posts="carouselPosts" />
90
+ </section>
91
+
92
+ <!-- Categories section -->
93
+ <section>
94
+ <BlogSectionTitle :title="t('uiVintage.blog.categories')" :description="t('uiVintage.blog.categoriesDescription')">
95
+ <template #action>
96
+ <BlogViewAllLink to="/blog/categories" :label="t('uiVintage.blog.viewAll')" />
97
+ </template>
98
+ </BlogSectionTitle>
99
+ <CategoriesSection :categories="props.categories" />
100
+ </section>
101
+
102
+ <!-- Tags section -->
103
+ <section v-if="props.tags.length">
104
+ <BlogSectionTitle :title="t('uiVintage.blog.tags')" :description="t('uiVintage.blog.tagsDescription')">
105
+ <template #action>
106
+ <BlogViewAllLink to="/blog/tags" :label="t('uiVintage.blog.viewAll')" />
107
+ </template>
108
+ </BlogSectionTitle>
109
+ <BlogTagsSection :tags="props.tags" />
110
+ </section>
111
+
112
+ <!-- All posts section -->
113
+ <section>
114
+ <BlogSectionTitle :title="t('uiVintage.blog.allPosts')" :description="t('uiVintage.blog.allPostsDescription')" />
115
+ <AllPostsSection :posts="props.posts"
116
+ :current-page="props.currentPage"
117
+ :posts-per-page="props.postsPerPage"
118
+ :total-posts="props.totalPosts"
119
+ :has-more-posts="props.hasMorePosts"
120
+ :is-loading="props.isLoading"
121
+ @load-more="emit('load-more')" />
122
+ </section>
123
+ </div>
124
+ </template>
@@ -38,17 +38,17 @@ const showModifiedDate = computed(() => Boolean(props.dateModified) && formatted
38
38
  <!-- Author -->
39
39
  <div :class="['flex gap-3 px-5 py-4', props.authorBio ? 'items-start' : 'items-center']">
40
40
  <!-- Author image -->
41
- <NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="shrink-0 rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
41
+ <NuxtLinkLocale :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="shrink-0 rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
42
42
  <NuxtImg :src="props.authorImageUrl" :alt="props.author" height="44" width="44" format="avif" quality="40" :sizes="{ 480: '44px', 1280: '44px' }" loading="lazy" decoding="async" class="size-11 rounded border border-border object-cover" />
43
- </NuxtLink>
43
+ </NuxtLinkLocale>
44
44
 
45
45
  <!-- Author name and bio -->
46
46
  <div class="min-w-0 flex-1">
47
47
  <p class="text-[11px] font-semibold tracking-wider text-muted-foreground uppercase">{{ t('uiVintage.blog.author') }}</p>
48
- <NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="group/link inline-flex items-center gap-1 text-sm font-semibold text-foreground outline-none transition-colors hover:text-primary focus-visible:underline md:text-base">
48
+ <NuxtLinkLocale :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="group/link inline-flex items-center gap-1 text-sm font-semibold text-foreground outline-none transition-colors hover:text-primary focus-visible:underline md:text-base">
49
49
  {{ props.author }}
50
50
  <HugeiconsIcon :icon="ArrowUpRight01Icon" class="size-4 text-muted-foreground transition-[translate,color] duration-300 ease-spring group-hover/link:translate-x-0.5 group-hover/link:-translate-y-0.5 group-hover/link:text-primary" />
51
- </NuxtLink>
51
+ </NuxtLinkLocale>
52
52
  <p v-if="props.authorBio" class="mt-1.5 text-xs leading-relaxed text-muted-foreground md:text-sm">
53
53
  {{ props.authorBio }}
54
54
  </p>
@@ -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>