@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,6 +1,4 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { Badge } from '../ui/badge';
|
|
3
|
-
|
|
4
2
|
// Props
|
|
5
3
|
const props = withDefaults(defineProps<{
|
|
6
4
|
tags?: Array<{
|
|
@@ -15,8 +13,13 @@ const props = withDefaults(defineProps<{
|
|
|
15
13
|
|
|
16
14
|
<template>
|
|
17
15
|
<div v-if="props.tags.length" class="flex flex-wrap gap-3">
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
<!-- Tag chip -->
|
|
17
|
+
<span v-for="(tag, index) in props.tags" :key="tag.slug" data-aos="blur-up" :data-aos-delay="Math.min(index * 40, 400)" class="inline-flex">
|
|
18
|
+
<NuxtLinkLocale :to="`/blog/tags/${tag.slug}`" class="group/tag inline-flex items-center gap-2 rounded border border-border bg-card px-3 py-2 text-xs font-semibold text-foreground shadow-elevated-sm outline-none [transition:translate_300ms_var(--ease-spring),border-color_150ms,color_150ms,box-shadow_200ms] hover:-translate-y-0.5 hover:border-primary/50 hover:text-primary hover:shadow-elevated-md focus-visible:ring-[3px] focus-visible:ring-ring/45 sm:text-sm">
|
|
19
|
+
<span aria-hidden="true" class="text-primary transition-[rotate] duration-300 ease-bounce group-hover/tag:rotate-12">#</span>
|
|
20
|
+
{{ tag.name }}
|
|
21
|
+
<span class="rounded-sm bg-surface px-1.5 py-0.5 text-[11px] text-muted-foreground tabular-nums transition-colors duration-150 group-hover/tag:bg-primary/10 group-hover/tag:text-primary">{{ tag.count }}</span>
|
|
22
|
+
</NuxtLinkLocale>
|
|
23
|
+
</span>
|
|
21
24
|
</div>
|
|
22
25
|
</template>
|
|
@@ -16,9 +16,9 @@ const props = withDefaults(defineProps<{
|
|
|
16
16
|
</script>
|
|
17
17
|
|
|
18
18
|
<template>
|
|
19
|
-
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4
|
|
19
|
+
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 md:gap-6 lg:grid-cols-3 xl:grid-cols-4">
|
|
20
20
|
<!-- Category card -->
|
|
21
|
-
<NuxtLink v-for="(category,
|
|
21
|
+
<NuxtLink v-for="(category, index) in props.categories" :key="category.path || category.slug" data-aos="blur-up" :data-aos-delay="(index % 4) * 80" :to="category.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
|
|
22
22
|
<CategoryCard :name="category.name" :image="category.image" :count="category.count" />
|
|
23
23
|
</NuxtLink>
|
|
24
24
|
</div>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { ArrowUpRight01Icon } from '@hugeicons/core-free-icons';
|
|
4
|
+
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
5
|
+
import { Card } from '../ui/card';
|
|
5
6
|
|
|
6
7
|
// Props
|
|
7
8
|
const props = withDefaults(defineProps<{
|
|
@@ -14,23 +15,31 @@ const props = withDefaults(defineProps<{
|
|
|
14
15
|
</script>
|
|
15
16
|
|
|
16
17
|
<template>
|
|
17
|
-
<Card class="
|
|
18
|
-
<
|
|
19
|
-
<!-- Background
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
<Card interactive class="group/category h-full gap-0! overflow-hidden p-0! sm:gap-0!">
|
|
19
|
+
<div class="relative h-40 w-full overflow-hidden bg-surface">
|
|
20
|
+
<!-- Background image -->
|
|
21
|
+
<NuxtImg v-if="props.image" :src="props.image" :alt="props.name" height="225" width="400" format="avif" quality="35" :sizes="{ 480: '480px', 1280: '400px' }" loading="lazy" decoding="async" class="size-full object-cover transition-[scale] duration-700 ease-out-expo group-hover/category:scale-110" />
|
|
22
|
+
|
|
23
|
+
<!-- Fallback when there is no image -->
|
|
24
|
+
<div v-else aria-hidden="true" class="size-full bg-[radial-gradient(circle_at_30%_20%,color-mix(in_oklab,var(--primary)_35%,transparent),transparent_65%)]" />
|
|
25
|
+
|
|
26
|
+
<!-- Legibility shade -->
|
|
27
|
+
<div aria-hidden="true" class="absolute inset-0 bg-linear-to-t from-black/80 via-black/25 to-black/0" />
|
|
23
28
|
|
|
24
|
-
<!--
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
<!-- Arrow that appears on hover -->
|
|
30
|
+
<span aria-hidden="true" class="absolute top-3 right-3 flex size-8 scale-75 items-center justify-center rounded border border-white/25 bg-black/30 text-white opacity-0 backdrop-blur-md transition-[opacity,scale] duration-300 ease-spring group-hover/category:scale-100 group-hover/category:opacity-100">
|
|
31
|
+
<HugeiconsIcon :icon="ArrowUpRight01Icon" class="size-4" />
|
|
32
|
+
</span>
|
|
33
|
+
|
|
34
|
+
<!-- Name and count -->
|
|
35
|
+
<div class="absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 p-4">
|
|
36
|
+
<span class="truncate text-base font-bold text-white transition-[translate] duration-300 ease-spring group-hover/category:-translate-y-0.5">
|
|
28
37
|
{{ props.name }}
|
|
29
38
|
</span>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
<span class="shrink-0 rounded-sm border border-white/25 bg-white/15 px-2 py-0.5 text-xs font-bold text-white tabular-nums backdrop-blur-md">
|
|
40
|
+
{{ props.count }}
|
|
41
|
+
</span>
|
|
33
42
|
</div>
|
|
34
|
-
</
|
|
43
|
+
</div>
|
|
35
44
|
</Card>
|
|
36
45
|
</template>
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
|
-
import {
|
|
3
|
+
import { computed, onMounted, onUnmounted, ref } 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 {
|
|
9
|
+
import { cn } from '../../lib/utils';
|
|
10
10
|
|
|
11
11
|
const { t } = useI18n();
|
|
12
12
|
|
|
@@ -24,180 +24,143 @@ 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 autoplayEnabled = ref(false);
|
|
29
34
|
const slideCount = computed(() => props.featuredPosts.length || 0);
|
|
30
|
-
|
|
31
|
-
const direction = ref<'forward' | 'backward'>('forward');
|
|
32
|
-
|
|
33
|
-
// Start auto-play functionality
|
|
34
|
-
const startAutoPlay = () => {
|
|
35
|
-
intervalId.value = setInterval(nextSlide, 5000);
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
// Stop auto-play functionality
|
|
39
|
-
const stopAutoPlay = () => {
|
|
40
|
-
if (intervalId.value) clearInterval(intervalId.value);
|
|
41
|
-
};
|
|
35
|
+
let swipeStartX: number | null = null;
|
|
42
36
|
|
|
43
37
|
// Handle next slide press
|
|
44
38
|
const nextSlide = () => {
|
|
45
|
-
direction.value = 'forward';
|
|
46
39
|
currentSlide.value = (currentSlide.value + 1) % slideCount.value;
|
|
47
40
|
};
|
|
48
41
|
|
|
49
42
|
// Handle previous slide press
|
|
50
43
|
const prevSlide = () => {
|
|
51
|
-
direction.value = 'backward';
|
|
52
44
|
currentSlide.value = (currentSlide.value - 1 + slideCount.value) % slideCount.value;
|
|
53
45
|
};
|
|
54
46
|
|
|
55
47
|
// Go to specific slide
|
|
56
48
|
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
49
|
currentSlide.value = index;
|
|
66
50
|
};
|
|
67
51
|
|
|
68
|
-
//
|
|
52
|
+
// Pause autoplay while the user hovers, focuses, or leaves the tab
|
|
53
|
+
const pause = () => {
|
|
54
|
+
isPaused.value = true;
|
|
55
|
+
};
|
|
56
|
+
const resume = () => {
|
|
57
|
+
isPaused.value = false;
|
|
58
|
+
};
|
|
59
|
+
const handleVisibilityChange = () => {
|
|
60
|
+
isPaused.value = document.visibilityState !== 'visible';
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Keyboard navigation
|
|
64
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
65
|
+
if (event.key === 'ArrowRight') nextSlide();
|
|
66
|
+
if (event.key === 'ArrowLeft') prevSlide();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Swipe navigation on touch devices
|
|
70
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
71
|
+
if (event.pointerType !== 'mouse') swipeStartX = event.clientX;
|
|
72
|
+
};
|
|
73
|
+
const handlePointerUp = (event: PointerEvent) => {
|
|
74
|
+
if (swipeStartX === null) return;
|
|
75
|
+
const deltaX = event.clientX - swipeStartX;
|
|
76
|
+
swipeStartX = null;
|
|
77
|
+
if (Math.abs(deltaX) < SWIPE_THRESHOLD_PX) return;
|
|
78
|
+
if (deltaX < 0) nextSlide();
|
|
79
|
+
else prevSlide();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Autoplay is driven by the progress animation of the active indicator, so the bar and the slide change always stay in sync
|
|
83
|
+
const handleProgressEnd = () => {
|
|
84
|
+
if (autoplayEnabled.value) nextSlide();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Start autoplay when component mounts (never with reduced motion)
|
|
69
88
|
onMounted(() => {
|
|
70
|
-
|
|
89
|
+
autoplayEnabled.value = slideCount.value > 1 && !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
90
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
71
91
|
});
|
|
72
92
|
|
|
73
|
-
// Clean up
|
|
93
|
+
// Clean up listeners when component unmounts
|
|
74
94
|
onUnmounted(() => {
|
|
75
|
-
|
|
95
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
76
96
|
});
|
|
77
97
|
</script>
|
|
78
98
|
|
|
79
99
|
<template>
|
|
80
|
-
<div v-if="props.featuredPosts.length > 0" class="relative" @mouseenter="
|
|
81
|
-
<!-- Slides -->
|
|
82
|
-
<div class="relative
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
<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">
|
|
101
|
+
<!-- Slides (stacked; the active one crossfades in) -->
|
|
102
|
+
<div class="relative h-[28rem] w-full md:h-[32rem]">
|
|
103
|
+
<div v-for="(post, index) in props.featuredPosts" :id="`carousel-slide-${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')">
|
|
104
|
+
<!-- Slide image with a slow zoom -->
|
|
105
|
+
<NuxtLink :to="post.path" tabindex="-1" aria-hidden="true" class="absolute inset-0 overflow-hidden">
|
|
106
|
+
<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')" />
|
|
107
|
+
<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" />
|
|
108
|
+
</NuxtLink>
|
|
109
|
+
|
|
110
|
+
<!-- Legibility shade -->
|
|
111
|
+
<div aria-hidden="true" class="pointer-events-none absolute inset-0 bg-linear-to-t from-black/90 via-black/45 to-black/5" />
|
|
112
|
+
<div aria-hidden="true" class="pointer-events-none absolute inset-0 bg-linear-to-r from-black/50 via-transparent to-transparent" />
|
|
113
|
+
|
|
114
|
+
<!-- Slide content (replays its entrance every time the slide becomes active) -->
|
|
115
|
+
<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">
|
|
116
|
+
<!-- Category badge -->
|
|
117
|
+
<NuxtLink v-if="post.categoryText && post.categoryPath" :to="post.categoryPath" :class="cn('w-fit', currentSlide === index && 'animate-uv-fade-up')">
|
|
118
|
+
<Badge :text="post.categoryText" pulse class="border-white/25 bg-white/15 text-white backdrop-blur-md transition-colors hover:bg-white/25" />
|
|
119
|
+
</NuxtLink>
|
|
120
|
+
|
|
121
|
+
<!-- Title -->
|
|
122
|
+
<NuxtLink :to="post.path" :class="cn('outline-none', currentSlide === index && 'animate-uv-fade-up [animation-delay:90ms]')">
|
|
123
|
+
<h3 class="line-clamp-3 text-2xl leading-tight font-bold tracking-tight text-balance drop-shadow-sm md:text-5xl">
|
|
124
|
+
{{ post.title }}
|
|
125
|
+
</h3>
|
|
126
|
+
</NuxtLink>
|
|
127
|
+
|
|
128
|
+
<!-- Description -->
|
|
129
|
+
<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]')">
|
|
130
|
+
{{ post.description }}
|
|
131
|
+
</p>
|
|
132
|
+
|
|
133
|
+
<!-- Read post button -->
|
|
134
|
+
<div :class="cn(currentSlide === index && 'animate-uv-fade-up [animation-delay:260ms]')">
|
|
135
|
+
<Button as-child size="lg" class="group/cta">
|
|
88
136
|
<NuxtLink :to="post.path">
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
</div>
|
|
137
|
+
{{ t('uiVintage.blog.goToPost') }}
|
|
138
|
+
<HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/cta:translate-x-1" />
|
|
92
139
|
</NuxtLink>
|
|
93
|
-
|
|
94
|
-
|
|
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>
|
|
140
|
+
</Button>
|
|
141
|
+
</div>
|
|
139
142
|
</div>
|
|
140
|
-
</
|
|
143
|
+
</div>
|
|
141
144
|
</div>
|
|
142
145
|
|
|
143
|
-
<!--
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
:aria-label="t('uiVintage.blog.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
146
|
+
<!-- Controls bar -->
|
|
147
|
+
<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">
|
|
148
|
+
<!-- Slide indicators (the active one fills up until the next slide) -->
|
|
149
|
+
<div class="flex items-center gap-2" role="tablist" :aria-label="t('uiVintage.blog.carouselNavigation')">
|
|
150
|
+
<button v-for="(_, index) in props.featuredPosts" :key="index" type="button" role="tab" :aria-selected="currentSlide === index" :aria-controls="`carousel-slide-${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)">
|
|
151
|
+
<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" />
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<!-- Previous and next buttons -->
|
|
156
|
+
<div class="flex items-center gap-2">
|
|
157
|
+
<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">
|
|
158
|
+
<HugeiconsIcon :icon="ArrowLeft01Icon" class="size-4" />
|
|
159
|
+
</button>
|
|
160
|
+
<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">
|
|
161
|
+
<HugeiconsIcon :icon="ArrowRight01Icon" class="size-4" />
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
158
165
|
</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>
|
|
166
|
+
</template>
|
|
@@ -4,8 +4,7 @@ import { useI18n } from 'vue-i18n';
|
|
|
4
4
|
import { ArrowRight01Icon } from '@hugeicons/core-free-icons';
|
|
5
5
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
6
|
import { Badge } from '../ui/badge';
|
|
7
|
-
import {
|
|
8
|
-
import { Card, CardContent } from '../ui/card';
|
|
7
|
+
import { Card } from '../ui/card';
|
|
9
8
|
|
|
10
9
|
const { t } = useI18n();
|
|
11
10
|
|
|
@@ -24,38 +23,38 @@ const props = withDefaults(defineProps<{
|
|
|
24
23
|
</script>
|
|
25
24
|
|
|
26
25
|
<template>
|
|
27
|
-
<Card class="
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
</
|
|
59
|
-
</
|
|
26
|
+
<Card interactive class="group/post h-full gap-0! overflow-hidden p-0! sm:gap-0!">
|
|
27
|
+
<!-- Featured image -->
|
|
28
|
+
<div v-if="props.image" class="relative aspect-video w-full overflow-hidden border-b border-border bg-surface">
|
|
29
|
+
<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,filter] duration-700 ease-out-expo group-hover/post:scale-[1.06] group-hover/post:saturate-125" />
|
|
30
|
+
|
|
31
|
+
<!-- Shade that deepens on hover -->
|
|
32
|
+
<div aria-hidden="true" class="absolute inset-0 bg-linear-to-t from-black/45 via-black/0 to-black/0 opacity-60 transition-opacity duration-500 group-hover/post:opacity-100" />
|
|
33
|
+
|
|
34
|
+
<!-- Category over the image -->
|
|
35
|
+
<Badge v-if="props.category" :text="props.category" class="absolute top-3 left-3 border-white/25 bg-black/35 text-white backdrop-blur-md" />
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<!-- Content -->
|
|
39
|
+
<div class="flex grow flex-col gap-3 p-5 sm:p-6">
|
|
40
|
+
<!-- Category (when there is no image to sit on) -->
|
|
41
|
+
<Badge v-if="props.category && !props.image" :text="props.category" />
|
|
42
|
+
|
|
43
|
+
<!-- Post title -->
|
|
44
|
+
<h2 class="line-clamp-2 text-base leading-snug font-bold text-foreground transition-colors duration-200 group-hover/post:text-primary">
|
|
45
|
+
{{ props.title }}
|
|
46
|
+
</h2>
|
|
47
|
+
|
|
48
|
+
<!-- Post description -->
|
|
49
|
+
<p class="line-clamp-3 grow text-xs leading-relaxed text-muted-foreground md:text-sm">
|
|
50
|
+
{{ props.description }}
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
<!-- Read more -->
|
|
54
|
+
<span class="mt-2 inline-flex items-center gap-1.5 text-xs font-semibold text-primary sm:text-sm">
|
|
55
|
+
{{ t('uiVintage.blog.readMore') }}
|
|
56
|
+
<HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/post:translate-x-1" />
|
|
57
|
+
</span>
|
|
58
|
+
</div>
|
|
60
59
|
</Card>
|
|
61
60
|
</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
|
|
21
|
-
|
|
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>
|