@brumaombra/ui-vintage 0.9.3 → 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 +7 -13
- package/README.md +5 -5
- package/package.json +1 -1
- package/src/components/blog/BlogHeaderSection.vue +2 -2
- package/src/components/blog/BlogIndexPage.vue +9 -3
- package/src/components/blog/BlogInfoSection.vue +4 -4
- package/src/components/blog/BlogPostsPage.vue +1 -1
- 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 +2 -2
- 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/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 {
|
|
@@ -3684,7 +3678,7 @@ import type { BlogPostData, BlogCategoryData, BlogTagData } from '@brumaombra/ui
|
|
|
3684
3678
|
```ts
|
|
3685
3679
|
function slugify(value: unknown)
|
|
3686
3680
|
function normalizeContentPath(path: string)
|
|
3687
|
-
function buildCategoriesFromPosts(posts: BlogPostData[], getPath: (slug: string) => string): BlogCategoryData[]
|
|
3681
|
+
function buildCategoriesFromPosts(posts: BlogPostData[], getPath: (slug: string) => string = slug => `/blog/categories/${slug}`): BlogCategoryData[]
|
|
3688
3682
|
function buildTagsFromPosts(posts: BlogPostData[]): BlogTagData[]
|
|
3689
3683
|
function sortByStableSeed(items: T[], seed: string, getKey: (item: T) => string)
|
|
3690
3684
|
interface BlogPostData {
|
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
|
@@ -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)">
|
|
@@ -13,14 +13,14 @@ import BlogViewAllLink from './BlogViewAllLink.vue';
|
|
|
13
13
|
import CategoriesSection from './CategoriesSection.vue';
|
|
14
14
|
import HeaderCarousel from './HeaderCarousel.vue';
|
|
15
15
|
|
|
16
|
-
// Post shown in the carousel and in the list (
|
|
16
|
+
// Post shown in the carousel and in the list (the links localize the paths)
|
|
17
17
|
type BlogListPost = {
|
|
18
18
|
path: string;
|
|
19
19
|
title: string;
|
|
20
20
|
description?: string;
|
|
21
21
|
image?: string;
|
|
22
22
|
categoryText?: string;
|
|
23
|
-
|
|
23
|
+
categorySlug?: string;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
// Props
|
|
@@ -57,6 +57,12 @@ const emit = defineEmits<{
|
|
|
57
57
|
|
|
58
58
|
const { t } = useI18n();
|
|
59
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
|
+
|
|
60
66
|
// Announce the latest post in the header
|
|
61
67
|
const headerAnnouncement = computed(() => props.featuredPosts[0] ? { text: props.featuredPosts[0].title, to: props.featuredPosts[0].path } : null);
|
|
62
68
|
|
|
@@ -80,7 +86,7 @@ const headerStats = computed(() => [
|
|
|
80
86
|
<!-- Header carousel -->
|
|
81
87
|
<section>
|
|
82
88
|
<BlogSectionTitle :title="t('uiVintage.blog.latestPosts')" :description="t('uiVintage.blog.latestPostsDescription')" />
|
|
83
|
-
<HeaderCarousel :featured-posts="
|
|
89
|
+
<HeaderCarousel :featured-posts="carouselPosts" />
|
|
84
90
|
</section>
|
|
85
91
|
|
|
86
92
|
<!-- Categories section -->
|
|
@@ -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>
|
|
@@ -7,7 +7,7 @@ import BlogHeaderSection from './BlogHeaderSection.vue';
|
|
|
7
7
|
import BlogPageBreadcrumbs from './BlogPageBreadcrumbs.vue';
|
|
8
8
|
import PostsList from './PostsList.vue';
|
|
9
9
|
|
|
10
|
-
// Props (the posts of a category or a tag, their paths
|
|
10
|
+
// Props (the posts of a category or a tag, the links localize their paths)
|
|
11
11
|
const props = withDefaults(defineProps<{
|
|
12
12
|
section: 'categories' | 'tags';
|
|
13
13
|
title: string;
|
|
@@ -18,8 +18,8 @@ const props = withDefaults(defineProps<{
|
|
|
18
18
|
<template>
|
|
19
19
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 md:gap-6 lg:grid-cols-3 xl:grid-cols-4">
|
|
20
20
|
<!-- Category card -->
|
|
21
|
-
<
|
|
21
|
+
<NuxtLinkLocale v-for="(category, index) in props.categories" :key="category.path || category.slug" data-aos="blur-up" :data-aos-delay="(index % 4) * 80" :to="category.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
|
|
22
22
|
<CategoryCard :name="category.name" :image="category.image" :count="category.count" />
|
|
23
|
-
</
|
|
23
|
+
</NuxtLinkLocale>
|
|
24
24
|
</div>
|
|
25
25
|
</template>
|
|
@@ -112,10 +112,10 @@ onUnmounted(() => {
|
|
|
112
112
|
<div class="relative h-[28rem] w-full md:h-[32rem]">
|
|
113
113
|
<div v-for="(post, index) in props.featuredPosts" :id="`${slideIdPrefix}-${index}`" :key="post.path || post.title || index" role="group" aria-roledescription="slide" :aria-label="`${index + 1} / ${slideCount}`" :aria-hidden="currentSlide !== index" :inert="currentSlide !== index || undefined" :class="cn('absolute inset-0 transition-[opacity,scale] duration-700 ease-out-expo', currentSlide === index ? 'z-10 scale-100 opacity-100' : 'z-0 scale-[1.02] opacity-0')">
|
|
114
114
|
<!-- Slide image with a slow zoom -->
|
|
115
|
-
<
|
|
115
|
+
<NuxtLinkLocale :to="post.path" tabindex="-1" aria-hidden="true" class="absolute inset-0 overflow-hidden">
|
|
116
116
|
<NuxtImg v-if="post.image" :src="post.image" :alt="post.title" width="1200" height="640" format="avif" quality="45" :sizes="{ 480: '480px', 1536: '1152px' }" :loading="index === 0 ? 'eager' : 'lazy'" :fetchpriority="index === 0 ? 'high' : 'auto'" :preload="index === 0" :class="cn('size-full object-cover', currentSlide === index && 'animate-uv-ken-burns')" />
|
|
117
117
|
<div v-else class="size-full bg-[radial-gradient(circle_at_20%_20%,color-mix(in_oklab,var(--primary)_45%,transparent),transparent_60%)] bg-surface" />
|
|
118
|
-
</
|
|
118
|
+
</NuxtLinkLocale>
|
|
119
119
|
|
|
120
120
|
<!-- Legibility shade -->
|
|
121
121
|
<div aria-hidden="true" class="pointer-events-none absolute inset-0 bg-linear-to-t from-black/90 via-black/45 to-black/5" />
|
|
@@ -124,16 +124,16 @@ onUnmounted(() => {
|
|
|
124
124
|
<!-- Slide content (replays its entrance every time the slide becomes active) -->
|
|
125
125
|
<div class="absolute inset-x-0 bottom-0 flex flex-col gap-4 p-6 pb-20 text-white md:max-w-3xl md:p-10 md:pb-24">
|
|
126
126
|
<!-- Category badge -->
|
|
127
|
-
<
|
|
127
|
+
<NuxtLinkLocale v-if="post.categoryText && post.categoryPath" :to="post.categoryPath" :class="cn('w-fit', currentSlide === index && 'animate-uv-fade-up')">
|
|
128
128
|
<Badge :text="post.categoryText" pulse class="border-white/25 bg-white/15 text-white backdrop-blur-md transition-colors hover:bg-white/25" />
|
|
129
|
-
</
|
|
129
|
+
</NuxtLinkLocale>
|
|
130
130
|
|
|
131
131
|
<!-- Title -->
|
|
132
|
-
<
|
|
132
|
+
<NuxtLinkLocale :to="post.path" :class="cn('outline-none', currentSlide === index && 'animate-uv-fade-up [animation-delay:90ms]')">
|
|
133
133
|
<h3 class="line-clamp-3 text-2xl leading-tight font-bold tracking-tight text-balance drop-shadow-sm md:text-5xl">
|
|
134
134
|
{{ post.title }}
|
|
135
135
|
</h3>
|
|
136
|
-
</
|
|
136
|
+
</NuxtLinkLocale>
|
|
137
137
|
|
|
138
138
|
<!-- Description -->
|
|
139
139
|
<p v-if="post.description" :class="cn('line-clamp-2 max-w-2xl text-xs leading-relaxed text-white/80 md:text-base', currentSlide === index && 'animate-uv-fade-up [animation-delay:180ms]')">
|
|
@@ -143,10 +143,10 @@ onUnmounted(() => {
|
|
|
143
143
|
<!-- Read post button -->
|
|
144
144
|
<div :class="cn(currentSlide === index && 'animate-uv-fade-up [animation-delay:260ms]')">
|
|
145
145
|
<Button as-child size="lg" class="group/cta">
|
|
146
|
-
<
|
|
146
|
+
<NuxtLinkLocale :to="post.path">
|
|
147
147
|
{{ t('uiVintage.blog.goToPost') }}
|
|
148
148
|
<HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/cta:translate-x-1" />
|
|
149
|
-
</
|
|
149
|
+
</NuxtLinkLocale>
|
|
150
150
|
</Button>
|
|
151
151
|
</div>
|
|
152
152
|
</div>
|
|
@@ -19,8 +19,8 @@ const props = withDefaults(defineProps<{
|
|
|
19
19
|
<template>
|
|
20
20
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
21
21
|
<!-- Post cards rise in row by row as they scroll into view -->
|
|
22
|
-
<
|
|
22
|
+
<NuxtLinkLocale v-for="(post, index) in props.posts" :key="post.path" data-aos="blur-up" :data-aos-delay="(index % 3) * 90" :to="post.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
|
|
23
23
|
<PostCard class="h-full" :image="post.image" :category="post.categoryText" :title="post.title" :description="post.description" />
|
|
24
|
-
</
|
|
24
|
+
</NuxtLinkLocale>
|
|
25
25
|
</div>
|
|
26
26
|
</template>
|
|
@@ -38,8 +38,8 @@ export const slugify = (value: unknown) => {
|
|
|
38
38
|
// Remove the trailing slashes of a route path, since content paths never have one ("/blog/post/" -> "/blog/post")
|
|
39
39
|
export const normalizeContentPath = (path: string) => path.replace(/(.)\/+$/, '$1');
|
|
40
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[] => {
|
|
41
|
+
// Build the unique categories of the posts, with their post count and the image of their first post (the path is the blog category page by default)
|
|
42
|
+
export const buildCategoriesFromPosts = (posts: BlogPostData[], getPath: (slug: string) => string = slug => `/blog/categories/${slug}`): BlogCategoryData[] => {
|
|
43
43
|
const categoriesList = posts.map(post => post.categorySlug);
|
|
44
44
|
const uniqueCategories = categoriesList.filter((category, index, self): category is string => !!category && self.indexOf(category) === index);
|
|
45
45
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
3
|
import { computed } from 'vue';
|
|
4
4
|
import { useI18n } from 'vue-i18n';
|
|
5
|
-
import type {
|
|
5
|
+
import type { HTMLAttributes } from 'vue';
|
|
6
6
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
7
7
|
import BackgroundGrid from '../background-grid/BackgroundGrid.vue';
|
|
8
8
|
import { ThemeSelector } from '../theme-selector';
|
|
@@ -11,7 +11,7 @@ import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupConte
|
|
|
11
11
|
import { cn } from '../../lib/utils';
|
|
12
12
|
import type { HugeiconsIconDefinition } from '../../lib/common-types';
|
|
13
13
|
|
|
14
|
-
// Props
|
|
14
|
+
// Props (item and home paths are localized by the links, external URLs work too)
|
|
15
15
|
const props = withDefaults(defineProps<{
|
|
16
16
|
appName?: string;
|
|
17
17
|
appLogo?: string;
|
|
@@ -29,13 +29,11 @@ const props = withDefaults(defineProps<{
|
|
|
29
29
|
label: string;
|
|
30
30
|
description?: string;
|
|
31
31
|
icon?: HugeiconsIconDefinition | null;
|
|
32
|
-
|
|
33
|
-
to?: unknown;
|
|
32
|
+
to: string;
|
|
34
33
|
active?: boolean;
|
|
35
34
|
newTab?: boolean;
|
|
36
35
|
}>;
|
|
37
36
|
}>;
|
|
38
|
-
sidebarLinkComponent?: Component | string;
|
|
39
37
|
showBackground?: boolean;
|
|
40
38
|
insetClass?: HTMLAttributes['class'];
|
|
41
39
|
topbarClass?: HTMLAttributes['class'];
|
|
@@ -50,26 +48,14 @@ const props = withDefaults(defineProps<{
|
|
|
50
48
|
collapsible: 'offcanvas',
|
|
51
49
|
compact: false,
|
|
52
50
|
sidebarSections: () => [],
|
|
53
|
-
sidebarLinkComponent: 'a',
|
|
54
51
|
showBackground: true
|
|
55
52
|
});
|
|
56
53
|
|
|
57
|
-
//
|
|
54
|
+
// Get the link props of a sidebar item (new tab links open in a separate, unlinked tab)
|
|
58
55
|
const getSidebarItemLinkProps = (item: NonNullable<typeof props.sidebarSections>[number]['items'][number]) => {
|
|
59
|
-
const sharedProps = item.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
|
60
|
-
|
|
61
|
-
// If the link component is an anchor, use href. Otherwise, use to for router links.
|
|
62
|
-
if (props.sidebarLinkComponent === 'a') {
|
|
63
|
-
return {
|
|
64
|
-
href: item.href ?? '#',
|
|
65
|
-
...sharedProps
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// For non-anchor link components, assume they use a "to" prop (like Vue Router's <RouterLink>).
|
|
70
56
|
return {
|
|
71
|
-
to: item.to
|
|
72
|
-
...
|
|
57
|
+
to: item.to,
|
|
58
|
+
...(item.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})
|
|
73
59
|
};
|
|
74
60
|
};
|
|
75
61
|
|
|
@@ -93,8 +79,9 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
|
|
|
93
79
|
<slot name="sidebar-header">
|
|
94
80
|
<SidebarMenu>
|
|
95
81
|
<SidebarMenuItem>
|
|
82
|
+
<!-- Home link (the path is localized) -->
|
|
96
83
|
<SidebarMenuButton as-child size="lg">
|
|
97
|
-
<
|
|
84
|
+
<NuxtLinkLocale :to="props.appLinkTo" class="inline-flex items-center gap-2">
|
|
98
85
|
<div class="inline-flex items-center gap-2">
|
|
99
86
|
<!-- App logo -->
|
|
100
87
|
<template v-if="lightLogo">
|
|
@@ -105,7 +92,7 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
|
|
|
105
92
|
<!-- App name -->
|
|
106
93
|
<span v-if="props.appName" class="text-xl font-semibold tracking-tight text-foreground">{{ props.appName }}</span>
|
|
107
94
|
</div>
|
|
108
|
-
</
|
|
95
|
+
</NuxtLinkLocale>
|
|
109
96
|
</SidebarMenuButton>
|
|
110
97
|
</SidebarMenuItem>
|
|
111
98
|
</SidebarMenu>
|
|
@@ -125,9 +112,9 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
|
|
|
125
112
|
<SidebarMenu :class="cn('gap-1', props.compact && 'gap-0.5')">
|
|
126
113
|
<SidebarMenuItem v-for="item in section.items" :key="item.id">
|
|
127
114
|
<SidebarMenuButton as-child :is-active="item.active" :class="cn('group/nav relative h-auto min-h-11 overflow-hidden rounded px-3 py-2.5 text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground data-[active=true]:bg-primary/10 data-[active=true]:text-foreground sm:px-3.5', 'before:absolute before:inset-y-2 before:left-0 before:w-0.5 before:origin-center before:scale-y-0 before:bg-primary before:transition-transform before:duration-300 before:ease-spring data-[active=true]:before:scale-y-100', item.description ? 'items-start' : 'items-center')">
|
|
128
|
-
<
|
|
129
|
-
<!-- Icon -->
|
|
130
|
-
<HugeiconsIcon v-if="item.icon" :icon="item.icon" :stroke-width="1.8" :class="cn('shrink-0 opacity-90 transition-[opacity,color] duration-150 group-hover/nav:opacity-100 group-data-[active=true]/nav:text-primary', item.description ? 'mt-
|
|
115
|
+
<NuxtLinkLocale v-bind="getSidebarItemLinkProps(item)" :aria-current="item.active ? 'page' : undefined">
|
|
116
|
+
<!-- Icon, centered on the capitals of the label (or of its first line, 1.25em tall): the line box alone centers it 0.08em too low, because of the space the font keeps for descenders (the size is important: the menu button shrinks its direct svg children to size-4) -->
|
|
117
|
+
<HugeiconsIcon v-if="item.icon" :icon="item.icon" :stroke-width="1.8" :class="cn('size-5! shrink-0 opacity-90 transition-[opacity,color] duration-150 group-hover/nav:opacity-100 group-data-[active=true]/nav:text-primary', item.description ? 'mt-[calc((1.25em_-_20px)/2_-_0.08em)]' : 'self-center -translate-y-[0.08em]')" />
|
|
131
118
|
|
|
132
119
|
<!-- Label and optional description -->
|
|
133
120
|
<div :class="cn('min-w-0 flex-1 text-left leading-tight', item.description ? 'grid' : 'flex items-center')">
|
|
@@ -139,7 +126,7 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
|
|
|
139
126
|
{{ item.description }}
|
|
140
127
|
</span>
|
|
141
128
|
</div>
|
|
142
|
-
</
|
|
129
|
+
</NuxtLinkLocale>
|
|
143
130
|
</SidebarMenuButton>
|
|
144
131
|
</SidebarMenuItem>
|
|
145
132
|
</SidebarMenu>
|
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
3
|
import FavouriteIcon from '@hugeicons/core-free-icons/FavouriteIcon';
|
|
4
4
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
5
|
-
import { computed, type
|
|
5
|
+
import { computed, type HTMLAttributes } from 'vue';
|
|
6
6
|
import { useI18n } from 'vue-i18n';
|
|
7
7
|
import { cn } from '../../../lib/utils';
|
|
8
8
|
import type { HeadingLevel } from '../../../lib/common-types';
|
|
9
9
|
|
|
10
|
+
// Footer link (the path is localized by the link, external URLs work too)
|
|
10
11
|
export interface LandingFooterLink {
|
|
11
12
|
id: string;
|
|
12
13
|
label: string;
|
|
13
|
-
|
|
14
|
-
to?: unknown;
|
|
14
|
+
to: string;
|
|
15
15
|
newTab?: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -30,7 +30,6 @@ const props = withDefaults(defineProps<{
|
|
|
30
30
|
logoClass?: HTMLAttributes['class'];
|
|
31
31
|
appDescription?: string;
|
|
32
32
|
sections?: LandingFooterSection[];
|
|
33
|
-
linkComponent?: Component | string;
|
|
34
33
|
authorName?: string;
|
|
35
34
|
authorLink?: string;
|
|
36
35
|
year?: number;
|
|
@@ -45,7 +44,6 @@ const props = withDefaults(defineProps<{
|
|
|
45
44
|
appLinkTo: '/',
|
|
46
45
|
appDescription: '',
|
|
47
46
|
sections: () => [],
|
|
48
|
-
linkComponent: 'a',
|
|
49
47
|
authorName: '',
|
|
50
48
|
authorLink: '',
|
|
51
49
|
year: () => new Date().getFullYear(),
|
|
@@ -57,22 +55,11 @@ const { t } = useI18n();
|
|
|
57
55
|
const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name: props.appName }) : t('uiVintage.common.home'));
|
|
58
56
|
const hasSingleSection = computed(() => props.sections.length === 1);
|
|
59
57
|
|
|
60
|
-
// Get
|
|
58
|
+
// Get the link props (new tab links open in a separate, unlinked tab)
|
|
61
59
|
const getLinkProps = (link: LandingFooterLink) => {
|
|
62
|
-
const sharedProps = link.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
|
63
|
-
|
|
64
|
-
// If the link component is an anchor, use href. Otherwise, use to for router links.
|
|
65
|
-
if (props.linkComponent === 'a') {
|
|
66
|
-
return {
|
|
67
|
-
href: link.href ?? '#',
|
|
68
|
-
...sharedProps
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// For non-anchor link components, assume they use a "to" prop (like Vue Router's <RouterLink>).
|
|
73
60
|
return {
|
|
74
|
-
to: link.to
|
|
75
|
-
...
|
|
61
|
+
to: link.to,
|
|
62
|
+
...(link.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})
|
|
76
63
|
};
|
|
77
64
|
};
|
|
78
65
|
|
|
@@ -88,14 +75,14 @@ const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
|
|
|
88
75
|
<div :class="cn('flex flex-col gap-8 md:flex-row md:items-start md:justify-between')">
|
|
89
76
|
<!-- Brand section -->
|
|
90
77
|
<div :class="cn('max-w-md flex-1', props.brandClass)">
|
|
91
|
-
<!-- App logo and name -->
|
|
92
|
-
<
|
|
78
|
+
<!-- App logo and name (home link, the path is localized) -->
|
|
79
|
+
<NuxtLinkLocale :to="props.appLinkTo" class="mb-4 inline-flex items-center gap-2 text-lg font-semibold tracking-tight text-foreground transition-opacity duration-150 hover:opacity-80">
|
|
93
80
|
<template v-if="lightLogo">
|
|
94
81
|
<NuxtImg :src="lightLogo" :alt="logoAlt" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="lazy" decoding="async" :class="cn('size-8 shrink-0 object-contain', hasDarkLogo && 'dark:hidden', props.logoClass)" />
|
|
95
82
|
<NuxtImg v-if="hasDarkLogo" :src="darkLogo" :alt="logoAlt" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="lazy" decoding="async" :class="cn('hidden size-8 shrink-0 object-contain dark:block', props.logoClass)" />
|
|
96
83
|
</template>
|
|
97
84
|
<span>{{ props.appName }}</span>
|
|
98
|
-
</
|
|
85
|
+
</NuxtLinkLocale>
|
|
99
86
|
|
|
100
87
|
<!-- App description -->
|
|
101
88
|
<p v-if="props.appDescription" class="max-w-sm text-xs md:text-sm leading-relaxed text-muted-foreground">
|
|
@@ -114,9 +101,9 @@ const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
|
|
|
114
101
|
<!-- Section links -->
|
|
115
102
|
<ul class="space-y-3 text-xs sm:text-sm">
|
|
116
103
|
<li v-for="link in props.sections[0]?.links" :key="link.id">
|
|
117
|
-
<
|
|
104
|
+
<NuxtLinkLocale v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
|
|
118
105
|
{{ link.label }}
|
|
119
|
-
</
|
|
106
|
+
</NuxtLinkLocale>
|
|
120
107
|
</li>
|
|
121
108
|
</ul>
|
|
122
109
|
</div>
|
|
@@ -133,9 +120,9 @@ const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
|
|
|
133
120
|
<!-- Section links -->
|
|
134
121
|
<ul class="space-y-3 text-xs sm:text-sm">
|
|
135
122
|
<li v-for="link in section.links" :key="link.id">
|
|
136
|
-
<
|
|
123
|
+
<NuxtLinkLocale v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
|
|
137
124
|
{{ link.label }}
|
|
138
|
-
</
|
|
125
|
+
</NuxtLinkLocale>
|
|
139
126
|
</li>
|
|
140
127
|
</ul>
|
|
141
128
|
</div>
|
|
@@ -64,7 +64,8 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
|
|
|
64
64
|
<!-- Left-aligned content -->
|
|
65
65
|
<div :class="cn('flex items-center', props.leftClass)">
|
|
66
66
|
<slot name="left">
|
|
67
|
-
|
|
67
|
+
<!-- Home link (the path is localized) -->
|
|
68
|
+
<NuxtLinkLocale v-if="lightLogo || props.appName" :to="props.appLinkTo" class="inline-flex items-center gap-2 transition-opacity duration-150 hover:opacity-80">
|
|
68
69
|
<!-- App logo -->
|
|
69
70
|
<template v-if="lightLogo">
|
|
70
71
|
<NuxtImg :src="lightLogo" :alt="logoAlt" width="44" height="44" :sizes="{ 320: '44px', 640: '36px' }" loading="eager" fetchpriority="high" :class="cn('size-11 sm:size-9 shrink-0 object-contain', hasDarkLogo && 'dark:hidden', props.logoClass)" />
|
|
@@ -75,7 +76,7 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
|
|
|
75
76
|
<span v-if="props.appName" class="hidden sm:inline! text-xl font-semibold tracking-tight text-foreground">
|
|
76
77
|
{{ props.appName }}
|
|
77
78
|
</span>
|
|
78
|
-
</
|
|
79
|
+
</NuxtLinkLocale>
|
|
79
80
|
</slot>
|
|
80
81
|
</div>
|
|
81
82
|
|
|
@@ -23,7 +23,8 @@ const hasStructuredSlots = computed(() => {
|
|
|
23
23
|
</script>
|
|
24
24
|
|
|
25
25
|
<template>
|
|
26
|
-
|
|
26
|
+
<!-- Clip the horizontal overflow of decorations (glows, marquees), so the page never scrolls sideways on mobile -->
|
|
27
|
+
<div :class="cn('relative flex min-h-screen flex-col overflow-x-clip bg-background text-foreground', props.rootClass)">
|
|
27
28
|
<!-- Background grid -->
|
|
28
29
|
<BackgroundGrid v-if="props.showBackground" />
|
|
29
30
|
|
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// Props
|
|
5
|
-
const props = withDefaults(defineProps<{
|
|
2
|
+
// Props (the path is localized by the link, external URLs work too)
|
|
3
|
+
const props = defineProps<{
|
|
6
4
|
to: string;
|
|
7
5
|
text: string;
|
|
8
|
-
|
|
9
|
-
}>(), {
|
|
10
|
-
linkComponent: 'a'
|
|
11
|
-
});
|
|
6
|
+
}>();
|
|
12
7
|
|
|
13
8
|
// Emits
|
|
14
9
|
const emits = defineEmits<{
|
|
15
10
|
click: [event: MouseEvent];
|
|
16
11
|
}>();
|
|
17
12
|
|
|
18
|
-
// Get link props based on the type of link component
|
|
19
|
-
const getLinkProps = () => {
|
|
20
|
-
if (props.linkComponent === 'a') {
|
|
21
|
-
return { href: props.to };
|
|
22
|
-
} else {
|
|
23
|
-
return { to: props.to };
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
|
|
27
13
|
// Handle click
|
|
28
14
|
const handleClick = (event: MouseEvent) => {
|
|
29
15
|
emits('click', event);
|
|
@@ -31,7 +17,7 @@ const handleClick = (event: MouseEvent) => {
|
|
|
31
17
|
</script>
|
|
32
18
|
|
|
33
19
|
<template>
|
|
34
|
-
<
|
|
20
|
+
<NuxtLinkLocale :to="props.to" class="cursor-pointer font-semibold text-foreground hover:underline" @click="handleClick">
|
|
35
21
|
{{ props.text }}
|
|
36
|
-
</
|
|
22
|
+
</NuxtLinkLocale>
|
|
37
23
|
</template>
|