@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
|
@@ -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
|
-
<
|
|
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>
|
|
@@ -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 {
|
|
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>
|
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": {
|