@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.
- package/COMPONENTS.md +46 -2
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/components/blog/AllPostsSection.vue +1 -2
- package/src/components/blog/BlogContentRenderer.vue +144 -49
- package/src/components/blog/BlogFAQSection.vue +13 -15
- package/src/components/blog/BlogHeaderSection.vue +27 -9
- package/src/components/blog/BlogInfoSection.vue +37 -36
- package/src/components/blog/BlogProgressBar.vue +5 -2
- package/src/components/blog/BlogSectionTitle.vue +24 -5
- package/src/components/blog/BlogTagsSection.vue +8 -5
- package/src/components/blog/CategoriesList.vue +2 -2
- package/src/components/blog/CategoryCard.vue +25 -16
- package/src/components/blog/HeaderCarousel.vue +109 -146
- package/src/components/blog/PostCard.vue +34 -35
- package/src/components/blog/PostsList.vue +3 -2
- package/src/components/blog/SocialShareSidebar.vue +51 -41
- package/src/components/blog/TableOfContents.vue +14 -10
- package/src/components/content/BlogList.vue +16 -4
- package/src/components/content/BlogTable.vue +4 -4
- package/src/components/content/CodeBlock.vue +5 -5
- package/src/components/content/Flow.vue +4 -4
- package/src/components/content/ProseHr.vue +5 -5
- package/src/components/content/Terminal.vue +10 -4
- package/src/components/cookie-consent/CookieConsent.vue +82 -0
- package/src/components/cookie-consent/index.ts +3 -0
- package/src/components/cookie-consent/useCookieConsent.ts +55 -0
- package/src/components/landing/landing-footer/LandingFooter.vue +9 -4
- package/src/components/landing/landing-navbar/LandingNavbar.vue +47 -51
- package/src/components/landing/landing-shell/LandingShell.vue +7 -2
- package/src/components/ui/accordion/Accordion.vue +6 -6
- package/src/components/ui/animated-number/AnimatedNumber.vue +19 -5
- package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbLink.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbList.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbPage.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +2 -6
- package/src/components/ui/card/Card.vue +1 -1
- package/src/components/ui/command/CommandItem.vue +2 -2
- package/src/i18n/de.json +10 -1
- package/src/i18n/en.json +10 -1
- package/src/i18n/es.json +10 -1
- package/src/i18n/fr.json +10 -1
- package/src/i18n/it.json +10 -1
- package/src/i18n/ja.json +10 -1
- package/src/i18n/pt.json +10 -1
- package/src/i18n/ru.json +10 -1
- package/src/i18n/zh.json +10 -1
- 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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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))]
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
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-
|
|
358
|
-
<HugeiconsIcon :icon="ArrowRight01Icon" class="size-
|
|
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-
|
|
365
|
-
<SheetHeader class="flex-row items-center justify-between gap-4 border-b border-
|
|
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
|
|
370
|
-
<HugeiconsIcon :icon="Bookmark01Icon" class="size-4 text-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
22
|
-
<HugeiconsIcon :icon="props.icon" class="size-4 text-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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,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-
|
|
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:
|
|
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:
|
|
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-
|
|
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 }}
|