@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 +1 -0
- package/COMPONENTS.md +108 -14
- package/README.md +5 -5
- package/package.json +1 -1
- package/src/components/blog/BlogCategoriesPage.vue +46 -0
- package/src/components/blog/BlogHeaderSection.vue +2 -2
- package/src/components/blog/BlogIndexPage.vue +124 -0
- package/src/components/blog/BlogInfoSection.vue +4 -4
- package/src/components/blog/BlogPageBreadcrumbs.vue +43 -0
- package/src/components/blog/BlogPostPage.vue +116 -0
- package/src/components/blog/BlogPostsPage.vue +74 -0
- package/src/components/blog/BlogTagsPage.vue +46 -0
- package/src/components/blog/BlogViewAllLink.vue +18 -0
- package/src/components/blog/CategoriesList.vue +2 -2
- package/src/components/blog/HeaderCarousel.vue +8 -8
- package/src/components/blog/PostsList.vue +2 -2
- package/src/components/blog/blogPageUtils.ts +98 -0
- package/src/components/blog/index.ts +12 -1
- package/src/components/dashboard-shell/DashboardShell.vue +13 -26
- package/src/components/landing/landing-footer/LandingFooter.vue +13 -26
- package/src/components/landing/landing-navbar/LandingNavbar.vue +3 -2
- package/src/components/landing/landing-shell/LandingShell.vue +2 -1
- package/src/components/text-link/TextLink.vue +5 -19
- package/src/i18n/de.json +21 -0
- package/src/i18n/en.json +21 -0
- package/src/i18n/es.json +21 -0
- package/src/i18n/fr.json +21 -0
- package/src/i18n/it.json +21 -0
- package/src/i18n/ja.json +21 -0
- package/src/i18n/pt.json +21 -0
- package/src/i18n/ru.json +21 -0
- package/src/i18n/zh.json +21 -0
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
|
|
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.
|
|
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"
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
155
|
-
{ id: 'settings', label: '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
|
-
-
|
|
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
|
|
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
|
@@ -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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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>
|