@brumaombra/ui-vintage 0.8.1 → 0.9.0

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 (142) hide show
  1. package/AGENTS.md +41 -0
  2. package/COMPONENTS.md +91 -17
  3. package/README.md +296 -262
  4. package/module.mjs +5 -11
  5. package/package.json +31 -13
  6. package/src/components/blog/AllPostsSection.vue +1 -2
  7. package/src/components/blog/BlogContentRenderer.vue +144 -49
  8. package/src/components/blog/BlogFAQSection.vue +13 -15
  9. package/src/components/blog/BlogHeaderSection.vue +103 -11
  10. package/src/components/blog/BlogInfoSection.vue +46 -42
  11. package/src/components/blog/BlogPostHeader.vue +181 -0
  12. package/src/components/blog/BlogProgressBar.vue +3 -2
  13. package/src/components/blog/BlogSectionTitle.vue +27 -6
  14. package/src/components/blog/BlogTagsSection.vue +20 -8
  15. package/src/components/blog/CategoriesList.vue +2 -2
  16. package/src/components/blog/CategoryCard.vue +23 -19
  17. package/src/components/blog/HeaderCarousel.vue +117 -145
  18. package/src/components/blog/PostCard.vue +25 -33
  19. package/src/components/blog/PostsList.vue +3 -2
  20. package/src/components/blog/SocialShareSidebar.vue +62 -42
  21. package/src/components/blog/TableOfContents.vue +71 -34
  22. package/src/components/blog/index.ts +1 -0
  23. package/src/components/card-grid/CardGrid.vue +2 -2
  24. package/src/components/confirm-dialog/ConfirmDialog.vue +5 -0
  25. package/src/components/confirm-dialog/confirm-dialog-state.ts +22 -22
  26. package/src/components/content/BlogList.vue +30 -42
  27. package/src/components/content/BlogTable.vue +26 -30
  28. package/src/components/content/CodeBlock.vue +96 -22
  29. package/src/components/content/Flow.vue +30 -36
  30. package/src/components/content/ProseHr.vue +6 -6
  31. package/src/components/content/ProsePre.vue +9 -4
  32. package/src/components/content/Terminal.vue +42 -14
  33. package/src/components/cookie-consent/CookieConsent.vue +1 -1
  34. package/src/components/dashboard-shell/DashboardShell.vue +24 -7
  35. package/src/components/empty-state-card/EmptyStateCard.vue +2 -2
  36. package/src/components/error-page/ErrorPage.vue +3 -3
  37. package/src/components/info-card/InfoCard.vue +4 -4
  38. package/src/components/landing/landing-footer/LandingFooter.vue +16 -6
  39. package/src/components/landing/landing-navbar/LandingNavbar.vue +63 -51
  40. package/src/components/language-flag/index.ts +1 -0
  41. package/src/components/language-selector/LanguageSelector.vue +18 -10
  42. package/src/components/load-more-button/LoadMoreButton.vue +7 -12
  43. package/src/components/loading-state-card/LoadingStateCard.vue +2 -2
  44. package/src/components/message-dialog/MessageDialog.vue +11 -11
  45. package/src/components/message-dialog/message-dialog-state.ts +1 -4
  46. package/src/components/message-toast/MessageToast.vue +24 -9
  47. package/src/components/message-toast/message-toast-state.ts +20 -0
  48. package/src/components/page-header/PageHeader.vue +1 -1
  49. package/src/components/progress-component/ProgressComponent.vue +7 -3
  50. package/src/components/single-value-card/SingleValueCard.vue +37 -39
  51. package/src/components/slider-form-component/SliderFormComponent.vue +1 -1
  52. package/src/components/stat-strip/StatStrip.vue +36 -0
  53. package/src/components/stat-strip/index.ts +2 -0
  54. package/src/components/switch-form-component/SwitchFormComponent.vue +5 -5
  55. package/src/components/text-link/TextLink.vue +1 -1
  56. package/src/components/theme-selector/ThemeSelector.vue +2 -2
  57. package/src/components/theme-selector/theme-transition.ts +15 -3
  58. package/src/components/ui/accordion/Accordion.vue +18 -18
  59. package/src/components/ui/alert/AlertTitle.vue +1 -1
  60. package/src/components/ui/alert/index.ts +1 -1
  61. package/src/components/ui/avatar/AvatarFallback.vue +2 -1
  62. package/src/components/ui/avatar/AvatarGroup.vue +13 -3
  63. package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +2 -2
  64. package/src/components/ui/breadcrumb/BreadcrumbLink.vue +1 -1
  65. package/src/components/ui/breadcrumb/BreadcrumbList.vue +1 -1
  66. package/src/components/ui/breadcrumb/BreadcrumbPage.vue +1 -1
  67. package/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +2 -6
  68. package/src/components/ui/busy/Busy.vue +5 -7
  69. package/src/components/ui/button/Button.vue +4 -2
  70. package/src/components/ui/button/index.ts +1 -4
  71. package/src/components/ui/calendar/Calendar.vue +12 -7
  72. package/src/components/ui/calendar/CalendarCell.vue +1 -1
  73. package/src/components/ui/calendar/CalendarCellTrigger.vue +1 -1
  74. package/src/components/ui/calendar/CalendarHeadCell.vue +1 -1
  75. package/src/components/ui/calendar/CalendarNextButton.vue +1 -1
  76. package/src/components/ui/calendar/CalendarPrevButton.vue +1 -1
  77. package/src/components/ui/card/Card.vue +5 -15
  78. package/src/components/ui/checkbox/Checkbox.vue +5 -1
  79. package/src/components/ui/combobox/ComboboxInput.vue +5 -1
  80. package/src/components/ui/command/CommandGroup.vue +17 -3
  81. package/src/components/ui/command/CommandInput.vue +1 -1
  82. package/src/components/ui/command/CommandItem.vue +2 -2
  83. package/src/components/ui/command/CommandList.vue +5 -1
  84. package/src/components/ui/data-table/DataTable.vue +11 -3
  85. package/src/components/ui/date-picker/DatePicker.vue +7 -1
  86. package/src/components/ui/date-time-picker/DateTimePicker.vue +5 -1
  87. package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  88. package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +1 -1
  89. package/src/components/ui/dropdown-menu/classes.ts +2 -0
  90. package/src/components/ui/field/Field.vue +4 -0
  91. package/src/components/ui/field/FieldLabel.vue +6 -2
  92. package/src/components/ui/field/field-context.ts +62 -0
  93. package/src/components/ui/field/index.ts +1 -0
  94. package/src/components/ui/file-dropzone/FileDropzone.vue +4 -4
  95. package/src/components/ui/input/Input.vue +5 -1
  96. package/src/components/ui/native-select/NativeSelect.vue +6 -2
  97. package/src/components/ui/number-field/NumberField.vue +5 -1
  98. package/src/components/ui/pagination/PaginationItem.vue +1 -1
  99. package/src/components/ui/pin-input/PinInputInput.vue +2 -2
  100. package/src/components/ui/progress/Progress.vue +2 -2
  101. package/src/components/ui/radio-group/RadioGroupCard.vue +1 -1
  102. package/src/components/ui/select/SelectItem.vue +1 -1
  103. package/src/components/ui/select/SelectItemContent.vue +1 -1
  104. package/src/components/ui/select/SelectTrigger.vue +5 -1
  105. package/src/components/ui/sidebar/Sidebar.vue +1 -1
  106. package/src/components/ui/sidebar/SidebarGroupAction.vue +1 -1
  107. package/src/components/ui/sidebar/SidebarGroupLabel.vue +1 -1
  108. package/src/components/ui/sidebar/SidebarInset.vue +1 -1
  109. package/src/components/ui/sidebar/SidebarMenuAction.vue +1 -1
  110. package/src/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
  111. package/src/components/ui/sidebar/SidebarMenuSkeleton.vue +2 -2
  112. package/src/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
  113. package/src/components/ui/sidebar/SidebarProvider.vue +17 -6
  114. package/src/components/ui/sidebar/index.ts +2 -2
  115. package/src/components/ui/sidebar/utils.ts +1 -1
  116. package/src/components/ui/slider/Slider.vue +5 -4
  117. package/src/components/ui/spinner/Spinner.vue +3 -2
  118. package/src/components/ui/stepper/StepperDescription.vue +5 -3
  119. package/src/components/ui/stepper/StepperIndicator.vue +2 -2
  120. package/src/components/ui/stepper/StepperTitle.vue +5 -3
  121. package/src/components/ui/switch/Switch.vue +6 -2
  122. package/src/components/ui/tabs/TabsList.vue +1 -1
  123. package/src/components/ui/tabs/TabsTrigger.vue +1 -1
  124. package/src/components/ui/textarea/Textarea.vue +5 -1
  125. package/src/components/ui/time-picker/TimePicker.vue +2 -2
  126. package/src/components/ui/toggle-group/ToggleGroup.vue +1 -1
  127. package/src/components/ui/tooltip/TooltipContent.vue +1 -1
  128. package/src/i18n/de.json +19 -7
  129. package/src/i18n/en.json +19 -7
  130. package/src/i18n/es.json +19 -7
  131. package/src/i18n/fr.json +19 -7
  132. package/src/i18n/it.json +19 -7
  133. package/src/i18n/ja.json +19 -7
  134. package/src/i18n/pt.json +19 -7
  135. package/src/i18n/ru.json +19 -7
  136. package/src/i18n/zh.json +19 -7
  137. package/src/lib/color-tokens.ts +1 -1
  138. package/src/lib/i18n.ts +32 -4
  139. package/src/lib/utils.ts +21 -1
  140. package/src/runtime/ui-vintage-i18n-plugin.ts +11 -1
  141. package/src/styles.css +52 -16
  142. package/skills/ui-vintage/SKILL.md +0 -42
@@ -2,7 +2,9 @@
2
2
  import { NuxtImg } from '#components';
3
3
  import { computed } from 'vue';
4
4
  import { useI18n } from 'vue-i18n';
5
- import { Card, CardContent } from '../ui/card';
5
+ import { ArrowUpRight01Icon, Calendar03Icon, RefreshIcon } from '@hugeicons/core-free-icons';
6
+ import { HugeiconsIcon } from '@hugeicons/vue';
7
+ import { Card } from '../ui/card';
6
8
  import { formatDateLongItalyTimezone } from '../../lib/utils';
7
9
 
8
10
  // Props
@@ -10,9 +12,11 @@ const props = withDefaults(defineProps<{
10
12
  author: string;
11
13
  authorUrl: string;
12
14
  authorImageUrl: string;
15
+ authorBio?: string;
13
16
  datePublished: string;
14
17
  dateModified?: string | null;
15
18
  }>(), {
19
+ authorBio: '',
16
20
  dateModified: null
17
21
  });
18
22
 
@@ -21,52 +25,52 @@ const { t, locale } = useI18n();
21
25
  // Formatted dates
22
26
  const formattedPublishedDate = computed(() => formatDateLongItalyTimezone(props.datePublished, locale.value));
23
27
  const formattedModifiedDate = computed(() => formatDateLongItalyTimezone(props.dateModified, locale.value));
28
+
29
+ // Only show the update date when it differs from the publication date
30
+ const showModifiedDate = computed(() => Boolean(props.dateModified) && formattedModifiedDate.value !== formattedPublishedDate.value);
24
31
  </script>
25
32
 
26
33
  <template>
27
34
  <!-- 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>
35
+ <Card data-aos="blur-up" class="gap-0! overflow-hidden p-0!">
36
+ <!-- Author -->
37
+ <div :class="['flex gap-3 px-5 py-4', props.authorBio ? 'items-start' : 'items-center']">
38
+ <!-- Author image -->
39
+ <NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="shrink-0 rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
40
+ <NuxtImg :src="props.authorImageUrl" :alt="props.author" height="44" width="44" format="avif" quality="40" :sizes="{ 480: '44px', 1280: '44px' }" loading="lazy" decoding="async" class="size-11 rounded border border-border object-cover" />
41
+ </NuxtLink>
42
+
43
+ <!-- Author name and bio -->
44
+ <div class="min-w-0 flex-1">
45
+ <p class="text-[11px] font-semibold tracking-wider text-muted-foreground uppercase">{{ t('uiVintage.blog.author') }}</p>
46
+ <NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="group/link inline-flex items-center gap-1 text-sm font-semibold text-foreground outline-none transition-colors hover:text-primary focus-visible:underline md:text-base">
47
+ {{ props.author }}
48
+ <HugeiconsIcon :icon="ArrowUpRight01Icon" class="size-4 text-muted-foreground transition-[translate,color] duration-300 ease-spring group-hover/link:translate-x-0.5 group-hover/link:-translate-y-0.5 group-hover/link:text-primary" />
49
+ </NuxtLink>
50
+ <p v-if="props.authorBio" class="mt-1.5 text-xs leading-relaxed text-muted-foreground md:text-sm">
51
+ {{ props.authorBio }}
52
+ </p>
53
+ </div>
54
+ </div>
38
55
 
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>
49
- </div>
56
+ <!-- Date information -->
57
+ <div v-if="props.datePublished || showModifiedDate" class="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-border bg-surface px-5 py-2.5 text-xs text-muted-foreground">
58
+ <!-- Published date -->
59
+ <span v-if="props.datePublished" class="inline-flex items-center gap-2">
60
+ <HugeiconsIcon :icon="Calendar03Icon" class="size-3.5 shrink-0 text-primary" />
61
+ {{ t('uiVintage.blog.publishedDate') }}
62
+ <time :datetime="props.datePublished" class="text-foreground">{{ formattedPublishedDate }}</time>
63
+ </span>
50
64
 
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>
65
+ <!-- Divider between the dates (hidden when they wrap on small screens) -->
66
+ <span v-if="props.datePublished && showModifiedDate" aria-hidden="true" class="hidden h-3.5 w-px bg-border sm:block" />
60
67
 
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>
68
- </div>
69
- </div>
70
- </CardContent>
68
+ <!-- Last updated date -->
69
+ <span v-if="showModifiedDate" class="inline-flex items-center gap-2">
70
+ <HugeiconsIcon :icon="RefreshIcon" class="size-3.5 shrink-0 text-primary" />
71
+ {{ t('uiVintage.blog.lastUpdatedDate') }}
72
+ <time :datetime="props.dateModified ?? undefined" class="text-foreground">{{ formattedModifiedDate }}</time>
73
+ </span>
74
+ </div>
71
75
  </Card>
72
- </template>
76
+ </template>
@@ -0,0 +1,181 @@
1
+ <script setup lang="ts">
2
+ import { NuxtImg } from '#components';
3
+ import { computed } from 'vue';
4
+ import { useI18n } from 'vue-i18n';
5
+ import { Badge } from '../ui/badge';
6
+ import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '../ui/breadcrumb';
7
+ import { formatDateLongItalyTimezone, getReadingMinutes } from '../../lib/utils';
8
+
9
+ // Props
10
+ const props = withDefaults(defineProps<{
11
+ title: string;
12
+ description?: string;
13
+ image?: string;
14
+ author?: string;
15
+ authorImageUrl?: string;
16
+ datePublished?: string;
17
+ categoryText?: string;
18
+ categorySlug?: string;
19
+ tags?: Array<{
20
+ name: string;
21
+ slug: string;
22
+ }>;
23
+ body?: unknown;
24
+ readingMinutes?: number | null;
25
+ blogPath?: string;
26
+ blogLabel?: string;
27
+ animated?: boolean;
28
+ }>(), {
29
+ description: '',
30
+ image: '',
31
+ author: '',
32
+ authorImageUrl: '',
33
+ datePublished: '',
34
+ categoryText: '',
35
+ categorySlug: '',
36
+ tags: () => [],
37
+ readingMinutes: null,
38
+ blogPath: '/blog',
39
+ blogLabel: '',
40
+ animated: true
41
+ });
42
+
43
+ const { t, locale } = useI18n();
44
+
45
+ // Split the title so each word can rise into place on its own
46
+ const titleWords = computed(() => props.title.split(/\s+/).filter(Boolean));
47
+
48
+ // Long publication date in the current locale
49
+ const formattedDate = computed(() => formatDateLongItalyTimezone(props.datePublished, locale.value));
50
+
51
+ // Explicit reading time wins, otherwise estimate it from the body
52
+ const resolvedReadingMinutes = computed(() => {
53
+ if (props.readingMinutes) return props.readingMinutes;
54
+ return props.body ? getReadingMinutes(props.body) : null;
55
+ });
56
+
57
+ // Links built from the blog base path
58
+ const categoryPath = computed(() => `${props.blogPath}/categories/${props.categorySlug}`);
59
+ const tagPath = (slug: string) => `${props.blogPath}/tags/${slug}`;
60
+
61
+ // Keep only the entrance classes when animations are enabled
62
+ const enter = (classes: string) => props.animated ? classes : '';
63
+
64
+ // Whether the category link can be built
65
+ const hasCategory = computed(() => Boolean(props.categoryText && props.categorySlug));
66
+
67
+ // Whether the details row has any column to show
68
+ const hasDetails = computed(() => Boolean(props.author || formattedDate.value || resolvedReadingMinutes.value || hasCategory.value));
69
+
70
+ const badgeLinkClasses = 'rounded outline-none transition-opacity duration-150 hover:opacity-80 focus-visible:ring-[3px] focus-visible:ring-ring/45';
71
+
72
+ // Shared classes for the details cells (exposed to the meta slot)
73
+ const detailCellClasses = 'min-w-0 border-t border-l border-border py-4 pl-4 pr-4';
74
+ const detailLabelClasses = 'text-[11px] font-semibold tracking-wider text-muted-foreground uppercase';
75
+ </script>
76
+
77
+ <template>
78
+ <header class="relative isolate mb-10 md:mb-14">
79
+ <!-- Breadcrumbs -->
80
+ <Breadcrumb :class="enter('animate-uv-fade-up')">
81
+ <BreadcrumbList>
82
+ <BreadcrumbItem>
83
+ <BreadcrumbLink as-child>
84
+ <NuxtLinkLocale :to="props.blogPath">
85
+ {{ props.blogLabel || t('uiVintage.blog.breadcrumbBlog') }}
86
+ </NuxtLinkLocale>
87
+ </BreadcrumbLink>
88
+ </BreadcrumbItem>
89
+ <BreadcrumbSeparator />
90
+ <template v-if="hasCategory">
91
+ <BreadcrumbItem>
92
+ <BreadcrumbLink as-child>
93
+ <NuxtLinkLocale :to="categoryPath">
94
+ {{ props.categoryText }}
95
+ </NuxtLinkLocale>
96
+ </BreadcrumbLink>
97
+ </BreadcrumbItem>
98
+ <BreadcrumbSeparator />
99
+ </template>
100
+ <BreadcrumbItem class="min-w-0">
101
+ <BreadcrumbPage class="truncate">
102
+ {{ props.title }}
103
+ </BreadcrumbPage>
104
+ </BreadcrumbItem>
105
+ </BreadcrumbList>
106
+ </Breadcrumb>
107
+
108
+ <!-- Title and lede, closed by a rule with a primary segment -->
109
+ <div class="relative mt-8 border-b border-border pb-8">
110
+ <span aria-hidden="true" :class="['absolute -bottom-px left-0 h-0.5 w-20 origin-left bg-primary', enter('animate-uv-grow-x')]" :style="props.animated ? { animationDelay: '500ms' } : undefined" />
111
+
112
+ <!-- Title (each word rises in) -->
113
+ <h1 class="text-3xl leading-[1.08] font-semibold tracking-tight text-foreground sm:text-4xl md:text-5xl">
114
+ <template v-for="(word, index) in titleWords" :key="`${word}-${index}`">
115
+ <span :class="['inline-block', enter('animate-uv-word-in')]" :style="props.animated ? { animationDelay: `${120 + Math.min(index, 12) * 50}ms` } : undefined">{{ word }}</span>{{ index < titleWords.length - 1 ? ' ' : '' }}
116
+ </template>
117
+ </h1>
118
+
119
+ <!-- Lede -->
120
+ <p v-if="props.description" :class="['mt-5 max-w-3xl text-sm leading-relaxed text-muted-foreground md:text-lg', enter('animate-uv-fade-up [animation-delay:450ms]')]">
121
+ {{ props.description }}
122
+ </p>
123
+ </div>
124
+
125
+ <!-- Labelled details (columns without data are left out; the meta slot adds more) -->
126
+ <div v-if="hasDetails || $slots.meta" :class="['overflow-hidden border-b border-border', enter('animate-uv-fade-up [animation-delay:550ms]')]">
127
+ <dl class="-mt-px -ml-[calc(1rem+1px)] grid grid-cols-2 md:grid-flow-col md:auto-cols-fr md:grid-cols-none">
128
+ <!-- Author -->
129
+ <div v-if="props.author" :class="detailCellClasses">
130
+ <dt :class="detailLabelClasses">{{ t('uiVintage.blog.author') }}</dt>
131
+ <dd class="mt-2 flex min-w-0 items-center gap-2 text-sm font-semibold text-foreground">
132
+ <NuxtImg v-if="props.authorImageUrl" :src="props.authorImageUrl" :alt="props.author" width="24" height="24" format="avif" quality="40" class="size-6 shrink-0 rounded border border-border object-cover" />
133
+ <span class="truncate">{{ props.author }}</span>
134
+ </dd>
135
+ </div>
136
+
137
+ <!-- Publication date -->
138
+ <div v-if="formattedDate" :class="detailCellClasses">
139
+ <dt :class="detailLabelClasses">{{ t('uiVintage.blog.publishedDate') }}</dt>
140
+ <dd class="mt-2 text-sm text-foreground">
141
+ <time :datetime="props.datePublished">{{ formattedDate }}</time>
142
+ </dd>
143
+ </div>
144
+
145
+ <!-- Reading time -->
146
+ <div v-if="resolvedReadingMinutes" :class="detailCellClasses">
147
+ <dt :class="detailLabelClasses">{{ t('uiVintage.blog.readingTime') }}</dt>
148
+ <dd class="mt-2 text-sm text-foreground">{{ t('uiVintage.blog.readingTimeValue', { minutes: resolvedReadingMinutes }) }}</dd>
149
+ </div>
150
+
151
+ <!-- Category -->
152
+ <div v-if="hasCategory" :class="detailCellClasses">
153
+ <dt :class="detailLabelClasses">{{ t('uiVintage.blog.category') }}</dt>
154
+ <dd class="mt-1.5">
155
+ <NuxtLinkLocale :to="categoryPath" :class="['inline-flex', badgeLinkClasses]">
156
+ <Badge color="yellow" :text="props.categoryText" pulse />
157
+ </NuxtLinkLocale>
158
+ </dd>
159
+ </div>
160
+
161
+ <!-- Extra columns from the app (use the same cell structure: a div with a dt and a dd) -->
162
+ <slot name="meta" :cell-classes="detailCellClasses" :label-classes="detailLabelClasses" />
163
+ </dl>
164
+ </div>
165
+
166
+ <!-- Tags -->
167
+ <div v-if="props.tags.length > 0" :class="['mt-4 flex flex-wrap items-center gap-2', enter('animate-uv-fade-up [animation-delay:600ms]')]">
168
+ <NuxtLinkLocale v-for="tag in props.tags" :key="tag.slug" :to="tagPath(tag.slug)" :class="badgeLinkClasses">
169
+ <Badge :text="`#${tag.name}`" />
170
+ </NuxtLinkLocale>
171
+ </div>
172
+
173
+ <!-- Featured image (replaceable through the media slot) -->
174
+ <slot name="media">
175
+ <div v-if="props.image" :class="['relative mt-8 aspect-video w-full overflow-hidden rounded border border-border shadow-elevated-xl', enter('animate-uv-fade-up [animation-delay:650ms]')]">
176
+ <NuxtImg :src="props.image" :alt="props.title" format="avif" quality="50" :sizes="{ 480: '480px', 1536: '896px' }" loading="eager" fetchpriority="high" preload :class="['size-full object-cover', enter('animate-uv-ken-burns')]" />
177
+ <div aria-hidden="true" class="pointer-events-none absolute inset-0 bg-linear-to-t from-black/20 to-transparent" />
178
+ </div>
179
+ </slot>
180
+ </header>
181
+ </template>
@@ -43,13 +43,14 @@ 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('h-0.5 w-full bg-border/60 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
+ <!-- Solid fill -->
53
+ <div class="h-full bg-primary transition-[width] duration-150 ease-out" :style="{ width: `${progress}%` }" />
53
54
  </div>
54
55
  </template>
55
56
 
@@ -1,12 +1,33 @@
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
- </template>
12
+ <div data-aos="blur-up" class="relative mb-6 flex flex-col gap-3 border-b border-border pb-4 sm:flex-row sm:items-end sm:justify-between md:mb-8">
13
+ <!-- Primary segment at the start of the rule -->
14
+ <span aria-hidden="true" class="absolute -bottom-px left-0 h-0.5 w-12 bg-primary" />
15
+
16
+ <div class="min-w-0">
17
+ <!-- Title -->
18
+ <h2 class="text-xl font-semibold tracking-tight text-foreground md:text-2xl">
19
+ {{ props.title }}
20
+ </h2>
21
+
22
+ <!-- Description -->
23
+ <p v-if="props.description" class="mt-1 max-w-2xl text-xs leading-6 text-muted-foreground md:text-sm">
24
+ {{ props.description }}
25
+ </p>
26
+ </div>
27
+
28
+ <!-- Optional trailing action (for example a "View all" link) -->
29
+ <div v-if="$slots.action" class="shrink-0">
30
+ <slot name="action" />
31
+ </div>
32
+ </div>
33
+ </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<{
@@ -8,15 +6,29 @@ const props = withDefaults(defineProps<{
8
6
  slug: string;
9
7
  count: number;
10
8
  }>;
9
+ blogPath?: string;
11
10
  }>(), {
12
- tags: () => []
11
+ tags: () => [],
12
+ blogPath: '/blog'
13
13
  });
14
14
  </script>
15
15
 
16
16
  <template>
17
- <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>
17
+ <div v-if="props.tags.length" class="flex flex-wrap gap-2">
18
+ <!-- Tag chip -->
19
+ <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">
20
+ <NuxtLinkLocale :to="`${props.blogPath}/tags/${tag.slug}`" class="group/tag inline-flex items-stretch overflow-hidden rounded border border-border text-xs shadow-elevated-sm outline-none transition-colors duration-150 hover:border-primary/40 focus-visible:ring-[3px] focus-visible:ring-ring/45 sm:text-sm">
21
+ <!-- Name -->
22
+ <span class="inline-flex items-center gap-1.5 bg-card px-3 py-1.5 font-medium text-foreground transition-colors duration-150 group-hover/tag:bg-primary/10 group-hover/tag:text-primary">
23
+ <span aria-hidden="true" class="text-primary">#</span>
24
+ {{ tag.name }}
25
+ </span>
26
+
27
+ <!-- Post count -->
28
+ <span class="inline-flex items-center border-l border-border bg-surface px-2 text-[11px] font-semibold text-muted-foreground tabular-nums transition-colors duration-150 group-hover/tag:border-primary/40 group-hover/tag:bg-primary/10 group-hover/tag:text-primary">
29
+ {{ tag.count }}
30
+ </span>
31
+ </NuxtLinkLocale>
32
+ </span>
21
33
  </div>
22
- </template>
34
+ </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>
@@ -1,7 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import { NuxtImg } from '#components';
3
- import { Badge } from '../ui/badge';
4
- import { Card, CardContent } from '../ui/card';
3
+ import { ArrowRight01Icon, Folder01Icon } from '@hugeicons/core-free-icons';
4
+ import { HugeiconsIcon } from '@hugeicons/vue';
5
+ import { Card } from '../ui/card';
5
6
 
6
7
  // Props
7
8
  const props = withDefaults(defineProps<{
@@ -14,23 +15,26 @@ const props = withDefaults(defineProps<{
14
15
  </script>
15
16
 
16
17
  <template>
17
- <Card class="p-0! overflow-hidden group cursor-pointer transition-transform duration-300 ease-out hover:-translate-y-1 motion-reduce:transition-none motion-reduce:hover:translate-y-0">
18
- <CardContent class="gap-0 p-0!">
19
- <!-- Background Image -->
20
- <div v-if="props.image" class="relative w-full h-36 overflow-hidden">
21
- <NuxtImg :src="props.image" :alt="props.name" height="225" width="400" format="avif" quality="35" :sizes="{ 480: '480px', 1280: '400px' }" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
22
- </div>
23
-
24
- <!-- Content -->
25
- <div class="p-4 flex items-center justify-between">
26
- <!-- Name -->
27
- <span class="text-sm font-bold text-foreground truncate mr-3">
28
- {{ props.name }}
29
- </span>
18
+ <Card class="group/category h-full gap-0! overflow-hidden p-0! transition-[translate,box-shadow] duration-300 ease-out-expo hover:-translate-y-0.5 hover:shadow-elevated-md">
19
+ <!-- Image -->
20
+ <div class="h-32 w-full overflow-hidden border-b border-border bg-surface">
21
+ <NuxtImg v-if="props.image" :src="props.image" :alt="props.name" height="225" width="400" format="avif" quality="35" :sizes="{ 480: '480px', 1280: '400px' }" loading="lazy" decoding="async" class="size-full object-cover transition-[scale] duration-700 ease-out-expo group-hover/category:scale-[1.03]" />
30
22
 
31
- <!-- Count -->
32
- <Badge color="gray" :text="String(props.count)" />
23
+ <!-- Fallback when there is no image -->
24
+ <div v-else aria-hidden="true" class="flex size-full items-center justify-center text-primary/60">
25
+ <HugeiconsIcon :icon="Folder01Icon" class="size-8" />
33
26
  </div>
34
- </CardContent>
27
+ </div>
28
+
29
+ <!-- Name, post count and arrow -->
30
+ <div class="flex grow items-center gap-3 bg-surface px-4 py-3 transition-colors duration-200 group-hover/category:bg-primary/10">
31
+ <span class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground transition-colors duration-200 group-hover/category:text-primary">
32
+ {{ props.name }}
33
+ </span>
34
+ <span class="shrink-0 rounded border border-primary/30 px-1.5 text-[11px] font-semibold leading-5 text-primary tabular-nums">
35
+ {{ props.count }}
36
+ </span>
37
+ <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 shrink-0 text-muted-foreground transition-[translate,color] duration-300 ease-spring group-hover/category:translate-x-1 group-hover/category:text-primary" />
38
+ </div>
35
39
  </Card>
36
- </template>
40
+ </template>