@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.
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from 'vue-i18n';
4
+ import News01Icon from '@hugeicons/core-free-icons/News01Icon';
5
+ import { LoadMoreButton } from '../load-more-button';
6
+ import BlogHeaderSection from './BlogHeaderSection.vue';
7
+ import BlogPageBreadcrumbs from './BlogPageBreadcrumbs.vue';
8
+ import PostsList from './PostsList.vue';
9
+
10
+ // Props (the posts of a category or a tag, the links localize their paths)
11
+ const props = withDefaults(defineProps<{
12
+ section: 'categories' | 'tags';
13
+ title: string;
14
+ description: string;
15
+ posts?: Array<{
16
+ path: string;
17
+ title: string;
18
+ description?: string;
19
+ image?: string;
20
+ categoryText?: string;
21
+ }>;
22
+ totalPosts?: number;
23
+ currentPage?: number;
24
+ postsPerPage?: number;
25
+ hasMorePosts?: boolean;
26
+ isLoading?: boolean;
27
+ }>(), {
28
+ posts: () => [],
29
+ totalPosts: 0,
30
+ currentPage: 1,
31
+ postsPerPage: 9,
32
+ hasMorePosts: false,
33
+ isLoading: false
34
+ });
35
+
36
+ // Emits
37
+ const emit = defineEmits<{
38
+ 'load-more': [];
39
+ }>();
40
+
41
+ const { t } = useI18n();
42
+
43
+ // Breadcrumbs: the section list, then the category or tag
44
+ const breadcrumbs = computed(() => [
45
+ props.section === 'tags' ? { label: t('uiVintage.blog.breadcrumbTags'), to: '/blog/tags' } : { label: t('uiVintage.blog.breadcrumbCategories'), to: '/blog/categories' },
46
+ { label: props.title }
47
+ ]);
48
+
49
+ // Number of posts shown under the header
50
+ const headerStats = computed(() => [{ icon: News01Icon, value: props.totalPosts, label: t('uiVintage.blog.stats.posts', props.totalPosts) }]);
51
+ </script>
52
+
53
+ <template>
54
+ <div>
55
+ <!-- Breadcrumbs -->
56
+ <BlogPageBreadcrumbs :items="breadcrumbs" />
57
+
58
+ <!-- Blog header -->
59
+ <BlogHeaderSection :title="props.title"
60
+ :highlight="props.title"
61
+ :description="props.description"
62
+ :stats="headerStats"
63
+ class="mb-12 md:mb-16" />
64
+
65
+ <!-- Posts -->
66
+ <PostsList :posts="props.posts" />
67
+
68
+ <!-- Load more button -->
69
+ <LoadMoreButton v-if="props.hasMorePosts" :busy="props.isLoading" :text="t('uiVintage.blog.loadMoreWithNumbers', {
70
+ current: Math.min(props.currentPage * props.postsPerPage, props.totalPosts),
71
+ total: props.totalPosts
72
+ })" @load-more="emit('load-more')" class="mt-8" data-aos="blur-up" />
73
+ </div>
74
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from 'vue-i18n';
4
+ import News01Icon from '@hugeicons/core-free-icons/News01Icon';
5
+ import Tag01Icon from '@hugeicons/core-free-icons/Tag01Icon';
6
+ import type { BlogTagData } from './blogPageUtils';
7
+ import BlogHeaderSection from './BlogHeaderSection.vue';
8
+ import BlogPageBreadcrumbs from './BlogPageBreadcrumbs.vue';
9
+ import BlogTagsSection from './BlogTagsSection.vue';
10
+
11
+ // Props
12
+ const props = withDefaults(defineProps<{
13
+ description: string;
14
+ tags?: BlogTagData[];
15
+ }>(), {
16
+ tags: () => []
17
+ });
18
+
19
+ const { t } = useI18n();
20
+
21
+ // Tag totals shown under the header
22
+ const headerStats = computed(() => {
23
+ const tagUses = props.tags.reduce((total, tag) => total + (tag.count || 0), 0);
24
+ return [
25
+ { icon: Tag01Icon, value: props.tags.length, label: t('uiVintage.blog.stats.tags', props.tags.length) },
26
+ { icon: News01Icon, value: tagUses, label: t('uiVintage.blog.stats.uses', tagUses) }
27
+ ];
28
+ });
29
+ </script>
30
+
31
+ <template>
32
+ <div>
33
+ <!-- Breadcrumbs -->
34
+ <BlogPageBreadcrumbs :items="[{ label: t('uiVintage.blog.breadcrumbTags') }]" />
35
+
36
+ <!-- Blog header -->
37
+ <BlogHeaderSection :title="t('uiVintage.blog.tags')"
38
+ :highlight="t('uiVintage.blog.tags')"
39
+ :description="props.description"
40
+ :stats="headerStats"
41
+ class="mb-12 md:mb-16" />
42
+
43
+ <!-- Tags list -->
44
+ <BlogTagsSection :tags="props.tags" class="justify-center" />
45
+ </div>
46
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
3
+ import { HugeiconsIcon } from '@hugeicons/vue';
4
+
5
+ // Props
6
+ const props = defineProps<{
7
+ to: string;
8
+ label: string;
9
+ }>();
10
+ </script>
11
+
12
+ <template>
13
+ <!-- Link to the full list of a blog section (the path is localized) -->
14
+ <NuxtLinkLocale :to="props.to" class="group/view inline-flex items-center gap-1.5 rounded px-1 text-xs font-semibold text-primary outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45 sm:text-sm">
15
+ {{ props.label }}
16
+ <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/view:translate-x-1" />
17
+ </NuxtLinkLocale>
18
+ </template>
@@ -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
- <NuxtLink 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">
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
- </NuxtLink>
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
- <NuxtLink :to="post.path" tabindex="-1" aria-hidden="true" class="absolute inset-0 overflow-hidden">
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
- </NuxtLink>
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
- <NuxtLink v-if="post.categoryText && post.categoryPath" :to="post.categoryPath" :class="cn('w-fit', currentSlide === index && 'animate-uv-fade-up')">
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
- </NuxtLink>
129
+ </NuxtLinkLocale>
130
130
 
131
131
  <!-- Title -->
132
- <NuxtLink :to="post.path" :class="cn('outline-none', currentSlide === index && 'animate-uv-fade-up [animation-delay:90ms]')">
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
- </NuxtLink>
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
- <NuxtLink :to="post.path">
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
- </NuxtLink>
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
- <NuxtLink 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">
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
- </NuxtLink>
24
+ </NuxtLinkLocale>
25
25
  </div>
26
26
  </template>
@@ -0,0 +1,98 @@
1
+ // Post fields read by the blog page helpers (a Nuxt Content blog collection entry)
2
+ export interface BlogPostData {
3
+ path?: string;
4
+ title?: string;
5
+ description?: string;
6
+ image?: string;
7
+ categoryText?: string;
8
+ categorySlug?: string;
9
+ tags?: string[];
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ // Category card data built from the posts
14
+ export interface BlogCategoryData {
15
+ name: string;
16
+ slug: string;
17
+ path: string;
18
+ count: number;
19
+ image?: string;
20
+ }
21
+
22
+ // Tag badge data built from the posts
23
+ export interface BlogTagData {
24
+ name: string;
25
+ slug: string;
26
+ count: number;
27
+ }
28
+
29
+ // Turn a label into a URL slug (e.g. "Dark Mode" -> "dark-mode")
30
+ export const slugify = (value: unknown) => {
31
+ return String(value || '')
32
+ .trim()
33
+ .toLowerCase()
34
+ .replace(/[^a-z0-9]+/g, '-')
35
+ .replace(/^-+|-+$/g, '');
36
+ };
37
+
38
+ // Remove the trailing slashes of a route path, since content paths never have one ("/blog/post/" -> "/blog/post")
39
+ export const normalizeContentPath = (path: string) => path.replace(/(.)\/+$/, '$1');
40
+
41
+ // Build the unique categories of the posts, with their post count and the image of their first post (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
+ const categoriesList = posts.map(post => post.categorySlug);
44
+ const uniqueCategories = categoriesList.filter((category, index, self): category is string => !!category && self.indexOf(category) === index);
45
+
46
+ // Map unique categories to category data
47
+ return uniqueCategories.map(category => {
48
+ const firstPost = posts.find(post => post.categorySlug === category);
49
+
50
+ // Return category data
51
+ return {
52
+ name: firstPost?.categoryText || category,
53
+ slug: category,
54
+ path: getPath(category),
55
+ count: categoriesList.filter(item => item === category).length,
56
+ image: firstPost?.image
57
+ };
58
+ });
59
+ };
60
+
61
+ // Build the unique tags of the posts, with their number of uses (the first label wins for tags that share a slug)
62
+ export const buildTagsFromPosts = (posts: BlogPostData[]): BlogTagData[] => {
63
+ const tagsBySlug = new Map<string, BlogTagData>();
64
+
65
+ // Loop through each post and tag
66
+ for (const post of posts) {
67
+ for (const tag of post.tags || []) {
68
+ const slug = slugify(tag);
69
+ if (!slug) continue; // Skip tags that cannot produce a URL slug
70
+
71
+ // Preserve the first label while counting the duplicates
72
+ const existingTag = tagsBySlug.get(slug);
73
+ tagsBySlug.set(slug, {
74
+ name: existingTag?.name || tag,
75
+ slug,
76
+ count: (existingTag?.count || 0) + 1
77
+ });
78
+ }
79
+ }
80
+
81
+ // Return unique tags with their post counts
82
+ return [...tagsBySlug.values()];
83
+ };
84
+
85
+ // Create a deterministic hash for stable related content ordering (FNV-1a: a linear hash would give every seed the same order)
86
+ const hashString = (value: string) => {
87
+ let hash = 2166136261;
88
+ for (let index = 0; index < value.length; index++) {
89
+ hash ^= value.charCodeAt(index);
90
+ hash = Math.imul(hash, 16777619);
91
+ }
92
+ return hash >>> 0;
93
+ };
94
+
95
+ // Sort a list by a deterministic score derived from a seed (the same post always shows the same related items, on the server and in the browser)
96
+ export const sortByStableSeed = <T>(items: T[], seed: string, getKey: (item: T) => string) => {
97
+ return [...items].sort((itemA, itemB) => hashString(`${seed}-${getKey(itemA)}`) - hashString(`${seed}-${getKey(itemB)}`));
98
+ };
@@ -13,4 +13,15 @@ export { default as HeaderCarousel } from './HeaderCarousel.vue';
13
13
  export { default as PostCard } from './PostCard.vue';
14
14
  export { default as PostsList } from './PostsList.vue';
15
15
  export { default as SocialShareSidebar } from './SocialShareSidebar.vue';
16
- export { default as TableOfContents } from './TableOfContents.vue';
16
+ export { default as TableOfContents } from './TableOfContents.vue';
17
+ // Complete blog pages (the app fetches the content and sets the SEO, these render the page)
18
+ export { default as BlogIndexPage } from './BlogIndexPage.vue';
19
+ export { default as BlogPostPage } from './BlogPostPage.vue';
20
+ export { default as BlogCategoriesPage } from './BlogCategoriesPage.vue';
21
+ export { default as BlogTagsPage } from './BlogTagsPage.vue';
22
+ export { default as BlogPostsPage } from './BlogPostsPage.vue';
23
+ export { default as BlogViewAllLink } from './BlogViewAllLink.vue';
24
+
25
+ // Helpers for the data of the blog pages
26
+ export { slugify, normalizeContentPath, buildCategoriesFromPosts, buildTagsFromPosts, sortByStableSeed } from './blogPageUtils';
27
+ export type { BlogPostData, BlogCategoryData, BlogTagData } from './blogPageUtils';
@@ -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 { Component, HTMLAttributes } from 'vue';
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
- href?: string;
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
- // Resolve link props for sidebar items
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 ?? item.href ?? '/',
72
- ...sharedProps
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
- <a :href="props.appLinkTo" class="inline-flex items-center gap-2">
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
- </a>
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
- <component :is="props.sidebarLinkComponent" v-bind="getSidebarItemLinkProps(item)" :aria-current="item.active ? 'page' : undefined">
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-0.5 size-5 sm:h-5 sm:w-5' : 'size-5 self-center sm:h-6 sm:w-6')" />
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
- </component>
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 Component, type HTMLAttributes } from 'vue';
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
- href?: string;
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 link props based on the type of link component and whether the link should open in a new tab
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 ?? link.href ?? '/',
75
- ...sharedProps
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
- <a :href="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">
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
- </a>
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
- <component :is="props.linkComponent" 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">
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
- </component>
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
- <component :is="props.linkComponent" 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">
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
- </component>
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
- <a v-if="lightLogo || props.appName" :href="props.appLinkTo" class="inline-flex items-center gap-2 transition-opacity duration-150 hover:opacity-80">
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
- </a>
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
- <div :class="cn('relative flex min-h-screen flex-col bg-background text-foreground', props.rootClass)">
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
- import type { Component } from 'vue';
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
- linkComponent?: Component | string;
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
- <component :is="props.linkComponent" v-bind="getLinkProps()" class="cursor-pointer font-semibold text-foreground hover:underline" @click="handleClick">
20
+ <NuxtLinkLocale :to="props.to" class="cursor-pointer font-semibold text-foreground hover:underline" @click="handleClick">
35
21
  {{ props.text }}
36
- </component>
22
+ </NuxtLinkLocale>
37
23
  </template>
package/src/i18n/de.json CHANGED
@@ -108,6 +108,27 @@
108
108
  "telegram": "Auf Telegram teilen",
109
109
  "copy": "Link kopieren",
110
110
  "copied": "Link kopiert"
111
+ },
112
+ "latestPosts": "Neueste Beiträge",
113
+ "latestPostsDescription": "Von der Redaktion ausgewählte Beiträge.",
114
+ "categories": "Kategorien",
115
+ "categoriesDescription": "Spring direkt zu den Themen, die dich interessieren.",
116
+ "tags": "Tags",
117
+ "tagsDescription": "Kleinere Themen, die sich durch alle Kategorien ziehen.",
118
+ "allPosts": "Alle Beiträge",
119
+ "allPostsDescription": "Alles, was wir veröffentlicht haben, das Neueste zuerst.",
120
+ "viewAll": "Alle ansehen",
121
+ "readAlso": "Lies auch",
122
+ "exploreCategories": "Kategorien entdecken",
123
+ "exploreAllCategories": "Alle Kategorien entdecken",
124
+ "breadcrumbHome": "Startseite",
125
+ "breadcrumbCategories": "Kategorien",
126
+ "breadcrumbTags": "Tags",
127
+ "stats": {
128
+ "posts": "Beitrag | Beiträge",
129
+ "categories": "Kategorie | Kategorien",
130
+ "tags": "Tag | Tags",
131
+ "uses": "Verwendung | Verwendungen"
111
132
  }
112
133
  },
113
134
  "errorPage": {