@brumaombra/ui-vintage 0.8.1 → 0.8.2
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/COMPONENTS.md +4 -1
- package/package.json +2 -2
- package/src/components/blog/AllPostsSection.vue +1 -2
- package/src/components/blog/BlogContentRenderer.vue +144 -49
- package/src/components/blog/BlogFAQSection.vue +13 -15
- package/src/components/blog/BlogHeaderSection.vue +27 -9
- package/src/components/blog/BlogInfoSection.vue +37 -36
- package/src/components/blog/BlogProgressBar.vue +5 -2
- package/src/components/blog/BlogSectionTitle.vue +24 -5
- package/src/components/blog/BlogTagsSection.vue +8 -5
- package/src/components/blog/CategoriesList.vue +2 -2
- package/src/components/blog/CategoryCard.vue +25 -16
- package/src/components/blog/HeaderCarousel.vue +109 -146
- package/src/components/blog/PostCard.vue +34 -35
- package/src/components/blog/PostsList.vue +3 -2
- package/src/components/blog/SocialShareSidebar.vue +51 -41
- package/src/components/blog/TableOfContents.vue +14 -10
- package/src/components/content/BlogList.vue +16 -4
- package/src/components/content/BlogTable.vue +4 -4
- package/src/components/content/CodeBlock.vue +5 -5
- package/src/components/content/Flow.vue +4 -4
- package/src/components/content/ProseHr.vue +5 -5
- package/src/components/content/Terminal.vue +10 -4
- package/src/components/landing/landing-footer/LandingFooter.vue +9 -4
- package/src/components/landing/landing-navbar/LandingNavbar.vue +47 -51
- package/src/components/landing/landing-shell/LandingShell.vue +7 -2
- package/src/components/ui/accordion/Accordion.vue +6 -6
- package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbLink.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbList.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbPage.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +2 -6
- package/src/components/ui/card/Card.vue +1 -1
- package/src/components/ui/command/CommandItem.vue +2 -2
- package/src/i18n/de.json +3 -1
- package/src/i18n/en.json +3 -1
- package/src/i18n/es.json +3 -1
- package/src/i18n/fr.json +3 -1
- package/src/i18n/it.json +3 -1
- package/src/i18n/ja.json +3 -1
- package/src/i18n/pt.json +3 -1
- package/src/i18n/ru.json +3 -1
- package/src/i18n/zh.json +3 -1
- package/src/styles.css +40 -0
package/COMPONENTS.md
CHANGED
|
@@ -3424,7 +3424,7 @@ Components for Nuxt Content pages and a complete blog.
|
|
|
3424
3424
|
|
|
3425
3425
|
### `@brumaombra/ui-vintage/blog`
|
|
3426
3426
|
|
|
3427
|
-
Blog building blocks: header carousel, post
|
|
3427
|
+
Blog building blocks: an animated hero header, a full-bleed featured carousel, post and category cards, tags, table of contents, reading progress, FAQ, share sidebar, and the content renderer. Scroll entrances use AOS: install `aos` in the app and initialize it (the components carry `data-aos="blur-up"`, and the library stylesheet defines that animation). Without AOS the content simply renders without entrances.
|
|
3428
3428
|
|
|
3429
3429
|
```ts
|
|
3430
3430
|
import { AllPostsSection, BlogContentRenderer, BlogFAQSection, BlogInfoSection, BlogHeaderSection, BlogSectionTitle, BlogTagsSection, CategoriesList, CategoriesSection, CategoryCard, HeaderCarousel, PostCard, PostsList, SocialShareSidebar, TableOfContents } from '@brumaombra/ui-vintage/blog';
|
|
@@ -3478,6 +3478,9 @@ import { AllPostsSection, BlogContentRenderer, BlogFAQSection, BlogInfoSection,
|
|
|
3478
3478
|
| Prop | Type | Default |
|
|
3479
3479
|
| --- | --- | --- |
|
|
3480
3480
|
| `title` *(required)* | `string` | |
|
|
3481
|
+
| `description` | `string` | `''` |
|
|
3482
|
+
|
|
3483
|
+
- **Slots:** `action`.
|
|
3481
3484
|
|
|
3482
3485
|
#### `BlogTagsSection`
|
|
3483
3486
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brumaombra/ui-vintage",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Source-published Nuxt UI runtime with shared theme tokens and built-in Nuxt integrations.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"author": "Mauro Brambilla",
|
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
"@nuxt/image": "^2.0.0",
|
|
134
134
|
"nuxt": "^4.0.0",
|
|
135
135
|
"vue": "^3.5.0",
|
|
136
|
-
"vue-i18n": "^9.0.0 || ^10.0.0"
|
|
136
|
+
"vue-i18n": "^9.0.0 || ^10.0.0 || ^11.0.0"
|
|
137
137
|
},
|
|
138
138
|
"dependencies": {
|
|
139
139
|
"@hugeicons/core-free-icons": "4.1.1",
|
|
@@ -56,7 +56,6 @@ const visiblePostsCount = computed(() => Math.min(props.posts.length, props.tota
|
|
|
56
56
|
:busy="props.isLoading"
|
|
57
57
|
:text="t('uiVintage.blog.loadMoreWithNumbers', { current: visiblePostsCount, total: props.totalPosts })"
|
|
58
58
|
@load-more="handleLoadMorePosts"
|
|
59
|
-
class="mt-8"
|
|
60
|
-
data-aos="fade-up" />
|
|
59
|
+
class="mt-8" />
|
|
61
60
|
</div>
|
|
62
61
|
</template>
|
|
@@ -13,24 +13,15 @@ const props = defineProps<{
|
|
|
13
13
|
|
|
14
14
|
<style scoped>
|
|
15
15
|
.blog-content {
|
|
16
|
-
|
|
17
|
-
--blog-content-code-bg: var(--bg-selected-light);
|
|
18
|
-
--blog-content-inline-code-bg: #f9fafb;
|
|
19
|
-
--blog-content-inline-code-border: var(--border-light);
|
|
20
|
-
color: var(--blog-content-text);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
.dark .blog-content {
|
|
24
|
-
--blog-content-text: var(--text-primary-dark);
|
|
25
|
-
--blog-content-code-bg: var(--bg-selected-dark);
|
|
26
|
-
--blog-content-inline-code-bg: var(--bg-card-dark);
|
|
27
|
-
--blog-content-inline-code-border: var(--border-dark);
|
|
16
|
+
color: var(--foreground);
|
|
28
17
|
}
|
|
29
18
|
|
|
19
|
+
/* Headings */
|
|
30
20
|
.blog-content :deep(h1:not(.not-prose):not(.not-prose *)),
|
|
31
21
|
.blog-content :deep(h2:not(.not-prose):not(.not-prose *)),
|
|
32
22
|
.blog-content :deep(h3:not(.not-prose):not(.not-prose *)) {
|
|
33
|
-
color: var(--
|
|
23
|
+
color: var(--foreground);
|
|
24
|
+
letter-spacing: -0.01em;
|
|
34
25
|
scroll-margin-top: 6rem;
|
|
35
26
|
}
|
|
36
27
|
|
|
@@ -41,91 +32,195 @@ const props = defineProps<{
|
|
|
41
32
|
}
|
|
42
33
|
|
|
43
34
|
.blog-content :deep(h2:not(.not-prose):not(.not-prose *)) {
|
|
44
|
-
margin-top:
|
|
45
|
-
margin-bottom: 1.
|
|
35
|
+
margin-top: 3rem;
|
|
36
|
+
margin-bottom: 1.25rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.blog-content :deep(h3:not(.not-prose):not(.not-prose *)) {
|
|
40
|
+
margin-top: 2.25rem;
|
|
41
|
+
margin-bottom: 1rem;
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
.blog-content :deep(h2:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)) {
|
|
49
|
-
font-size: 1.
|
|
45
|
+
font-size: 1.5rem;
|
|
50
46
|
font-weight: 700;
|
|
51
47
|
line-height: 2rem;
|
|
52
48
|
}
|
|
53
49
|
|
|
54
|
-
.blog-content :deep(h3:not(.not-prose):not(.not-prose *)) {
|
|
55
|
-
margin-top: 2rem;
|
|
56
|
-
margin-bottom: 1rem;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
50
|
.blog-content :deep(h3:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)) {
|
|
60
|
-
font-size: 1.
|
|
51
|
+
font-size: 1.15rem;
|
|
61
52
|
font-weight: 700;
|
|
62
53
|
line-height: 1.75rem;
|
|
63
54
|
}
|
|
64
55
|
|
|
56
|
+
/* Heading anchors: a "#" fades in on hover */
|
|
57
|
+
.blog-content :deep(:is(h2, h3):not(.not-prose):not(.not-prose *) > a) {
|
|
58
|
+
position: relative;
|
|
59
|
+
color: inherit;
|
|
60
|
+
text-decoration: none;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.blog-content :deep(:is(h2, h3):not(.not-prose):not(.not-prose *) > a::before) {
|
|
64
|
+
content: '#';
|
|
65
|
+
position: absolute;
|
|
66
|
+
left: -1.1em;
|
|
67
|
+
color: var(--primary);
|
|
68
|
+
opacity: 0;
|
|
69
|
+
translate: 4px 0;
|
|
70
|
+
transition: opacity 0.2s var(--ease-out-expo), translate 0.3s var(--ease-spring);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.blog-content :deep(:is(h2, h3):not(.not-prose):not(.not-prose *):hover > a::before) {
|
|
74
|
+
opacity: 1;
|
|
75
|
+
translate: 0 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Body text */
|
|
65
79
|
.blog-content :deep(p:not(.not-prose):not(.not-prose *)),
|
|
66
80
|
.blog-content :deep(li:not(.not-prose):not(.not-prose *)),
|
|
67
81
|
.blog-content :deep(blockquote:not(.not-prose):not(.not-prose *)),
|
|
68
82
|
.blog-content :deep(td:not(.not-prose):not(.not-prose *)),
|
|
69
83
|
.blog-content :deep(th:not(.not-prose):not(.not-prose *)) {
|
|
70
|
-
color: var(--
|
|
84
|
+
color: var(--foreground);
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
.blog-content :deep(p:not(.not-prose):not(.not-prose *)) {
|
|
74
88
|
font-size: 15px;
|
|
75
89
|
margin-top: 1rem;
|
|
76
90
|
margin-bottom: 1rem;
|
|
77
|
-
line-height: 1.
|
|
91
|
+
line-height: 1.85;
|
|
78
92
|
}
|
|
79
93
|
|
|
80
|
-
|
|
81
|
-
.blog-content :deep(
|
|
82
|
-
font-
|
|
94
|
+
/* Lede: the opening paragraph reads a little larger */
|
|
95
|
+
.blog-content :deep(p:not(.not-prose):not(.not-prose *):first-of-type) {
|
|
96
|
+
font-size: 17px;
|
|
97
|
+
line-height: 1.8;
|
|
98
|
+
color: var(--muted-foreground);
|
|
83
99
|
}
|
|
84
100
|
|
|
85
|
-
.blog-content :deep(
|
|
86
|
-
.blog-content :deep(li:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)),
|
|
87
|
-
.blog-content :deep(blockquote:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)) {
|
|
88
|
-
color: var(--blog-content-text);
|
|
89
|
-
text-decoration: none;
|
|
101
|
+
.blog-content :deep(:is(p, blockquote, li):not(.not-prose):not(.not-prose *) strong) {
|
|
90
102
|
font-weight: 700;
|
|
103
|
+
color: var(--foreground);
|
|
91
104
|
}
|
|
92
105
|
|
|
93
|
-
|
|
94
|
-
.blog-content :deep(li:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)
|
|
95
|
-
|
|
106
|
+
/* Links: a primary underline that thickens on hover */
|
|
107
|
+
.blog-content :deep(:is(p, li, blockquote):not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)) {
|
|
108
|
+
color: var(--foreground);
|
|
109
|
+
font-weight: 700;
|
|
96
110
|
text-decoration: underline;
|
|
111
|
+
text-decoration-color: color-mix(in oklab, var(--primary) 45%, transparent);
|
|
112
|
+
text-decoration-thickness: 2px;
|
|
113
|
+
text-underline-offset: 4px;
|
|
114
|
+
transition: color 0.15s, text-decoration-color 0.15s, text-decoration-thickness 0.15s;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.blog-content :deep(:is(p, li, blockquote):not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *):hover) {
|
|
118
|
+
color: var(--primary);
|
|
119
|
+
text-decoration-color: var(--primary);
|
|
120
|
+
text-decoration-thickness: 3px;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Lists */
|
|
124
|
+
.blog-content :deep(:is(ul, ol):not(.not-prose):not(.not-prose *)) {
|
|
125
|
+
margin: 1.25rem 0;
|
|
126
|
+
padding-left: 1.4rem;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.blog-content :deep(ul:not(.not-prose):not(.not-prose *)) {
|
|
130
|
+
list-style: square;
|
|
97
131
|
}
|
|
98
132
|
|
|
99
|
-
.blog-content :deep(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
.blog-content :deep(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
133
|
+
.blog-content :deep(ol:not(.not-prose):not(.not-prose *)) {
|
|
134
|
+
list-style: decimal;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.blog-content :deep(li:not(.not-prose):not(.not-prose *)) {
|
|
138
|
+
font-size: 15px;
|
|
139
|
+
line-height: 1.8;
|
|
140
|
+
margin: 0.4rem 0;
|
|
141
|
+
padding-left: 0.25rem;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.blog-content :deep(li:not(.not-prose):not(.not-prose *)::marker) {
|
|
145
|
+
color: var(--primary);
|
|
146
|
+
font-weight: 700;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Blockquotes */
|
|
150
|
+
.blog-content :deep(blockquote:not(.not-prose):not(.not-prose *)) {
|
|
151
|
+
position: relative;
|
|
152
|
+
margin: 2rem 0;
|
|
153
|
+
padding: 1rem 1.25rem 1rem 1.5rem;
|
|
154
|
+
border: 1px solid var(--border);
|
|
155
|
+
border-left: 3px solid var(--primary);
|
|
156
|
+
border-radius: var(--radius-sm);
|
|
157
|
+
background-color: var(--surface);
|
|
158
|
+
font-size: 15px;
|
|
159
|
+
font-style: italic;
|
|
160
|
+
line-height: 1.8;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.blog-content :deep(blockquote:not(.not-prose):not(.not-prose *) p) {
|
|
164
|
+
margin: 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* Inline code */
|
|
168
|
+
.blog-content :deep(:is(p, li, blockquote, td, th):not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)) {
|
|
169
|
+
color: var(--foreground);
|
|
170
|
+
background-color: var(--surface);
|
|
171
|
+
border: 1px solid var(--border);
|
|
107
172
|
border-radius: 0.25rem;
|
|
108
173
|
display: inline-block;
|
|
109
174
|
font-size: 0.75rem;
|
|
110
175
|
font-weight: 600;
|
|
111
176
|
line-height: 1rem;
|
|
112
177
|
padding: 0.125rem 0.5rem;
|
|
113
|
-
transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out, border-color 0.3s ease-in-out;
|
|
114
178
|
white-space: nowrap;
|
|
115
179
|
}
|
|
116
180
|
|
|
117
181
|
.blog-content :deep(pre:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)) {
|
|
118
|
-
color: var(--
|
|
119
|
-
background-color: var(--
|
|
182
|
+
color: var(--foreground);
|
|
183
|
+
background-color: var(--accent);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* Images */
|
|
187
|
+
.blog-content :deep(img:not(.not-prose):not(.not-prose *)) {
|
|
188
|
+
border: 1px solid var(--border);
|
|
189
|
+
border-radius: var(--radius-sm);
|
|
190
|
+
box-shadow: var(--uv-shadow-md);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* Plain markdown tables */
|
|
194
|
+
.blog-content :deep(table:not(.not-prose):not(.not-prose *)) {
|
|
195
|
+
width: 100%;
|
|
196
|
+
margin: 2rem 0;
|
|
197
|
+
border-collapse: collapse;
|
|
198
|
+
font-size: 14px;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.blog-content :deep(:is(th, td):not(.not-prose):not(.not-prose *)) {
|
|
202
|
+
border-bottom: 1px solid var(--border);
|
|
203
|
+
padding: 0.65rem 0.75rem;
|
|
204
|
+
text-align: left;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.blog-content :deep(th:not(.not-prose):not(.not-prose *)) {
|
|
208
|
+
background-color: var(--surface);
|
|
209
|
+
font-weight: 700;
|
|
120
210
|
}
|
|
121
211
|
|
|
122
212
|
@media (max-width: 640px) {
|
|
123
|
-
.blog-content :deep(p:not(.not-prose):not(.not-prose *)) {
|
|
124
|
-
font-size:
|
|
125
|
-
line-height: 1.
|
|
213
|
+
.blog-content :deep(:is(p, li):not(.not-prose):not(.not-prose *)) {
|
|
214
|
+
font-size: 14px;
|
|
215
|
+
line-height: 1.8;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.blog-content :deep(p:not(.not-prose):not(.not-prose *):first-of-type) {
|
|
219
|
+
font-size: 15px;
|
|
126
220
|
}
|
|
127
221
|
|
|
128
222
|
.blog-content :deep(h2:not(.not-prose):not(.not-prose *)) {
|
|
223
|
+
margin-top: 2.25rem;
|
|
129
224
|
margin-bottom: 1rem;
|
|
130
225
|
}
|
|
131
226
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue';
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
|
-
import {
|
|
4
|
+
import { HelpCircleIcon } from '@hugeicons/core-free-icons';
|
|
5
|
+
import { Accordion } from '../ui/accordion';
|
|
5
6
|
import BlogSectionTitle from './BlogSectionTitle.vue';
|
|
6
7
|
|
|
7
8
|
// Props
|
|
@@ -20,21 +21,18 @@ const hasValidFAQs = computed(() => props.faqs && props.faqs.length > 0);
|
|
|
20
21
|
|
|
21
22
|
<template>
|
|
22
23
|
<div v-if="hasValidFAQs" class="mt-12 lg:mt-16">
|
|
23
|
-
<!-- Title -->
|
|
24
|
-
<BlogSectionTitle :title="t('uiVintage.blog.faq.title')" />
|
|
24
|
+
<!-- Title and description -->
|
|
25
|
+
<BlogSectionTitle :title="t('uiVintage.blog.faq.title')" :description="t('uiVintage.blog.faq.description')" />
|
|
25
26
|
|
|
26
|
-
<!--
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
{{ faq.answer }}
|
|
36
|
-
</p>
|
|
37
|
-
</Accordion>
|
|
27
|
+
<!-- FAQ items -->
|
|
28
|
+
<div class="space-y-3">
|
|
29
|
+
<div v-for="(faq, index) in props.faqs" :key="index" data-aos="blur-up" :data-aos-delay="index * 70">
|
|
30
|
+
<Accordion :title="faq.question" :icon="HelpCircleIcon" :initially-expanded="index === 0">
|
|
31
|
+
<p class="text-xs leading-relaxed text-muted-foreground md:text-sm">
|
|
32
|
+
{{ faq.answer }}
|
|
33
|
+
</p>
|
|
34
|
+
</Accordion>
|
|
35
|
+
</div>
|
|
38
36
|
</div>
|
|
39
37
|
</div>
|
|
40
38
|
</template>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
2
3
|
import type { ToneColor } from '../../lib/color-tokens';
|
|
3
4
|
import { Badge } from '../ui/badge';
|
|
4
5
|
|
|
@@ -13,26 +14,43 @@ const props = withDefaults(defineProps<{
|
|
|
13
14
|
}>(), {
|
|
14
15
|
badges: () => []
|
|
15
16
|
});
|
|
17
|
+
|
|
18
|
+
// Split the title so each word can rise into place on its own
|
|
19
|
+
const titleWords = computed(() => props.title.split(/\s+/).filter(Boolean));
|
|
20
|
+
|
|
21
|
+
// The description follows the last word of the title
|
|
22
|
+
const descriptionDelay = computed(() => `${Math.min(titleWords.value.length, 12) * 55 + 200}ms`);
|
|
16
23
|
</script>
|
|
17
24
|
|
|
18
25
|
<template>
|
|
19
|
-
<
|
|
26
|
+
<header class="relative isolate flex flex-col gap-6 pt-4 pb-2 md:gap-8 md:pt-10">
|
|
27
|
+
<!-- Ambient glow -->
|
|
28
|
+
<div aria-hidden="true" class="pointer-events-none absolute -top-24 -left-24 -z-10 h-80 w-[36rem] max-w-[120%] rounded-full bg-primary/12 blur-[100px]" />
|
|
29
|
+
|
|
20
30
|
<!-- Badges -->
|
|
21
|
-
<div v-if="props.badges.length > 0" class="flex flex-wrap gap-3
|
|
22
|
-
<Badge v-for="badge in props.badges"
|
|
31
|
+
<div v-if="props.badges.length > 0" class="flex flex-wrap gap-3">
|
|
32
|
+
<Badge v-for="(badge, index) in props.badges"
|
|
23
33
|
:key="`${badge.color}-${badge.text}`"
|
|
24
34
|
:color="badge.color"
|
|
25
|
-
:text="badge.text"
|
|
35
|
+
:text="badge.text"
|
|
36
|
+
:pulse="index === 0"
|
|
37
|
+
class="animate-uv-fade-up"
|
|
38
|
+
:style="{ animationDelay: `${index * 70}ms` }" />
|
|
26
39
|
</div>
|
|
27
40
|
|
|
28
|
-
<!-- Title -->
|
|
29
|
-
<h1 class="text-3xl
|
|
30
|
-
|
|
41
|
+
<!-- Title (each word rises in with a spring) -->
|
|
42
|
+
<h1 class="text-3xl leading-[1.05] font-bold tracking-tight text-foreground md:text-6xl">
|
|
43
|
+
<template v-for="(word, index) in titleWords" :key="`${word}-${index}`">
|
|
44
|
+
<span class="inline-block animate-uv-word-in" :style="{ animationDelay: `${120 + Math.min(index, 12) * 55}ms` }">{{ word }}</span>{{ index < titleWords.length - 1 ? ' ' : '' }}
|
|
45
|
+
</template>
|
|
31
46
|
</h1>
|
|
32
47
|
|
|
48
|
+
<!-- Accent rule -->
|
|
49
|
+
<div aria-hidden="true" class="h-1 w-20 origin-left animate-uv-grow-x rounded-full bg-primary shadow-glow" :style="{ animationDelay: descriptionDelay }" />
|
|
50
|
+
|
|
33
51
|
<!-- Description -->
|
|
34
|
-
<p class="text-sm
|
|
52
|
+
<p class="max-w-3xl animate-uv-fade-up text-sm leading-relaxed text-muted-foreground md:text-xl" :style="{ animationDelay: descriptionDelay }">
|
|
35
53
|
{{ props.description }}
|
|
36
54
|
</p>
|
|
37
|
-
</
|
|
55
|
+
</header>
|
|
38
56
|
</template>
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
3
|
import { computed } from 'vue';
|
|
4
4
|
import { useI18n } from 'vue-i18n';
|
|
5
|
+
import { ArrowUpRight01Icon, Calendar03Icon, RefreshIcon } from '@hugeicons/core-free-icons';
|
|
6
|
+
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
5
7
|
import { Card, CardContent } from '../ui/card';
|
|
6
8
|
import { formatDateLongItalyTimezone } from '../../lib/utils';
|
|
7
9
|
|
|
@@ -21,50 +23,49 @@ const { t, locale } = useI18n();
|
|
|
21
23
|
// Formatted dates
|
|
22
24
|
const formattedPublishedDate = computed(() => formatDateLongItalyTimezone(props.datePublished, locale.value));
|
|
23
25
|
const formattedModifiedDate = computed(() => formatDateLongItalyTimezone(props.dateModified, locale.value));
|
|
26
|
+
|
|
27
|
+
// Only show the update date when it differs from the publication date
|
|
28
|
+
const showModifiedDate = computed(() => Boolean(props.dateModified) && formattedModifiedDate.value !== formattedPublishedDate.value);
|
|
24
29
|
</script>
|
|
25
30
|
|
|
26
31
|
<template>
|
|
27
32
|
<!-- Author and date information -->
|
|
28
|
-
<Card data-aos="
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
<Card data-aos="blur-up" class="group/author relative overflow-hidden">
|
|
34
|
+
<!-- Accent glow -->
|
|
35
|
+
<div aria-hidden="true" class="pointer-events-none absolute -top-16 -left-16 size-48 rounded-full bg-primary/10 blur-3xl" />
|
|
36
|
+
|
|
37
|
+
<CardContent class="relative flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
|
|
38
|
+
<!-- Author -->
|
|
39
|
+
<div class="flex items-center gap-4">
|
|
40
|
+
<!-- Author image -->
|
|
41
|
+
<NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="relative shrink-0 rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
|
|
42
|
+
<NuxtImg :src="props.authorImageUrl" :alt="props.author" height="56" width="56" format="avif" quality="40" :sizes="{ 480: '56px', 1280: '56px' }" loading="lazy" decoding="async" class="size-14 rounded border border-border object-cover shadow-elevated-sm transition-[scale] duration-300 ease-spring hover:scale-105" />
|
|
43
|
+
</NuxtLink>
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
</p>
|
|
47
|
-
</div>
|
|
48
|
-
</div>
|
|
45
|
+
<!-- Author name -->
|
|
46
|
+
<div class="min-w-0">
|
|
47
|
+
<p class="text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase">{{ t('uiVintage.blog.author') }}</p>
|
|
48
|
+
<NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="group/link mt-0.5 inline-flex items-center gap-1 text-base font-bold text-foreground outline-none hover:text-primary focus-visible:underline">
|
|
49
|
+
{{ props.author }}
|
|
50
|
+
<HugeiconsIcon :icon="ArrowUpRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/link:translate-x-0.5 group-hover/link:-translate-y-0.5" />
|
|
51
|
+
</NuxtLink>
|
|
49
52
|
</div>
|
|
53
|
+
</div>
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
</div>
|
|
55
|
+
<!-- Date information -->
|
|
56
|
+
<div class="flex flex-wrap gap-2 sm:justify-end">
|
|
57
|
+
<!-- Published date -->
|
|
58
|
+
<div v-if="props.datePublished" class="flex items-center gap-2 rounded border border-border bg-surface px-3 py-2 text-xs text-muted-foreground">
|
|
59
|
+
<HugeiconsIcon :icon="Calendar03Icon" class="size-4 shrink-0 text-primary" />
|
|
60
|
+
<span class="font-semibold">{{ t('uiVintage.blog.publishedDate') }}</span>
|
|
61
|
+
<time :datetime="props.datePublished" class="text-foreground">{{ formattedPublishedDate }}</time>
|
|
62
|
+
</div>
|
|
60
63
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
</time>
|
|
67
|
-
</div>
|
|
64
|
+
<!-- Last updated date -->
|
|
65
|
+
<div v-if="showModifiedDate" class="flex items-center gap-2 rounded border border-border bg-surface px-3 py-2 text-xs text-muted-foreground">
|
|
66
|
+
<HugeiconsIcon :icon="RefreshIcon" class="size-4 shrink-0 text-primary" />
|
|
67
|
+
<span class="font-semibold">{{ t('uiVintage.blog.lastUpdatedDate') }}</span>
|
|
68
|
+
<time :datetime="props.dateModified ?? undefined" class="text-foreground">{{ formattedModifiedDate }}</time>
|
|
68
69
|
</div>
|
|
69
70
|
</div>
|
|
70
71
|
</CardContent>
|
|
@@ -43,13 +43,16 @@ onUnmounted(() => {
|
|
|
43
43
|
</script>
|
|
44
44
|
|
|
45
45
|
<template>
|
|
46
|
-
<div :class="cn('relative h-
|
|
46
|
+
<div :class="cn('relative h-[3px] w-full bg-border/40 landing-navbar-progress', props.class)"
|
|
47
47
|
role="progressbar"
|
|
48
48
|
:aria-label="resolvedAriaLabel"
|
|
49
49
|
aria-valuemin="0"
|
|
50
50
|
aria-valuemax="100"
|
|
51
51
|
:aria-valuenow="Math.round(progress)">
|
|
52
|
-
|
|
52
|
+
<!-- Fill with a glowing leading edge (the dot stays inside the fill, so it is never clipped and never widens the page) -->
|
|
53
|
+
<div class="relative h-full bg-linear-to-r from-primary/40 via-primary to-primary transition-[width] duration-150 ease-out" :style="{ width: `${progress}%` }">
|
|
54
|
+
<span v-if="progress > 0" aria-hidden="true" class="absolute top-1/2 right-0 size-2 -translate-y-1/2 rounded-full bg-primary shadow-[0_0_10px_2px_color-mix(in_oklab,var(--primary)_70%,transparent)]" />
|
|
55
|
+
</div>
|
|
53
56
|
</div>
|
|
54
57
|
</template>
|
|
55
58
|
|
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// Props
|
|
3
|
-
const props = defineProps<{
|
|
3
|
+
const props = withDefaults(defineProps<{
|
|
4
4
|
title: string;
|
|
5
|
-
|
|
5
|
+
description?: string;
|
|
6
|
+
}>(), {
|
|
7
|
+
description: ''
|
|
8
|
+
});
|
|
6
9
|
</script>
|
|
7
10
|
|
|
8
11
|
<template>
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
<div data-aos="blur-up" class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between md:mb-8">
|
|
13
|
+
<div class="min-w-0">
|
|
14
|
+
<!-- Title with accent bar -->
|
|
15
|
+
<h2 class="flex items-center gap-3 text-xl font-bold tracking-tight text-foreground md:text-2xl">
|
|
16
|
+
<span aria-hidden="true" class="h-6 w-1 shrink-0 rounded-full bg-primary shadow-glow md:h-7" />
|
|
17
|
+
{{ props.title }}
|
|
18
|
+
</h2>
|
|
19
|
+
|
|
20
|
+
<!-- Description -->
|
|
21
|
+
<p v-if="props.description" class="mt-2 max-w-2xl text-xs leading-6 text-muted-foreground md:text-sm">
|
|
22
|
+
{{ props.description }}
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<!-- Optional trailing action (for example a "View all" link) -->
|
|
27
|
+
<div v-if="$slots.action" class="shrink-0">
|
|
28
|
+
<slot name="action" />
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
12
31
|
</template>
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { Badge } from '../ui/badge';
|
|
3
|
-
|
|
4
2
|
// Props
|
|
5
3
|
const props = withDefaults(defineProps<{
|
|
6
4
|
tags?: Array<{
|
|
@@ -15,8 +13,13 @@ const props = withDefaults(defineProps<{
|
|
|
15
13
|
|
|
16
14
|
<template>
|
|
17
15
|
<div v-if="props.tags.length" class="flex flex-wrap gap-3">
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
<!-- Tag chip -->
|
|
17
|
+
<span v-for="(tag, index) in props.tags" :key="tag.slug" data-aos="blur-up" :data-aos-delay="Math.min(index * 40, 400)" class="inline-flex">
|
|
18
|
+
<NuxtLinkLocale :to="`/blog/tags/${tag.slug}`" class="group/tag inline-flex items-center gap-2 rounded border border-border bg-card px-3 py-2 text-xs font-semibold text-foreground shadow-elevated-sm outline-none [transition:translate_300ms_var(--ease-spring),border-color_150ms,color_150ms,box-shadow_200ms] hover:-translate-y-0.5 hover:border-primary/50 hover:text-primary hover:shadow-elevated-md focus-visible:ring-[3px] focus-visible:ring-ring/45 sm:text-sm">
|
|
19
|
+
<span aria-hidden="true" class="text-primary transition-[rotate] duration-300 ease-bounce group-hover/tag:rotate-12">#</span>
|
|
20
|
+
{{ tag.name }}
|
|
21
|
+
<span class="rounded-sm bg-surface px-1.5 py-0.5 text-[11px] text-muted-foreground tabular-nums transition-colors duration-150 group-hover/tag:bg-primary/10 group-hover/tag:text-primary">{{ tag.count }}</span>
|
|
22
|
+
</NuxtLinkLocale>
|
|
23
|
+
</span>
|
|
21
24
|
</div>
|
|
22
25
|
</template>
|
|
@@ -16,9 +16,9 @@ const props = withDefaults(defineProps<{
|
|
|
16
16
|
</script>
|
|
17
17
|
|
|
18
18
|
<template>
|
|
19
|
-
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4
|
|
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,
|
|
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">
|
|
22
22
|
<CategoryCard :name="category.name" :image="category.image" :count="category.count" />
|
|
23
23
|
</NuxtLink>
|
|
24
24
|
</div>
|