@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.
Files changed (44) hide show
  1. package/COMPONENTS.md +4 -1
  2. package/package.json +2 -2
  3. package/src/components/blog/AllPostsSection.vue +1 -2
  4. package/src/components/blog/BlogContentRenderer.vue +144 -49
  5. package/src/components/blog/BlogFAQSection.vue +13 -15
  6. package/src/components/blog/BlogHeaderSection.vue +27 -9
  7. package/src/components/blog/BlogInfoSection.vue +37 -36
  8. package/src/components/blog/BlogProgressBar.vue +5 -2
  9. package/src/components/blog/BlogSectionTitle.vue +24 -5
  10. package/src/components/blog/BlogTagsSection.vue +8 -5
  11. package/src/components/blog/CategoriesList.vue +2 -2
  12. package/src/components/blog/CategoryCard.vue +25 -16
  13. package/src/components/blog/HeaderCarousel.vue +109 -146
  14. package/src/components/blog/PostCard.vue +34 -35
  15. package/src/components/blog/PostsList.vue +3 -2
  16. package/src/components/blog/SocialShareSidebar.vue +51 -41
  17. package/src/components/blog/TableOfContents.vue +14 -10
  18. package/src/components/content/BlogList.vue +16 -4
  19. package/src/components/content/BlogTable.vue +4 -4
  20. package/src/components/content/CodeBlock.vue +5 -5
  21. package/src/components/content/Flow.vue +4 -4
  22. package/src/components/content/ProseHr.vue +5 -5
  23. package/src/components/content/Terminal.vue +10 -4
  24. package/src/components/landing/landing-footer/LandingFooter.vue +9 -4
  25. package/src/components/landing/landing-navbar/LandingNavbar.vue +47 -51
  26. package/src/components/landing/landing-shell/LandingShell.vue +7 -2
  27. package/src/components/ui/accordion/Accordion.vue +6 -6
  28. package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +1 -1
  29. package/src/components/ui/breadcrumb/BreadcrumbLink.vue +1 -1
  30. package/src/components/ui/breadcrumb/BreadcrumbList.vue +1 -1
  31. package/src/components/ui/breadcrumb/BreadcrumbPage.vue +1 -1
  32. package/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +2 -6
  33. package/src/components/ui/card/Card.vue +1 -1
  34. package/src/components/ui/command/CommandItem.vue +2 -2
  35. package/src/i18n/de.json +3 -1
  36. package/src/i18n/en.json +3 -1
  37. package/src/i18n/es.json +3 -1
  38. package/src/i18n/fr.json +3 -1
  39. package/src/i18n/it.json +3 -1
  40. package/src/i18n/ja.json +3 -1
  41. package/src/i18n/pt.json +3 -1
  42. package/src/i18n/ru.json +3 -1
  43. package/src/i18n/zh.json +3 -1
  44. 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 cards and lists, categories, tags, table of contents, reading progress, FAQ, share sidebar, and the content renderer.
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.1",
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
- --blog-content-text: var(--text-primary-light);
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(--blog-content-text);
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: 2rem;
45
- margin-bottom: 1.5rem;
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.375rem;
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.125rem;
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(--blog-content-text);
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.75;
91
+ line-height: 1.85;
78
92
  }
79
93
 
80
- .blog-content :deep(p:not(.not-prose):not(.not-prose *) strong:not(.not-prose):not(.not-prose *)),
81
- .blog-content :deep(blockquote:not(.not-prose):not(.not-prose *) strong:not(.not-prose):not(.not-prose *)) {
82
- font-weight: 700;
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(p:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)),
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
- .blog-content :deep(p:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *):hover),
94
- .blog-content :deep(li:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *):hover),
95
- .blog-content :deep(blockquote:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *):hover) {
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(p:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)),
100
- .blog-content :deep(li:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)),
101
- .blog-content :deep(blockquote:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)),
102
- .blog-content :deep(td:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)),
103
- .blog-content :deep(th:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)) {
104
- color: var(--blog-content-text);
105
- background-color: var(--blog-content-inline-code-bg);
106
- border: 1px solid var(--blog-content-inline-code-border);
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(--blog-content-text);
119
- background-color: var(--blog-content-code-bg);
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: 13px;
125
- line-height: 1.75;
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 { Accordion } from '@brumaombra/ui-vintage/accordion';
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
- <!-- Description -->
27
- <p class="text-base md:text-lg text-muted-foreground mb-8">
28
- {{ t('uiVintage.blog.faq.description') }}
29
- </p>
30
-
31
- <!-- FAQ Items -->
32
- <div class="space-y-4">
33
- <Accordion v-for="(faq, index) in props.faqs" :key="index" :title="faq.question" :initially-expanded="index === 0">
34
- <p class="text-xs md:text-sm text-muted-foreground leading-relaxed">
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
- <div class="flex flex-col space-y-6 md:space-y-8!">
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 mb-6 md:mb-8">
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 md:text-6xl font-bold text-foreground leading-tight">
30
- {{ props.title }}
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 md:text-xl text-muted-foreground leading-relaxed">
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
- </div>
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="fade-up">
29
- <CardContent>
30
- <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
31
- <!-- Author -->
32
- <div class="flex items-center mb-4 sm:mb-0">
33
- <div class="flex items-center">
34
- <!-- Author image -->
35
- <NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="rounded me-3 transition-transform duration-300 ease-out hover:scale-105 motion-reduce:transition-none motion-reduce:hover:scale-100">
36
- <NuxtImg :src="props.authorImageUrl" :alt="props.author" height="50" width="50" format="avif" quality="40" :sizes="{ 480: '50px', 1280: '50px' }" loading="lazy" decoding="async" class="object-cover rounded" />
37
- </NuxtLink>
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
- <!-- Author name -->
40
- <div>
41
- <p class="text-sm text-muted-foreground">{{ t('uiVintage.blog.author') }}</p>
42
- <p class="font-bold text-foreground">
43
- <NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="hover:underline">
44
- {{ props.author }}
45
- </NuxtLink>
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
- <!-- Date information -->
52
- <div class="flex flex-col sm:items-end text-xs md:text-sm text-muted-foreground">
53
- <!-- Published date -->
54
- <div v-if="props.datePublished" class="mb-1">
55
- <span class="font-semibold">{{ t('uiVintage.blog.publishedDate') }}:</span>
56
- <time :datetime="props.datePublished" class="ml-1">
57
- {{ formattedPublishedDate }}
58
- </time>
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
- <!-- Last updated date -->
62
- <div v-if="props.dateModified" class="text-xs">
63
- <span class="font-semibold">{{ t('uiVintage.blog.lastUpdatedDate') }}:</span>
64
- <time :datetime="props.dateModified" class="ml-1">
65
- {{ formattedModifiedDate }}
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-1 w-full border-t border-(--border-light) bg-(--bg-selected-light) dark:border-(--border-dark) dark:bg-(--bg-selected-dark) landing-navbar-progress', props.class)"
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
- <div class="h-full origin-left bg-primary transition-[width] duration-150 ease-out" :style="{ width: `${progress}%` }" />
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
- <h2 class="text-xl md:text-2xl font-bold text-foreground mb-5">
10
- {{ props.title }}
11
- </h2>
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
- <NuxtLinkLocale v-for="(tag, index) in props.tags" :key="tag.slug" :to="`/blog/tags/${tag.slug}`" data-aos="fade-up" :data-aos-delay="Math.min(index * 100, 500)">
19
- <Badge color="gray" :text="`${tag.name} (${tag.count})`" class="transition-transform duration-200 ease-out hover:scale-105 motion-reduce:transition-none motion-reduce:hover:scale-100" />
20
- </NuxtLinkLocale>
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 gap-5 md:gap-6">
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, i) in props.categories" :key="category.path || category.slug" :to="category.path" class="block h-full" data-aos="fade-up" :data-aos-delay="(i % 4) * 200">
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>