@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
@@ -1,12 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { NuxtImg } from '#components';
3
- import { ref, computed, onMounted, onUnmounted } from 'vue';
3
+ import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue';
4
4
  import { useI18n } from 'vue-i18n';
5
5
  import { ArrowLeft01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons';
6
6
  import { HugeiconsIcon } from '@hugeicons/vue';
7
7
  import { Badge } from '../ui/badge';
8
8
  import { Button } from '../ui/button';
9
- import { Card, CardContent } from '../ui/card';
9
+ import { cn } from '../../lib/utils';
10
10
 
11
11
  const { t } = useI18n();
12
12
 
@@ -24,180 +24,152 @@ const props = withDefaults(defineProps<{
24
24
  featuredPosts: () => []
25
25
  });
26
26
 
27
+ const AUTOPLAY_DURATION_MS = 6000;
28
+ const SWIPE_THRESHOLD_PX = 50;
29
+
27
30
  // Carousel state
28
31
  const currentSlide = ref(0);
32
+ const isPaused = ref(false);
33
+ const motionAllowed = ref(false);
29
34
  const slideCount = computed(() => props.featuredPosts.length || 0);
30
- const intervalId = ref<ReturnType<typeof setInterval> | null>(null);
31
- const direction = ref<'forward' | 'backward'>('forward');
32
-
33
- // Start auto-play functionality
34
- const startAutoPlay = () => {
35
- intervalId.value = setInterval(nextSlide, 5000);
36
- };
35
+ const autoplayEnabled = computed(() => motionAllowed.value && slideCount.value > 1);
36
+ const slideIdPrefix = `carousel-${useId()}-slide`;
37
37
 
38
- // Stop auto-play functionality
39
- const stopAutoPlay = () => {
40
- if (intervalId.value) clearInterval(intervalId.value);
41
- };
38
+ // Keep the active slide in range when the list of posts shrinks
39
+ watch(slideCount, (count) => {
40
+ if (currentSlide.value >= count) {
41
+ currentSlide.value = Math.max(0, count - 1);
42
+ }
43
+ });
44
+ let swipeStartX: number | null = null;
42
45
 
43
46
  // Handle next slide press
44
47
  const nextSlide = () => {
45
- direction.value = 'forward';
46
48
  currentSlide.value = (currentSlide.value + 1) % slideCount.value;
47
49
  };
48
50
 
49
51
  // Handle previous slide press
50
52
  const prevSlide = () => {
51
- direction.value = 'backward';
52
53
  currentSlide.value = (currentSlide.value - 1 + slideCount.value) % slideCount.value;
53
54
  };
54
55
 
55
56
  // Go to specific slide
56
57
  const goToSlide = (index: number) => {
57
- // Determine direction
58
- if (index > currentSlide.value) {
59
- direction.value = 'forward';
60
- } else {
61
- direction.value = 'backward';
62
- }
63
-
64
- // Set current slide
65
58
  currentSlide.value = index;
66
59
  };
67
60
 
68
- // Start autoplay when component mounts
61
+ // Pause autoplay while the user hovers, focuses, or leaves the tab
62
+ const pause = () => {
63
+ isPaused.value = true;
64
+ };
65
+ const resume = () => {
66
+ isPaused.value = false;
67
+ };
68
+ const handleVisibilityChange = () => {
69
+ isPaused.value = document.visibilityState !== 'visible';
70
+ };
71
+
72
+ // Keyboard navigation
73
+ const handleKeydown = (event: KeyboardEvent) => {
74
+ if (event.key === 'ArrowRight') nextSlide();
75
+ if (event.key === 'ArrowLeft') prevSlide();
76
+ };
77
+
78
+ // Swipe navigation on touch devices
79
+ const handlePointerDown = (event: PointerEvent) => {
80
+ if (event.pointerType !== 'mouse') swipeStartX = event.clientX;
81
+ };
82
+ const handlePointerUp = (event: PointerEvent) => {
83
+ if (swipeStartX === null) return;
84
+ const deltaX = event.clientX - swipeStartX;
85
+ swipeStartX = null;
86
+ if (Math.abs(deltaX) < SWIPE_THRESHOLD_PX) return;
87
+ if (deltaX < 0) nextSlide();
88
+ else prevSlide();
89
+ };
90
+
91
+ // Autoplay is driven by the progress animation of the active indicator, so the bar and the slide change always stay in sync
92
+ const handleProgressEnd = () => {
93
+ if (autoplayEnabled.value) nextSlide();
94
+ };
95
+
96
+ // Allow autoplay once mounted (never with reduced motion); it starts as soon as there are at least two posts
69
97
  onMounted(() => {
70
- startAutoPlay();
98
+ motionAllowed.value = !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
99
+ document.addEventListener('visibilitychange', handleVisibilityChange);
71
100
  });
72
101
 
73
- // Clean up interval when component unmounts
102
+ // Clean up listeners when component unmounts
74
103
  onUnmounted(() => {
75
- stopAutoPlay();
104
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
76
105
  });
77
106
  </script>
78
107
 
79
108
  <template>
80
- <div v-if="props.featuredPosts.length > 0" class="relative" @mouseenter="stopAutoPlay" @mouseleave="startAutoPlay">
81
- <!-- Slides -->
82
- <div class="relative overflow-hidden rounded">
83
- <TransitionGroup name="slide">
84
- <div v-for="(post, index) in props.featuredPosts" :id="`carousel-slide-${index}`" :key="post.path || post.title || index" v-show="currentSlide === index" class="w-full" :data-direction="direction">
85
- <Card class="p-0! overflow-hidden cursor-pointer">
86
- <CardContent class="gap-0 p-0!">
87
- <!-- Slide image -->
109
+ <div v-if="props.featuredPosts.length > 0" role="region" aria-roledescription="carousel" :aria-label="t('uiVintage.blog.carouselNavigation')" class="group/carousel relative isolate overflow-hidden rounded border border-border bg-card shadow-elevated-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45" tabindex="0" @mouseenter="pause" @mouseleave="resume" @focusin="pause" @focusout="resume" @keydown="handleKeydown" @pointerdown="handlePointerDown" @pointerup="handlePointerUp">
110
+ <!-- Slides (stacked; the active one crossfades in) -->
111
+ <div class="relative h-[28rem] w-full md:h-[32rem]">
112
+ <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')">
113
+ <!-- Slide image with a slow zoom -->
114
+ <NuxtLink :to="post.path" tabindex="-1" aria-hidden="true" class="absolute inset-0 overflow-hidden">
115
+ <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')" />
116
+ <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" />
117
+ </NuxtLink>
118
+
119
+ <!-- Legibility shade -->
120
+ <div aria-hidden="true" class="pointer-events-none absolute inset-0 bg-linear-to-t from-black/90 via-black/45 to-black/5" />
121
+ <div aria-hidden="true" class="pointer-events-none absolute inset-0 bg-linear-to-r from-black/50 via-transparent to-transparent" />
122
+
123
+ <!-- Slide content (replays its entrance every time the slide becomes active) -->
124
+ <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">
125
+ <!-- Category badge -->
126
+ <NuxtLink v-if="post.categoryText && post.categoryPath" :to="post.categoryPath" :class="cn('w-fit', currentSlide === index && 'animate-uv-fade-up')">
127
+ <Badge :text="post.categoryText" pulse class="border-white/25 bg-white/15 text-white backdrop-blur-md transition-colors hover:bg-white/25" />
128
+ </NuxtLink>
129
+
130
+ <!-- Title -->
131
+ <NuxtLink :to="post.path" :class="cn('outline-none', currentSlide === index && 'animate-uv-fade-up [animation-delay:90ms]')">
132
+ <h3 class="line-clamp-3 text-2xl leading-tight font-bold tracking-tight text-balance drop-shadow-sm md:text-5xl">
133
+ {{ post.title }}
134
+ </h3>
135
+ </NuxtLink>
136
+
137
+ <!-- Description -->
138
+ <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]')">
139
+ {{ post.description }}
140
+ </p>
141
+
142
+ <!-- Read post button -->
143
+ <div :class="cn(currentSlide === index && 'animate-uv-fade-up [animation-delay:260ms]')">
144
+ <Button as-child size="lg" class="group/cta">
88
145
  <NuxtLink :to="post.path">
89
- <div class="relative w-full h-56 md:h-80 overflow-hidden">
90
- <NuxtImg :src="post.image" :alt="post.title" width="560" height="315" format="avif" quality="35" :sizes="{ 480: '480px', 1536: '1086px' }" :loading="index === 0 ? 'eager' : 'lazy'" :fetchpriority="index === 0 ? 'high' : 'auto'" :preload="index === 0" class="w-full h-full object-cover transition-transform duration-500 hover:scale-105" />
91
- </div>
146
+ {{ t('uiVintage.blog.goToPost') }}
147
+ <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/cta:translate-x-1" />
92
148
  </NuxtLink>
93
-
94
- <!-- Slide content -->
95
- <div class="p-6 md:p-8">
96
- <!-- Category badge -->
97
- <NuxtLink v-if="post.categoryText && post.categoryPath" :to="post.categoryPath" class="inline-block mb-3">
98
- <Badge color="gray" :text="post.categoryText" />
99
- </NuxtLink>
100
-
101
- <!-- Title -->
102
- <NuxtLink :to="post.path">
103
- <h3 class="text-base md:text-3xl font-bold text-foreground mb-3 line-clamp-2 hover:opacity-75 transition-opacity duration-200">
104
- {{ post.title }}
105
- </h3>
106
- </NuxtLink>
107
-
108
- <!-- Description -->
109
- <p class="text-xs md:text-base! text-muted-foreground mb-6 line-clamp-2 leading-relaxed">
110
- {{ post.description }}
111
- </p>
112
-
113
- <!-- Button + indicators row -->
114
- <div class="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
115
- <!-- Read post button -->
116
- <NuxtLink :to="post.path">
117
- <Button variant="primary" class="w-full sm:w-auto">
118
- <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4" />
119
- {{ t('uiVintage.blog.goToPost') }}
120
- </Button>
121
- </NuxtLink>
122
-
123
- <!-- Slide indicators -->
124
- <div v-if="props.featuredPosts.length > 1" class="flex w-full justify-center gap-3 sm:w-auto" role="tablist" :aria-label="t('uiVintage.blog.carouselNavigation')">
125
- <button v-for="(_, idx) in props.featuredPosts"
126
- :key="idx"
127
- @click="goToSlide(idx)"
128
- :class="['h-5 w-5 sm:h-4! sm:w-4! shrink-0 rounded-[2px] transition-all duration-300 cursor-pointer bg-(--border-light) dark:bg-(--border-dark)', currentSlide === idx ? 'w-8! sm:w-7! bg-(--text-primary-light)! dark:bg-(--text-primary-dark)!' : '']"
129
- :aria-label="t('uiVintage.blog.goToSlide', { index: idx + 1 })"
130
- :aria-current="currentSlide === idx"
131
- role="tab"
132
- :aria-controls="`carousel-slide-${idx}`">
133
- </button>
134
- </div>
135
- </div>
136
- </div>
137
- </CardContent>
138
- </Card>
149
+ </Button>
150
+ </div>
139
151
  </div>
140
- </TransitionGroup>
152
+ </div>
141
153
  </div>
142
154
 
143
- <!-- Previous slide -->
144
- <button v-if="props.featuredPosts.length > 1"
145
- @click="prevSlide"
146
- :aria-label="t('uiVintage.blog.previousSlide')"
147
- class="absolute left-2 top-[calc(50%-4rem)] -translate-y-1/2 z-10 border border-border bg-card px-3 py-3 rounded-[3px] transition-colors duration-200 hover:border-border-strong cursor-pointer">
148
- <HugeiconsIcon :icon="ArrowLeft01Icon" class="size-4 text-muted-foreground" />
149
- </button>
150
-
151
- <!-- Next slide -->
152
- <button v-if="props.featuredPosts.length > 1"
153
- @click="nextSlide"
154
- :aria-label="t('uiVintage.blog.nextSlide')"
155
- class="absolute right-2 top-[calc(50%-4rem)] -translate-y-1/2 z-10 border border-border bg-card px-3 py-3 rounded-[3px] transition-colors duration-200 hover:border-border-strong cursor-pointer">
156
- <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 text-muted-foreground" />
157
- </button>
155
+ <!-- Controls bar -->
156
+ <div v-if="slideCount > 1" class="absolute inset-x-0 bottom-0 z-20 flex items-center justify-between gap-4 p-6 md:px-10 md:pb-8">
157
+ <!-- Slide indicators (the active one fills up until the next slide) -->
158
+ <div class="flex items-center gap-2" role="tablist" :aria-label="t('uiVintage.blog.carouselNavigation')">
159
+ <button v-for="(_, index) in props.featuredPosts" :key="index" type="button" role="tab" :aria-selected="currentSlide === index" :aria-controls="`${slideIdPrefix}-${index}`" :aria-label="t('uiVintage.blog.goToSlide', { index: index + 1 })" :class="cn('relative h-1.5 cursor-pointer overflow-hidden rounded-full bg-white/30 outline-none transition-[width,background-color] duration-500 ease-spring hover:bg-white/50 focus-visible:ring-2 focus-visible:ring-white', currentSlide === index ? 'w-12' : 'w-4')" @click="goToSlide(index)">
160
+ <span v-if="currentSlide === index" :key="`progress-${currentSlide}`" class="absolute inset-0 origin-left rounded-full bg-primary" :style="autoplayEnabled ? { animation: `uv-grow-x ${AUTOPLAY_DURATION_MS}ms linear both`, animationPlayState: isPaused ? 'paused' : 'running' } : undefined" @animationend="handleProgressEnd" />
161
+ </button>
162
+ </div>
163
+
164
+ <!-- Previous and next buttons -->
165
+ <div class="flex items-center gap-2">
166
+ <button type="button" :aria-label="t('uiVintage.blog.previousSlide')" class="flex size-10 cursor-pointer items-center justify-center rounded border border-white/25 bg-black/30 text-white backdrop-blur-md outline-none [transition:background-color_150ms,scale_300ms_var(--ease-spring)] hover:bg-white/20 active:scale-90 focus-visible:ring-2 focus-visible:ring-white" @click="prevSlide">
167
+ <HugeiconsIcon :icon="ArrowLeft01Icon" class="size-4" />
168
+ </button>
169
+ <button type="button" :aria-label="t('uiVintage.blog.nextSlide')" class="flex size-10 cursor-pointer items-center justify-center rounded border border-white/25 bg-black/30 text-white backdrop-blur-md outline-none [transition:background-color_150ms,scale_300ms_var(--ease-spring)] hover:bg-white/20 active:scale-90 focus-visible:ring-2 focus-visible:ring-white" @click="nextSlide">
170
+ <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4" />
171
+ </button>
172
+ </div>
173
+ </div>
158
174
  </div>
159
- </template>
160
-
161
- <style scoped>
162
- .slide-enter-active,
163
- .slide-leave-active {
164
- transition: transform 0.75s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.75s cubic-bezier(0.22, 1, 0.36, 1), filter 0.75s cubic-bezier(0.22, 1, 0.36, 1);
165
- will-change: transform, opacity, filter;
166
- }
167
-
168
- .slide-enter-from[data-direction="forward"] {
169
- opacity: 0;
170
- transform: translateX(12%) scale(0.985);
171
- filter: blur(4px);
172
- }
173
-
174
- .slide-leave-to[data-direction="forward"] {
175
- opacity: 0;
176
- transform: translateX(-12%) scale(0.985);
177
- filter: blur(4px);
178
- }
179
-
180
- .slide-enter-from[data-direction="backward"] {
181
- opacity: 0;
182
- transform: translateX(-12%) scale(0.985);
183
- filter: blur(4px);
184
- }
185
-
186
- .slide-leave-to[data-direction="backward"] {
187
- opacity: 0;
188
- transform: translateX(12%) scale(0.985);
189
- filter: blur(4px);
190
- }
191
-
192
- .slide-leave-active {
193
- position: absolute;
194
- width: 100%;
195
- }
196
-
197
- .slide-enter-to,
198
- .slide-leave-from {
199
- opacity: 1;
200
- transform: translateX(0) scale(1);
201
- filter: blur(0);
202
- }
203
- </style>
175
+ </template>
@@ -3,9 +3,7 @@ import { NuxtImg } from '#components';
3
3
  import { useI18n } from 'vue-i18n';
4
4
  import { ArrowRight01Icon } from '@hugeicons/core-free-icons';
5
5
  import { HugeiconsIcon } from '@hugeicons/vue';
6
- import { Badge } from '../ui/badge';
7
- import { Button } from '../ui/button';
8
- import { Card, CardContent } from '../ui/card';
6
+ import { Card } from '../ui/card';
9
7
 
10
8
  const { t } = useI18n();
11
9
 
@@ -24,38 +22,32 @@ const props = withDefaults(defineProps<{
24
22
  </script>
25
23
 
26
24
  <template>
27
- <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">
28
- <CardContent class="h-full gap-0 p-0!">
29
- <!-- Featured image -->
30
- <div v-if="props.image" class="relative w-full h-48 overflow-hidden">
31
- <NuxtImg :src="props.image" :alt="props.title" 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" />
32
- </div>
25
+ <Card class="group/post 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">
26
+ <!-- Featured image -->
27
+ <div v-if="props.image" class="aspect-video w-full overflow-hidden border-b border-border bg-surface">
28
+ <NuxtImg :src="props.image" :alt="props.title" 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/post:scale-[1.03]" />
29
+ </div>
33
30
 
34
- <!-- Content -->
35
- <div class="p-6 flex flex-col grow">
36
- <!-- Category Badge -->
37
- <div v-if="props.category" class="mb-3">
38
- <Badge color="gray" :text="props.category" />
39
- </div>
31
+ <!-- Content -->
32
+ <div class="flex grow flex-col gap-2 px-5 py-4">
33
+ <!-- Category -->
34
+ <span v-if="props.category" class="text-[11px] font-semibold uppercase tracking-wider text-primary">{{ props.category }}</span>
40
35
 
41
- <!-- Post Title -->
42
- <h2 class="text-base font-bold text-foreground mb-2 line-clamp-2">
43
- {{ props.title }}
44
- </h2>
36
+ <!-- Post title -->
37
+ <h2 class="line-clamp-2 text-sm leading-snug font-semibold text-foreground transition-colors duration-200 group-hover/post:text-primary md:text-base">
38
+ {{ props.title }}
39
+ </h2>
45
40
 
46
- <!-- Post Description -->
47
- <p class="text-xs md:text-sm text-muted-foreground leading-relaxed mb-4 line-clamp-3 grow">
48
- {{ props.description }}
49
- </p>
41
+ <!-- Post description -->
42
+ <p class="line-clamp-3 grow text-xs leading-relaxed text-muted-foreground md:text-sm">
43
+ {{ props.description }}
44
+ </p>
45
+ </div>
50
46
 
51
- <!-- Read More Button -->
52
- <div class="mt-auto">
53
- <Button variant="primary" class="w-full sm:w-auto">
54
- <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4" />
55
- {{ t('uiVintage.blog.readMore') }}
56
- </Button>
57
- </div>
58
- </div>
59
- </CardContent>
47
+ <!-- Read more -->
48
+ <div class="flex items-center justify-between border-t border-border bg-surface px-5 py-2.5 text-xs text-muted-foreground transition-colors duration-200 group-hover/post:bg-primary/10 group-hover/post:text-primary">
49
+ <span class="font-medium">{{ t('uiVintage.blog.readMore') }}</span>
50
+ <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/post:translate-x-1" />
51
+ </div>
60
52
  </Card>
61
- </template>
53
+ </template>
@@ -17,8 +17,9 @@ const props = withDefaults(defineProps<{
17
17
  </script>
18
18
 
19
19
  <template>
20
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
21
- <NuxtLink v-for="(post, index) in props.posts" :key="post.path" :to="post.path" class="block h-full" data-aos="fade-up" :data-aos-delay="(index % 3) * 200">
20
+ <div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
21
+ <!-- Post cards rise in row by row as they scroll into view -->
22
+ <NuxtLink v-for="(post, index) in props.posts" :key="post.path" data-aos="blur-up" :data-aos-delay="(index % 3) * 90" :to="post.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
22
23
  <PostCard class="h-full" :image="post.image" :category="post.categoryText" :title="post.title" :description="post.description" />
23
24
  </NuxtLink>
24
25
  </div>
@@ -1,9 +1,8 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue';
2
+ import { computed, ref } from 'vue';
3
3
  import { useI18n } from 'vue-i18n';
4
- import { Facebook01Icon, Linkedin01Icon, NewTwitterIcon, Share08Icon, TelegramIcon, WhatsappIcon } from '@hugeicons/core-free-icons';
4
+ import { Facebook01Icon, Link01Icon, Linkedin01Icon, NewTwitterIcon, Share08Icon, TelegramIcon, Tick02Icon, WhatsappIcon } from '@hugeicons/core-free-icons';
5
5
  import { HugeiconsIcon } from '@hugeicons/vue';
6
- import { Card, CardContent } from '../ui/card';
7
6
 
8
7
  // Props
9
8
  const props = defineProps<{
@@ -12,8 +11,9 @@ const props = defineProps<{
12
11
  }>();
13
12
 
14
13
  const { t } = useI18n();
14
+ const copied = ref(false);
15
15
 
16
- // Social platforms config
16
+ // Social platforms config (each icon turns its brand color on hover)
17
17
  const socialPlatforms = computed(() => {
18
18
  const encodedUrl = encodeURIComponent(props.url);
19
19
  const encodedTitle = encodeURIComponent(props.title);
@@ -22,75 +22,95 @@ const socialPlatforms = computed(() => {
22
22
  name: 'facebook',
23
23
  url: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
24
24
  icon: Facebook01Icon,
25
- color: 'bg-blue-700 hover:bg-blue-800',
25
+ color: 'hover:text-[#1877F2]',
26
26
  label: t('uiVintage.blog.share.facebook')
27
27
  }, {
28
28
  name: 'twitter',
29
29
  url: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`,
30
30
  icon: NewTwitterIcon,
31
- color: 'bg-black hover:bg-gray-800',
31
+ color: 'hover:text-foreground',
32
32
  label: t('uiVintage.blog.share.x')
33
33
  }, {
34
34
  name: 'whatsapp',
35
35
  url: `https://api.whatsapp.com/send?text=${encodedTitle}%20${encodedUrl}`,
36
36
  icon: WhatsappIcon,
37
- color: 'bg-green-500 hover:bg-green-600',
37
+ color: 'hover:text-[#25D366]',
38
38
  label: t('uiVintage.blog.share.whatsapp')
39
39
  }, {
40
40
  name: 'linkedin',
41
41
  url: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
42
42
  icon: Linkedin01Icon,
43
- color: 'bg-blue-700 hover:bg-blue-800',
43
+ color: 'hover:text-[#0A66C2]',
44
44
  label: t('uiVintage.blog.share.linkedin')
45
45
  }, {
46
46
  name: 'telegram',
47
47
  url: `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`,
48
48
  icon: TelegramIcon,
49
- color: 'bg-blue-400 hover:bg-blue-500',
49
+ color: 'hover:text-[#229ED9]',
50
50
  label: t('uiVintage.blog.share.telegram')
51
51
  }];
52
52
  });
53
+
54
+ // Shared button classes
55
+ const buttonClasses = 'flex size-9 cursor-pointer items-center justify-center rounded text-muted-foreground outline-none [transition:background-color_150ms,color_150ms,scale_300ms_var(--ease-spring)] hover:bg-accent active:scale-95 focus-visible:ring-[3px] focus-visible:ring-ring/45';
56
+
57
+ // Copy the article link to the clipboard
58
+ const copyLink = async () => {
59
+ if (!navigator.clipboard) return;
60
+ await navigator.clipboard.writeText(props.url);
61
+ copied.value = true;
62
+ window.setTimeout(() => {
63
+ copied.value = false;
64
+ }, 1800);
65
+ };
53
66
  </script>
54
67
 
55
68
  <template>
56
69
  <Teleport to="body">
57
70
  <!-- Desktop sidebar on the right -->
58
- <div class="fixed right-[max(6.875rem,calc(50%-38.125rem))] top-35 z-20 hidden xl:block 2xl:right-[max(0rem,calc(50%-35.125rem))]">
59
- <Card class="p-5! flex flex-col space-y-4 items-center">
60
- <CardContent class="p-0! gap-4 items-center">
61
- <!-- Share icon -->
62
- <HugeiconsIcon :icon="Share08Icon" class="size-5 text-muted-foreground mb-2" />
71
+ <div class="fixed top-35 right-[max(6.875rem,calc(50%-38.125rem))] z-20 hidden animate-[uv-fade-up_0.7s_var(--ease-out-expo)_0.6s_both] xl:block 2xl:right-[max(0rem,calc(50%-35.125rem))]">
72
+ <div class="flex flex-col items-center gap-0.5 rounded border border-border bg-card p-1.5 shadow-elevated-sm">
73
+ <!-- Share icon -->
74
+ <span aria-hidden="true" class="flex size-9 items-center justify-center text-primary">
75
+ <HugeiconsIcon :icon="Share08Icon" class="size-4" />
76
+ </span>
77
+ <span aria-hidden="true" class="my-1 h-px w-6 bg-border" />
78
+
79
+ <!-- Social share links (desktop) -->
80
+ <a v-for="platform in socialPlatforms" :key="platform.name" :href="platform.url" target="_blank" rel="noopener noreferrer" :aria-label="platform.label" :title="platform.label" :class="[buttonClasses, platform.color]">
81
+ <HugeiconsIcon :icon="platform.icon" class="size-4" />
82
+ </a>
63
83
 
64
- <!-- Social share links (desktop) -->
65
- <template v-for="platform in socialPlatforms" :key="platform.name">
66
- <NuxtLink :to="platform.url"
67
- target="_blank"
68
- rel="noopener noreferrer"
69
- :aria-label="platform.label"
70
- :class="['w-10 h-10 rounded flex items-center justify-center text-white transition-all duration-200 hover:opacity-80 hover:scale-105', platform.color]">
71
- <HugeiconsIcon :icon="platform.icon" class="size-4" />
72
- </NuxtLink>
73
- </template>
74
- </CardContent>
75
- </Card>
84
+ <!-- Copy link -->
85
+ <span aria-hidden="true" class="my-1 h-px w-6 bg-border" />
86
+ <button type="button" :aria-label="copied ? t('uiVintage.blog.share.copied') : t('uiVintage.blog.share.copy')" :title="t('uiVintage.blog.share.copy')" :class="[buttonClasses, 'hover:text-primary', copied && 'text-primary']" @click="copyLink">
87
+ <Transition mode="out-in" enter-active-class="transition-[opacity,scale] duration-300 ease-bounce" enter-from-class="opacity-0 scale-50" leave-active-class="transition-opacity duration-100" leave-to-class="opacity-0">
88
+ <HugeiconsIcon :key="String(copied)" :icon="copied ? Tick02Icon : Link01Icon" class="size-4" />
89
+ </Transition>
90
+ </button>
91
+ </div>
76
92
  </div>
77
93
 
78
94
  <!-- Mobile view (floating bar at bottom) -->
79
- <div class="fixed bottom-4 left-1/2 z-20 -translate-x-1/2 xl:hidden">
80
- <Card class="p-3! w-fit">
81
- <CardContent class="flex-row! p-0! items-center justify-center gap-3">
82
- <!-- Social share links (mobile) -->
83
- <template v-for="platform in socialPlatforms" :key="platform.name">
84
- <NuxtLink :to="platform.url"
85
- target="_blank"
86
- rel="noopener noreferrer"
87
- :aria-label="platform.label"
88
- :class="['w-10 h-10 rounded flex items-center justify-center text-white transition-all duration-200 hover:opacity-80 hover:scale-105', platform.color]">
89
- <HugeiconsIcon :icon="platform.icon" class="size-4" />
90
- </NuxtLink>
91
- </template>
92
- </CardContent>
93
- </Card>
95
+ <div class="fixed bottom-4 left-1/2 z-20 -translate-x-1/2 animate-[uv-fade-up_0.7s_var(--ease-out-expo)_0.8s_both] xl:hidden">
96
+ <div class="flex items-center gap-0.5 rounded border border-border bg-card p-1.5 shadow-elevated-sm">
97
+ <!-- Share icon -->
98
+ <span aria-hidden="true" class="flex size-9 items-center justify-center text-primary">
99
+ <HugeiconsIcon :icon="Share08Icon" class="size-4" />
100
+ </span>
101
+ <span aria-hidden="true" class="mx-1 h-6 w-px bg-border" />
102
+
103
+ <!-- Social share links (mobile) -->
104
+ <a v-for="platform in socialPlatforms" :key="platform.name" :href="platform.url" target="_blank" rel="noopener noreferrer" :aria-label="platform.label" :class="[buttonClasses, platform.color]">
105
+ <HugeiconsIcon :icon="platform.icon" class="size-4" />
106
+ </a>
107
+
108
+ <!-- Copy link -->
109
+ <span aria-hidden="true" class="mx-1 h-6 w-px bg-border" />
110
+ <button type="button" :aria-label="copied ? t('uiVintage.blog.share.copied') : t('uiVintage.blog.share.copy')" :class="[buttonClasses, 'hover:text-primary', copied && 'text-primary']" @click="copyLink">
111
+ <HugeiconsIcon :icon="copied ? Tick02Icon : Link01Icon" class="size-4" />
112
+ </button>
113
+ </div>
94
114
  </div>
95
115
  </Teleport>
96
- </template>
116
+ </template>