@brumaombra/ui-vintage 0.8.0 → 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 (49) hide show
  1. package/COMPONENTS.md +46 -2
  2. package/README.md +1 -1
  3. package/package.json +3 -2
  4. package/src/components/blog/AllPostsSection.vue +1 -2
  5. package/src/components/blog/BlogContentRenderer.vue +144 -49
  6. package/src/components/blog/BlogFAQSection.vue +13 -15
  7. package/src/components/blog/BlogHeaderSection.vue +27 -9
  8. package/src/components/blog/BlogInfoSection.vue +37 -36
  9. package/src/components/blog/BlogProgressBar.vue +5 -2
  10. package/src/components/blog/BlogSectionTitle.vue +24 -5
  11. package/src/components/blog/BlogTagsSection.vue +8 -5
  12. package/src/components/blog/CategoriesList.vue +2 -2
  13. package/src/components/blog/CategoryCard.vue +25 -16
  14. package/src/components/blog/HeaderCarousel.vue +109 -146
  15. package/src/components/blog/PostCard.vue +34 -35
  16. package/src/components/blog/PostsList.vue +3 -2
  17. package/src/components/blog/SocialShareSidebar.vue +51 -41
  18. package/src/components/blog/TableOfContents.vue +14 -10
  19. package/src/components/content/BlogList.vue +16 -4
  20. package/src/components/content/BlogTable.vue +4 -4
  21. package/src/components/content/CodeBlock.vue +5 -5
  22. package/src/components/content/Flow.vue +4 -4
  23. package/src/components/content/ProseHr.vue +5 -5
  24. package/src/components/content/Terminal.vue +10 -4
  25. package/src/components/cookie-consent/CookieConsent.vue +82 -0
  26. package/src/components/cookie-consent/index.ts +3 -0
  27. package/src/components/cookie-consent/useCookieConsent.ts +55 -0
  28. package/src/components/landing/landing-footer/LandingFooter.vue +9 -4
  29. package/src/components/landing/landing-navbar/LandingNavbar.vue +47 -51
  30. package/src/components/landing/landing-shell/LandingShell.vue +7 -2
  31. package/src/components/ui/accordion/Accordion.vue +6 -6
  32. package/src/components/ui/animated-number/AnimatedNumber.vue +19 -5
  33. package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +1 -1
  34. package/src/components/ui/breadcrumb/BreadcrumbLink.vue +1 -1
  35. package/src/components/ui/breadcrumb/BreadcrumbList.vue +1 -1
  36. package/src/components/ui/breadcrumb/BreadcrumbPage.vue +1 -1
  37. package/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +2 -6
  38. package/src/components/ui/card/Card.vue +1 -1
  39. package/src/components/ui/command/CommandItem.vue +2 -2
  40. package/src/i18n/de.json +10 -1
  41. package/src/i18n/en.json +10 -1
  42. package/src/i18n/es.json +10 -1
  43. package/src/i18n/fr.json +10 -1
  44. package/src/i18n/it.json +10 -1
  45. package/src/i18n/ja.json +10 -1
  46. package/src/i18n/pt.json +10 -1
  47. package/src/i18n/ru.json +10 -1
  48. package/src/i18n/zh.json +10 -1
  49. package/src/styles.css +40 -0
@@ -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 one lights up in 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,85 @@ 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:border-[#1877F2] hover:bg-[#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:border-foreground hover:bg-foreground hover:text-background!',
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:border-[#25D366] hover:bg-[#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:border-[#0A66C2] hover:bg-[#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:border-[#229ED9] hover:bg-[#229ED9]',
50
50
  label: t('uiVintage.blog.share.telegram')
51
51
  }];
52
52
  });
53
+
54
+ // Shared button classes
55
+ const buttonClasses = 'flex size-10 cursor-pointer items-center justify-center rounded border border-border bg-secondary text-muted-foreground shadow-elevated-sm outline-none [transition:background-color_150ms,border-color_150ms,color_150ms,scale_300ms_var(--ease-spring)] hover:scale-110 hover:text-white 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-3 rounded border border-border bg-card p-3 shadow-elevated-sm">
73
+ <!-- Share icon -->
74
+ <HugeiconsIcon :icon="Share08Icon" class="mb-1 size-5 text-primary" />
63
75
 
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>
76
+ <!-- Social share links (desktop) -->
77
+ <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]">
78
+ <HugeiconsIcon :icon="platform.icon" class="size-4" />
79
+ </a>
80
+
81
+ <!-- Copy link -->
82
+ <div class="my-1 h-px w-6 bg-border" />
83
+ <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:border-primary hover:bg-primary hover:text-primary-foreground!', copied && 'border-primary text-primary']" @click="copyLink">
84
+ <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">
85
+ <HugeiconsIcon :key="String(copied)" :icon="copied ? Tick02Icon : Link01Icon" class="size-4" />
86
+ </Transition>
87
+ </button>
88
+ </div>
76
89
  </div>
77
90
 
78
91
  <!-- 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>
92
+ <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">
93
+ <div class="flex items-center gap-2 rounded border border-border bg-card p-2 shadow-elevated-sm">
94
+ <!-- Social share links (mobile) -->
95
+ <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]">
96
+ <HugeiconsIcon :icon="platform.icon" class="size-4" />
97
+ </a>
98
+
99
+ <!-- Copy link -->
100
+ <button type="button" :aria-label="copied ? t('uiVintage.blog.share.copied') : t('uiVintage.blog.share.copy')" :class="[buttonClasses, 'hover:border-primary hover:bg-primary hover:text-primary-foreground!', copied && 'border-primary text-primary']" @click="copyLink">
101
+ <HugeiconsIcon :icon="copied ? Tick02Icon : Link01Icon" class="size-4" />
102
+ </button>
103
+ </div>
94
104
  </div>
95
105
  </Teleport>
96
106
  </template>
@@ -286,7 +286,7 @@ const openMobileToc = () => {
286
286
  // Get classes for heading button
287
287
  const getHeadingButtonClasses = (level: number, isActive: boolean) => {
288
288
  // Base classes for all titles
289
- const baseClasses = 'block w-full text-left px-2.5 py-2 md:px-3 md:py-2 rounded border-l-2 border-transparent transition-all duration-200 hover:bg-accent cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 motion-reduce:transition-none';
289
+ const baseClasses = 'relative block w-full cursor-pointer rounded px-2.5 py-2 text-left outline-none transition-[background-color,color,padding] duration-200 hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/45 md:px-3 before:absolute before:inset-y-1.5 before:left-0 before:w-[3px] before:origin-center before:scale-y-0 before:rounded-full before:bg-primary before:transition-transform before:duration-300 before:ease-spring';
290
290
 
291
291
  // Add classes based on heading level
292
292
  let levelClasses = '';
@@ -297,7 +297,7 @@ const getHeadingButtonClasses = (level: number, isActive: boolean) => {
297
297
  }
298
298
 
299
299
  // Add classes if the heading is active
300
- const activeClasses = isActive ? 'bg-accent border-l-primary text-foreground' : '';
300
+ const activeClasses = isActive ? 'bg-accent text-foreground! before:scale-y-100' : '';
301
301
 
302
302
  // Return combined classes
303
303
  return [baseClasses, levelClasses, activeClasses];
@@ -354,20 +354,20 @@ onUnmounted(() => {
354
354
  <Teleport to="body">
355
355
  <!-- Mobile persistent control -->
356
356
  <Transition name="toc-tab">
357
- <button v-if="!isDesktop && isDocked" ref="mobileTriggerRef" type="button" class="fixed top-4/5 left-0 z-20 flex h-16 w-8 -translate-y-1/2 items-center justify-center rounded-r border border-l-0 border-(--border-light) bg-(--bg-card-light) text-(--text-secondary-light) transition-[background-color,color,transform] duration-200 hover:bg-(--bg-selected-light) hover:text-(--text-primary-light) focus-visible:z-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 dark:border-(--border-dark) dark:bg-(--bg-card-dark) dark:text-(--text-secondary-dark) dark:hover:bg-(--bg-selected-dark) dark:hover:text-(--text-primary-dark) motion-reduce:transition-none" :aria-label="t('uiVintage.blog.openTableOfContents')" :aria-expanded="isMobileOpen" aria-haspopup="dialog" aria-controls="toc-mobile-overlay" @click="openMobileToc">
358
- <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4" aria-hidden="true" />
357
+ <button v-if="!isDesktop && isDocked" ref="mobileTriggerRef" type="button" class="group/toc-tab fixed top-4/5 left-0 z-20 flex h-16 w-7 -translate-y-1/2 items-center justify-center rounded-r border border-l-0 border-border bg-card text-primary shadow-elevated-sm transition-[width] duration-300 ease-out-expo hover:w-8 focus-visible:z-30 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45" :aria-label="t('uiVintage.blog.openTableOfContents')" :aria-expanded="isMobileOpen" aria-haspopup="dialog" aria-controls="toc-mobile-overlay" @click="openMobileToc">
358
+ <HugeiconsIcon :icon="ArrowRight01Icon" class="size-3.5 transition-[translate] duration-300 ease-spring group-hover/toc-tab:translate-x-0.5" aria-hidden="true" />
359
359
  </button>
360
360
  </Transition>
361
361
 
362
362
  <!-- Mobile full-screen overlay -->
363
363
  <Sheet :open="isMobileOpen" @update:open="handleMobileOpenChange">
364
- <SheetContent id="toc-mobile-overlay" side="left" class="flex h-full w-full max-w-none flex-col gap-0 border-0 bg-(--bg-card-light) p-0 dark:bg-(--bg-card-dark) sm:max-w-none">
365
- <SheetHeader class="flex-row items-center justify-between gap-4 border-b border-(--border-light) p-5 dark:border-(--border-dark)">
364
+ <SheetContent id="toc-mobile-overlay" side="left" class="flex h-full w-full max-w-none flex-col gap-0 border-0 bg-card p-0 data-[state=open]:[animation-duration:320ms] data-[state=open]:[animation-timing-function:var(--ease-out-expo)] sm:max-w-none">
365
+ <SheetHeader class="flex-row items-center justify-between gap-4 border-b border-border p-5">
366
366
  <!-- Mobile table of contents header -->
367
367
  <div class="flex min-w-0 items-center gap-3">
368
368
  <!-- Mobile table of contents icon -->
369
- <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded border border-(--border-light) bg-(--bg-card-light) dark:border-(--border-dark) dark:bg-(--bg-card-dark)">
370
- <HugeiconsIcon :icon="Bookmark01Icon" class="size-4 text-muted-foreground" />
369
+ <div class="flex size-10 shrink-0 items-center justify-center rounded border border-primary/30 bg-primary/10">
370
+ <HugeiconsIcon :icon="Bookmark01Icon" class="size-4 text-primary" />
371
371
  </div>
372
372
 
373
373
  <!-- Mobile table of contents title and description -->
@@ -404,14 +404,18 @@ onUnmounted(() => {
404
404
  </template>
405
405
 
406
406
  <style scoped>
407
- .toc-tab-enter-active,
407
+ .toc-tab-enter-active {
408
+ transition: opacity 0.3s var(--ease-out-expo), transform 0.32s var(--ease-out-expo);
409
+ }
410
+
408
411
  .toc-tab-leave-active {
409
- transition: opacity 0.25s ease;
412
+ transition: opacity 0.15s var(--ease-snappy), transform 0.15s var(--ease-snappy);
410
413
  }
411
414
 
412
415
  .toc-tab-enter-from,
413
416
  .toc-tab-leave-to {
414
417
  opacity: 0;
418
+ transform: translateX(-100%);
415
419
  }
416
420
 
417
421
  @media (prefers-reduced-motion: reduce) {
@@ -14,21 +14,33 @@ const props = withDefaults(defineProps<{
14
14
 
15
15
  // Icon container classes
16
16
  const getIconContainerClasses = () => {
17
- return 'flex items-center justify-center w-8 h-8 bg-card border border-border rounded mr-4 shrink-0';
17
+ const toneClasses = {
18
+ checkmark: 'border-green-200 bg-green-50 dark:border-green-900/50 dark:bg-green-950/30',
19
+ cross: 'border-red-200 bg-red-50 dark:border-red-900/50 dark:bg-red-950/30',
20
+ numbered: 'border-primary/30 bg-primary/10',
21
+ circle: 'border-primary/30 bg-primary/10'
22
+ };
23
+ return ['flex items-center justify-center size-8 border rounded mr-4 shrink-0', toneClasses[props.variant]];
18
24
  };
19
25
 
20
26
  // Icon color classes
21
27
  const getIconClasses = () => {
22
- return 'text-muted-foreground text-xs';
28
+ const toneClasses = {
29
+ checkmark: 'text-green-600 dark:text-green-400',
30
+ cross: 'text-red-600 dark:text-red-400',
31
+ numbered: 'text-primary',
32
+ circle: 'text-primary'
33
+ };
34
+ return ['text-xs size-4', toneClasses[props.variant]];
23
35
  };
24
36
  </script>
25
37
 
26
38
  <template>
27
39
  <div class="not-prose my-6 sm:my-8">
28
- <Card>
40
+ <Card data-aos="blur-up">
29
41
  <CardContent class="gap-0">
30
42
  <ul class="text-xs md:text-sm p-0! space-y-6!">
31
- <li v-for="(item, index) in props.items" :key="index" class="flex items-center">
43
+ <li v-for="(item, index) in props.items" :key="index" data-aos="blur-up" :data-aos-delay="100 + index * 80" class="flex items-center">
32
44
  <!-- Circle -->
33
45
  <div v-if="props.variant === 'circle'" :class="getIconContainerClasses()">
34
46
  <HugeiconsIcon :icon="CircleSmallIcon" :class="getIconClasses()" />
@@ -16,7 +16,7 @@ const props = withDefaults(defineProps<{
16
16
  // Header cell classes
17
17
  const getHeaderClasses = (colIndex: number) => {
18
18
  const baseClasses = 'text-left font-semibold text-foreground';
19
- const highlightClass = props.highlightCol === colIndex ? 'bg-accent' : '';
19
+ const highlightClass = props.highlightCol === colIndex ? 'bg-primary/10 text-primary' : '';
20
20
  return [baseClasses, highlightClass];
21
21
  };
22
22
 
@@ -24,14 +24,14 @@ const getHeaderClasses = (colIndex: number) => {
24
24
  const getCellClasses = (colIndex: number) => {
25
25
  const baseClasses = 'align-top text-foreground leading-relaxed whitespace-normal';
26
26
  const firstColClass = colIndex === 0 ? 'font-semibold text-muted-foreground' : '';
27
- const highlightClass = props.highlightCol === colIndex ? 'bg-accent' : '';
27
+ const highlightClass = props.highlightCol === colIndex ? 'bg-primary/5 font-semibold' : '';
28
28
  return [baseClasses, firstColClass, highlightClass];
29
29
  };
30
30
  </script>
31
31
 
32
32
  <template>
33
33
  <div class="not-prose my-6 sm:my-8">
34
- <Card>
34
+ <Card data-aos="blur-up">
35
35
  <CardContent>
36
36
  <div class="w-full overflow-x-auto">
37
37
  <Table class="w-full text-sm text-left">
@@ -46,7 +46,7 @@ const getCellClasses = (colIndex: number) => {
46
46
 
47
47
  <!-- Table body -->
48
48
  <TableBody>
49
- <TableRow v-for="(row, rowIndex) in props.rows" :key="rowIndex" class="text-muted-foreground hover:bg-transparent">
49
+ <TableRow v-for="(row, rowIndex) in props.rows" :key="rowIndex" class="text-muted-foreground transition-colors duration-150 hover:bg-surface/70">
50
50
  <TableCell v-for="(cell, colIndex) in row" :key="colIndex" :class="getCellClasses(colIndex)">
51
51
  {{ cell }}
52
52
  </TableCell>
@@ -76,11 +76,11 @@ const copyCode = async () => {
76
76
 
77
77
  <template>
78
78
  <div class="not-prose my-6 sm:my-8">
79
- <Card class="gap-0! sm:gap-0! overflow-hidden p-0! text-foreground">
79
+ <Card data-aos="blur-up" class="gap-0! sm:gap-0! overflow-hidden p-0! text-foreground">
80
80
  <!-- Code header -->
81
- <div class="flex items-center justify-between gap-3 border-b border-(--border-light) bg-(--bg-card-light) px-3 py-2 sm:px-4 dark:border-(--border-dark) dark:bg-(--bg-card-dark)">
81
+ <div class="flex items-center justify-between gap-3 border-b border-border bg-card px-3 py-2 sm:px-4">
82
82
  <div class="flex min-w-0 items-center gap-2">
83
- <HugeiconsIcon :icon="CodeIcon" class="size-4 shrink-0 text-muted-foreground" />
83
+ <HugeiconsIcon :icon="CodeIcon" class="size-4 shrink-0 text-primary" />
84
84
  <span class="truncate text-xs font-semibold text-muted-foreground">{{ resolvedTitle }}</span>
85
85
  </div>
86
86
 
@@ -88,7 +88,7 @@ const copyCode = async () => {
88
88
  <Button v-if="props.copyable"
89
89
  variant="ghost"
90
90
  size="icon"
91
- class="size-7 text-(--text-secondary-light) hover:bg-(--bg-selected-light) hover:text-(--text-primary-light) dark:text-(--text-secondary-dark) dark:hover:bg-(--bg-selected-dark) dark:hover:text-(--text-primary-dark)"
91
+ class="size-7 text-muted-foreground hover:bg-accent hover:text-foreground"
92
92
  :aria-label="t('uiVintage.code.copy')"
93
93
  :title="t('uiVintage.code.copy')"
94
94
  :disabled="!displayedCode"
@@ -99,7 +99,7 @@ const copyCode = async () => {
99
99
 
100
100
  <!-- Code content -->
101
101
  <div v-if="highlightedCode" class="code-block-content overflow-x-auto bg-surface" v-html="highlightedCode" />
102
- <pre v-else class="code-block-content overflow-x-auto bg-(--bg-surface-light) px-4 py-3 text-xs leading-6 dark:bg-(--bg-surface-dark) sm:px-5 sm:py-4 sm:text-sm"><code>{{ displayedCode }}</code></pre>
102
+ <pre v-else class="code-block-content overflow-x-auto bg-surface px-4 py-3 text-xs leading-6 sm:px-5 sm:py-4 sm:text-sm"><code>{{ displayedCode }}</code></pre>
103
103
  </Card>
104
104
  </div>
105
105
  </template>
@@ -42,7 +42,7 @@ const isVertical = () => {
42
42
 
43
43
  <template>
44
44
  <div class="not-prose my-6 sm:my-8">
45
- <Card>
45
+ <Card data-aos="blur-up">
46
46
  <!-- Card header -->
47
47
  <CardHeader v-if="props.title || props.description">
48
48
  <!-- Card title -->
@@ -62,12 +62,12 @@ const isVertical = () => {
62
62
  <!-- Flow items -->
63
63
  <template v-for="(item, index) in props.items" :key="`${getItemLabel(item)}-${index}`">
64
64
  <!-- Flow item -->
65
- <Card class="flex-1 overflow-hidden md:flex md:items-center">
65
+ <Card data-aos="blur-up" :data-aos-delay="100 + index * 90" class="flex-1 overflow-hidden transition-[border-color,translate] duration-300 ease-spring hover:-translate-y-0.5 hover:border-primary/40 md:flex md:items-center">
66
66
  <CardContent class="w-full gap-3 md:justify-center">
67
67
  <!-- Flow item header -->
68
68
  <div class="inline-flex items-center gap-3 text-xs font-semibold text-foreground">
69
69
  <!-- Flow item number -->
70
- <span class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded border border-(--border-light) bg-(--bg-card-light) text-xs font-semibold text-(--text-secondary-light) dark:border-(--border-dark) dark:bg-(--bg-card-dark) dark:text-(--text-secondary-dark)">
70
+ <span class="inline-flex size-8 shrink-0 items-center justify-center rounded border border-primary/30 bg-primary/10 text-xs font-bold text-primary">
71
71
  {{ index + 1 }}
72
72
  </span>
73
73
 
@@ -83,7 +83,7 @@ const isVertical = () => {
83
83
  </Card>
84
84
 
85
85
  <!-- Flow item separator -->
86
- <div v-if="index < props.items.length - 1" class="flex items-center justify-center text-muted-foreground">
86
+ <div v-if="index < props.items.length - 1" data-aos="blur-up" :data-aos-delay="140 + index * 90" class="flex items-center justify-center text-primary">
87
87
  <!-- Flow item separator for vertical orientation -->
88
88
  <HugeiconsIcon v-if="isVertical()" :icon="ArrowDown01Icon" class="size-5 shrink-0" />
89
89
 
@@ -12,18 +12,18 @@ const props = withDefaults(defineProps<{
12
12
  </script>
13
13
 
14
14
  <template>
15
- <div aria-hidden="true" class="not-prose my-6 sm:my-12">
15
+ <div aria-hidden="true" data-aos="blur-up" class="not-prose my-8 sm:my-14">
16
16
  <div class="flex items-center gap-3 sm:gap-4">
17
17
  <!-- Left line -->
18
- <div class="h-px flex-1 bg-linear-to-r from-transparent via-(--border-light) to-(--border-light) dark:via-(--border-dark) dark:to-(--border-dark)" />
18
+ <div class="h-px flex-1 bg-linear-to-r from-transparent via-border to-primary/50" />
19
19
 
20
20
  <!-- Icon in the middle -->
21
- <div class="flex h-9 w-9 items-center justify-center rounded bg-card border border-border">
22
- <HugeiconsIcon :icon="props.icon" class="size-4 text-muted-foreground" />
21
+ <div class="flex size-10 rotate-45 items-center justify-center rounded border border-primary/30 bg-card shadow-glow">
22
+ <HugeiconsIcon :icon="props.icon" class="size-4 -rotate-45 text-primary" />
23
23
  </div>
24
24
 
25
25
  <!-- Right line -->
26
- <div class="h-px flex-1 bg-linear-to-l from-transparent via-(--border-light) to-(--border-light) dark:via-(--border-dark) dark:to-(--border-dark)" />
26
+ <div class="h-px flex-1 bg-linear-to-l from-transparent via-border to-primary/50" />
27
27
  </div>
28
28
  </div>
29
29
  </template>
@@ -42,10 +42,16 @@ const copyCommands = async () => {
42
42
 
43
43
  <template>
44
44
  <div class="not-prose my-6 sm:my-8">
45
- <Card class="gap-0! sm:gap-0! overflow-hidden p-0! text-foreground">
45
+ <Card data-aos="blur-up" class="gap-0! sm:gap-0! overflow-hidden p-0! text-foreground">
46
46
  <!-- Terminal header -->
47
- <div class="flex items-center justify-between gap-3 border-b border-(--border-light) bg-(--bg-card-light) px-3 py-2 sm:px-4 dark:border-(--border-dark) dark:bg-(--bg-card-dark)">
47
+ <div class="flex items-center justify-between gap-3 border-b border-border bg-card px-3 py-2 sm:px-4">
48
48
  <div class="flex min-w-0 items-center gap-2">
49
+ <!-- Window dots -->
50
+ <span aria-hidden="true" class="mr-1 flex items-center gap-1.5">
51
+ <span class="size-2.5 rounded-full bg-red-400/80" />
52
+ <span class="size-2.5 rounded-full bg-amber-400/80" />
53
+ <span class="size-2.5 rounded-full bg-green-400/80" />
54
+ </span>
49
55
  <HugeiconsIcon :icon="TerminalIcon" class="size-4 shrink-0 text-muted-foreground" />
50
56
  <span class="truncate text-xs font-semibold text-muted-foreground">{{ resolvedTitle }}</span>
51
57
  </div>
@@ -54,7 +60,7 @@ const copyCommands = async () => {
54
60
  <Button v-if="props.copyable"
55
61
  variant="ghost"
56
62
  size="icon"
57
- class="size-7 text-(--text-secondary-light) hover:bg-(--bg-selected-light) hover:text-(--text-primary-light) dark:text-(--text-secondary-dark) dark:hover:bg-(--bg-selected-dark) dark:hover:text-(--text-primary-dark)"
63
+ class="size-7 text-muted-foreground hover:bg-accent hover:text-foreground"
58
64
  :aria-label="t('uiVintage.terminal.copy')"
59
65
  :title="t('uiVintage.terminal.copy')"
60
66
  :disabled="!commandText"
@@ -64,7 +70,7 @@ const copyCommands = async () => {
64
70
  </div>
65
71
 
66
72
  <!-- Terminal commands -->
67
- <pre class="overflow-x-auto bg-(--bg-surface-light) px-4 py-3 text-xs leading-6 dark:bg-(--bg-surface-dark) sm:px-5 sm:py-4 sm:text-sm"><code><span v-for="(command, index) in props.commands" :key="`${command}-${index}`" class="flex min-w-max"><span class="mr-3 select-none text-muted-foreground" aria-hidden="true">{{ props.prompt }}</span><span>{{ command }}</span></span></code></pre>
73
+ <pre class="overflow-x-auto bg-surface px-4 py-3 text-xs leading-6 sm:px-5 sm:py-4 sm:text-sm"><code><span v-for="(command, index) in props.commands" :key="`${command}-${index}`" class="flex min-w-max"><span class="mr-3 select-none text-primary" aria-hidden="true">{{ props.prompt }}</span><span>{{ command }}</span></span></code></pre>
68
74
  </Card>
69
75
  </div>
70
76
  </template>
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, type HTMLAttributes } from 'vue';
3
+ import { useI18n } from 'vue-i18n';
4
+ import { Button } from '../ui/button';
5
+ import { Card } from '../ui/card';
6
+ import { cn } from '../../lib/utils';
7
+ import { useCookieConsent } from './useCookieConsent';
8
+
9
+ // Extra attributes go on the banner (the root is the Teleport)
10
+ defineOptions({ inheritAttrs: false });
11
+
12
+ // Props
13
+ const props = withDefaults(defineProps<{
14
+ storageKey?: string;
15
+ message?: string;
16
+ policyTo?: string;
17
+ policyLabel?: string;
18
+ acceptLabel?: string;
19
+ declineLabel?: string;
20
+ class?: HTMLAttributes['class'];
21
+ }>(), {
22
+ storageKey: 'cookieConsent',
23
+ message: undefined,
24
+ policyTo: undefined,
25
+ policyLabel: undefined,
26
+ acceptLabel: undefined,
27
+ declineLabel: undefined,
28
+ class: undefined
29
+ });
30
+
31
+ // Emits
32
+ const emit = defineEmits<{
33
+ accept: [];
34
+ decline: [];
35
+ }>();
36
+
37
+ const { t } = useI18n();
38
+ const { consent, accept, decline } = useCookieConsent(props.storageKey);
39
+ const hasMounted = ref(false);
40
+
41
+ // Shown in the browser only, until the visitor chooses (the stored choice is not known on the server)
42
+ const isVisible = computed(() => hasMounted.value && consent.value === null);
43
+
44
+ // Texts, with translated defaults
45
+ const resolvedMessage = computed(() => props.message || t('uiVintage.cookieConsent.message'));
46
+ const resolvedPolicyLabel = computed(() => props.policyLabel || t('uiVintage.cookieConsent.policy'));
47
+ const resolvedAcceptLabel = computed(() => props.acceptLabel || t('uiVintage.cookieConsent.accept'));
48
+ const resolvedDeclineLabel = computed(() => props.declineLabel || t('uiVintage.cookieConsent.decline'));
49
+
50
+ // Save the choice and notify
51
+ const handleAccept = () => {
52
+ accept();
53
+ emit('accept');
54
+ };
55
+ const handleDecline = () => {
56
+ decline();
57
+ emit('decline');
58
+ };
59
+
60
+ // Wait for the browser before showing the banner
61
+ onMounted(() => {
62
+ hasMounted.value = true;
63
+ });
64
+ </script>
65
+
66
+ <template>
67
+ <!-- Rendered in the body: a transformed or filtered ancestor (page transitions, reveal animations) would otherwise pin the fixed banner to itself instead of the viewport -->
68
+ <Teleport to="body">
69
+ <div v-if="isVisible" v-bind="$attrs" data-slot="cookie-consent" role="region" :aria-label="t('uiVintage.cookieConsent.region')" :class="cn('fixed inset-x-4 bottom-4 z-50 sm:left-auto sm:right-4 sm:max-w-md', props.class)">
70
+ <Card class="gap-3! p-4 shadow-elevated-sm">
71
+ <p class="text-sm text-foreground">
72
+ {{ resolvedMessage }}
73
+ <NuxtLinkLocale v-if="props.policyTo" :to="props.policyTo" class="font-bold underline underline-offset-2">{{ resolvedPolicyLabel }}</NuxtLinkLocale>
74
+ </p>
75
+ <div class="flex flex-wrap justify-end gap-2">
76
+ <Button variant="secondary" size="sm" @click="handleDecline">{{ resolvedDeclineLabel }}</Button>
77
+ <Button variant="primary" size="sm" @click="handleAccept">{{ resolvedAcceptLabel }}</Button>
78
+ </div>
79
+ </Card>
80
+ </div>
81
+ </Teleport>
82
+ </template>
@@ -0,0 +1,3 @@
1
+ export { default } from './CookieConsent.vue';
2
+ export { default as CookieConsent } from './CookieConsent.vue';
3
+ export { useCookieConsent, type CookieConsentValue } from './useCookieConsent';
@@ -0,0 +1,55 @@
1
+ import { ref, type Ref } from 'vue';
2
+
3
+ // The visitor's choice: 'granted', 'denied' or null (not asked yet)
4
+ export type CookieConsentValue = 'granted' | 'denied' | null;
5
+
6
+ // One shared state per storage key, so the banner and the code loading the scripts stay in sync (browser only)
7
+ const consentStates = new Map<string, Ref<CookieConsentValue>>();
8
+
9
+ // Read the stored choice (storage can be unavailable, e.g. blocked in private windows)
10
+ const readStoredConsent = (storageKey: string): CookieConsentValue => {
11
+ try {
12
+ const value = localStorage.getItem(storageKey);
13
+ return value === 'granted' || value === 'denied' ? value : null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ };
18
+
19
+ // Cookie consent state: gate optional scripts (analytics, ads) on `consent.value === 'granted'` and call `reset()` to ask again
20
+ export const useCookieConsent = (storageKey = 'cookieConsent') => {
21
+ // On the server there is no stored choice (and no state must be shared between requests)
22
+ if (typeof window === 'undefined') {
23
+ const consent = ref<CookieConsentValue>(null);
24
+ const noop = () => {};
25
+ return { consent, accept: noop, decline: noop, reset: noop };
26
+ }
27
+
28
+ // Shared state for this key
29
+ let consent = consentStates.get(storageKey);
30
+ if (!consent) {
31
+ consent = ref(readStoredConsent(storageKey));
32
+ consentStates.set(storageKey, consent);
33
+ }
34
+
35
+ // Save a choice (null removes it, so the banner asks again)
36
+ const state = consent;
37
+ const setConsent = (value: CookieConsentValue) => {
38
+ state.value = value;
39
+ try {
40
+ if (value) {
41
+ localStorage.setItem(storageKey, value);
42
+ } else {
43
+ localStorage.removeItem(storageKey);
44
+ }
45
+ } catch { }
46
+ };
47
+
48
+ // Return the state and the actions
49
+ return {
50
+ consent: state,
51
+ accept: () => setConsent('granted'),
52
+ decline: () => setConsent('denied'),
53
+ reset: () => setConsent(null)
54
+ };
55
+ };
@@ -77,12 +77,15 @@ const getLinkProps = (link: LandingFooterLink) => {
77
77
 
78
78
  <template>
79
79
  <footer :class="cn('relative z-10 border-t border-border bg-card', props.class)">
80
+ <!-- Accent line -->
81
+ <div aria-hidden="true" class="absolute inset-x-0 -top-px h-px bg-linear-to-r from-transparent via-primary/60 to-transparent" />
82
+
80
83
  <div :class="cn('mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8', props.containerClass)">
81
84
  <div :class="cn('flex flex-col gap-8 md:flex-row md:items-start md:justify-between')">
82
85
  <!-- Brand section -->
83
86
  <div :class="cn('max-w-md flex-1', props.brandClass)">
84
87
  <!-- App logo and name -->
85
- <a :href="props.appLinkTo" class="mb-4 inline-flex items-center gap-2 text-lg font-semibold tracking-tight text-foreground transition-opacity hover:opacity-80">
88
+ <a :href="props.appLinkTo" class="mb-4 inline-flex items-center gap-2 text-lg font-semibold tracking-tight text-foreground transition-[opacity,scale] duration-300 ease-spring hover:scale-[1.02] hover:opacity-90">
86
89
  <NuxtImg v-if="props.appLogo" :src="props.appLogo" :alt="`${props.appName} logo`" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="lazy" decoding="async" :class="cn('size-8 shrink-0 object-contain', props.logoClass)" />
87
90
  <span>{{ props.appName }}</span>
88
91
  </a>
@@ -104,7 +107,8 @@ const getLinkProps = (link: LandingFooterLink) => {
104
107
  <!-- Section links -->
105
108
  <ul class="space-y-3 text-xs sm:text-sm">
106
109
  <li v-for="link in props.sections[0]?.links" :key="link.id">
107
- <component :is="props.linkComponent" v-bind="getLinkProps(link)" class="text-muted-foreground hover:underline">
110
+ <component :is="props.linkComponent" v-bind="getLinkProps(link)" class="group/link inline-flex items-center gap-1.5 text-muted-foreground transition-colors duration-150 hover:text-primary">
111
+ <span class="h-px w-0 bg-primary transition-[width] duration-300 ease-spring group-hover/link:w-3" />
108
112
  {{ link.label }}
109
113
  </component>
110
114
  </li>
@@ -123,7 +127,8 @@ const getLinkProps = (link: LandingFooterLink) => {
123
127
  <!-- Section links -->
124
128
  <ul class="space-y-3 text-xs sm:text-sm">
125
129
  <li v-for="link in section.links" :key="link.id">
126
- <component :is="props.linkComponent" v-bind="getLinkProps(link)" class="text-muted-foreground hover:underline">
130
+ <component :is="props.linkComponent" v-bind="getLinkProps(link)" class="group/link inline-flex items-center gap-1.5 text-muted-foreground transition-colors duration-150 hover:text-primary">
131
+ <span class="h-px w-0 bg-primary transition-[width] duration-300 ease-spring group-hover/link:w-3" />
127
132
  {{ link.label }}
128
133
  </component>
129
134
  </li>
@@ -142,7 +147,7 @@ const getLinkProps = (link: LandingFooterLink) => {
142
147
  <!-- Author -->
143
148
  <div v-if="props.authorName && props.authorLink" class="flex flex-wrap items-center justify-center gap-1.5 md:justify-end">
144
149
  <span>{{ t('uiVintage.footer.madeWith') }}</span>
145
- <HugeiconsIcon :icon="FavouriteIcon" class="size-4 shrink-0 text-red-700 dark:text-red-400" />
150
+ <HugeiconsIcon :icon="FavouriteIcon" class="size-4 shrink-0 text-red-600 transition-[scale] duration-300 ease-bounce hover:scale-125 dark:text-red-400" />
146
151
  <span>{{ t('uiVintage.footer.by') }}</span>
147
152
  <a :href="props.authorLink" target="_blank" rel="noopener noreferrer" class="font-semibold text-foreground hover:underline">
148
153
  {{ props.authorName }}