@brumaombra/ui-vintage 0.8.2 → 0.9.1
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/AGENTS.md +41 -0
- package/COMPONENTS.md +89 -18
- package/README.md +296 -262
- package/module.mjs +5 -11
- package/package.json +30 -12
- package/src/components/blog/BlogContentRenderer.vue +1 -1
- package/src/components/blog/BlogFAQSection.vue +1 -1
- package/src/components/blog/BlogHeaderSection.vue +93 -19
- package/src/components/blog/BlogInfoSection.vue +42 -37
- package/src/components/blog/BlogPostHeader.vue +181 -0
- package/src/components/blog/BlogProgressBar.vue +3 -5
- package/src/components/blog/BlogSectionTitle.vue +8 -6
- package/src/components/blog/BlogTagsSection.vue +16 -7
- package/src/components/blog/CategoryCard.vue +19 -23
- package/src/components/blog/HeaderCarousel.vue +17 -7
- package/src/components/blog/PostCard.vue +14 -21
- package/src/components/blog/SocialShareSidebar.vue +32 -15
- package/src/components/blog/TableOfContents.vue +64 -29
- package/src/components/blog/index.ts +1 -0
- package/src/components/card-grid/CardGrid.vue +3 -3
- package/src/components/chip/Chip.vue +1 -1
- package/src/components/confirm-dialog/ConfirmDialog.vue +6 -1
- package/src/components/confirm-dialog/confirm-dialog-state.ts +22 -22
- package/src/components/content/BlogList.vue +29 -52
- package/src/components/content/BlogTable.vue +26 -30
- package/src/components/content/CodeBlock.vue +99 -23
- package/src/components/content/Flow.vue +29 -35
- package/src/components/content/ProseHr.vue +6 -6
- package/src/components/content/ProsePre.vue +9 -4
- package/src/components/content/Terminal.vue +45 -21
- package/src/components/cookie-consent/CookieConsent.vue +1 -1
- package/src/components/dashboard-shell/DashboardShell.vue +24 -7
- package/src/components/empty-state-card/EmptyStateCard.vue +3 -3
- package/src/components/error-page/ErrorPage.vue +4 -4
- package/src/components/info-card/InfoCard.vue +4 -4
- package/src/components/landing/landing-footer/LandingFooter.vue +17 -12
- package/src/components/landing/landing-navbar/LandingNavbar.vue +22 -6
- package/src/components/landing/landing-shell/LandingShell.vue +2 -7
- package/src/components/language-flag/index.ts +1 -0
- package/src/components/language-selector/LanguageSelector.vue +19 -11
- package/src/components/load-more-button/LoadMoreButton.vue +9 -13
- package/src/components/loading-state-card/LoadingStateCard.vue +3 -3
- package/src/components/message-dialog/MessageDialog.vue +14 -12
- package/src/components/message-dialog/message-dialog-state.ts +1 -4
- package/src/components/message-toast/MessageToast.vue +28 -10
- package/src/components/message-toast/message-toast-state.ts +20 -0
- package/src/components/page-header/PageHeader.vue +1 -1
- package/src/components/progress-component/ProgressComponent.vue +7 -3
- package/src/components/single-value-card/SingleValueCard.vue +39 -40
- package/src/components/slider-form-component/SliderFormComponent.vue +1 -1
- package/src/components/stat-strip/StatStrip.vue +36 -0
- package/src/components/stat-strip/index.ts +2 -0
- package/src/components/switch-form-component/SwitchFormComponent.vue +5 -5
- package/src/components/text-link/TextLink.vue +1 -1
- package/src/components/theme-selector/ThemeSelector.vue +6 -3
- package/src/components/theme-selector/theme-transition.ts +15 -3
- package/src/components/ui/accordion/Accordion.vue +19 -19
- package/src/components/ui/alert/AlertTitle.vue +1 -1
- package/src/components/ui/alert/index.ts +1 -1
- package/src/components/ui/avatar/AvatarFallback.vue +2 -1
- package/src/components/ui/avatar/AvatarGroup.vue +13 -3
- package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +3 -3
- package/src/components/ui/busy/Busy.vue +5 -7
- package/src/components/ui/button/Button.vue +4 -2
- package/src/components/ui/button/index.ts +1 -4
- package/src/components/ui/calendar/Calendar.vue +12 -7
- package/src/components/ui/calendar/CalendarCell.vue +1 -1
- package/src/components/ui/calendar/CalendarCellTrigger.vue +1 -1
- package/src/components/ui/calendar/CalendarHeadCell.vue +1 -1
- package/src/components/ui/calendar/CalendarNextButton.vue +2 -2
- package/src/components/ui/calendar/CalendarPrevButton.vue +2 -2
- package/src/components/ui/card/Card.vue +5 -15
- package/src/components/ui/checkbox/Checkbox.vue +5 -1
- package/src/components/ui/combobox/ComboboxInput.vue +5 -1
- package/src/components/ui/combobox/ComboboxItem.vue +1 -1
- package/src/components/ui/combobox/ComboboxSelect.vue +2 -1
- package/src/components/ui/combobox/ComboboxTrigger.vue +1 -1
- package/src/components/ui/command/CommandGroup.vue +17 -3
- package/src/components/ui/command/CommandInput.vue +2 -2
- package/src/components/ui/command/CommandList.vue +5 -1
- package/src/components/ui/data-table/DataTable.vue +13 -4
- package/src/components/ui/date-picker/DatePicker.vue +8 -2
- package/src/components/ui/date-time-picker/DateTimePicker.vue +5 -1
- package/src/components/ui/dialog/DialogContent.vue +1 -1
- package/src/components/ui/dialog/DialogScrollContent.vue +1 -1
- package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +1 -1
- package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
- package/src/components/ui/dropdown-menu/classes.ts +2 -0
- package/src/components/ui/field/Field.vue +4 -0
- package/src/components/ui/field/FieldLabel.vue +6 -2
- package/src/components/ui/field/field-context.ts +62 -0
- package/src/components/ui/field/index.ts +1 -0
- package/src/components/ui/file-dropzone/FileDropzone.vue +14 -5
- package/src/components/ui/input/Input.vue +5 -1
- package/src/components/ui/native-select/NativeSelect.vue +7 -3
- package/src/components/ui/number-field/NumberField.vue +7 -2
- package/src/components/ui/pagination/PaginationEllipsis.vue +1 -1
- package/src/components/ui/pagination/PaginationFirst.vue +1 -1
- package/src/components/ui/pagination/PaginationItem.vue +1 -1
- package/src/components/ui/pagination/PaginationLast.vue +1 -1
- package/src/components/ui/pagination/PaginationNext.vue +1 -1
- package/src/components/ui/pagination/PaginationPrevious.vue +1 -1
- package/src/components/ui/pin-input/PinInputInput.vue +2 -2
- package/src/components/ui/progress/Progress.vue +2 -2
- package/src/components/ui/radio-group/RadioGroupCard.vue +1 -1
- package/src/components/ui/select/SelectItem.vue +2 -2
- package/src/components/ui/select/SelectItemContent.vue +1 -1
- package/src/components/ui/select/SelectScrollDownButton.vue +1 -1
- package/src/components/ui/select/SelectScrollUpButton.vue +1 -1
- package/src/components/ui/select/SelectTrigger.vue +6 -2
- package/src/components/ui/sidebar/Sidebar.vue +1 -1
- package/src/components/ui/sidebar/SidebarGroupAction.vue +1 -1
- package/src/components/ui/sidebar/SidebarGroupLabel.vue +1 -1
- package/src/components/ui/sidebar/SidebarInset.vue +1 -1
- package/src/components/ui/sidebar/SidebarMenuAction.vue +1 -1
- package/src/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
- package/src/components/ui/sidebar/SidebarMenuSkeleton.vue +2 -2
- package/src/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
- package/src/components/ui/sidebar/SidebarProvider.vue +17 -6
- package/src/components/ui/sidebar/SidebarTrigger.vue +1 -1
- package/src/components/ui/sidebar/index.ts +2 -2
- package/src/components/ui/sidebar/utils.ts +1 -1
- package/src/components/ui/slider/Slider.vue +5 -4
- package/src/components/ui/spinner/Spinner.vue +3 -2
- package/src/components/ui/stepper/SimpleStepper.vue +1 -1
- package/src/components/ui/stepper/StepperDescription.vue +5 -3
- package/src/components/ui/stepper/StepperIndicator.vue +3 -3
- package/src/components/ui/stepper/StepperTitle.vue +5 -3
- package/src/components/ui/switch/Switch.vue +6 -2
- package/src/components/ui/tabs/TabsList.vue +1 -1
- package/src/components/ui/tabs/TabsTrigger.vue +1 -1
- package/src/components/ui/tags-input/TagsInputItemDelete.vue +1 -1
- package/src/components/ui/textarea/Textarea.vue +5 -1
- package/src/components/ui/time-picker/TimePicker.vue +2 -2
- package/src/components/ui/toggle-group/ToggleGroup.vue +1 -1
- package/src/components/ui/tooltip/TooltipContent.vue +1 -1
- package/src/i18n/de.json +16 -6
- package/src/i18n/en.json +16 -6
- package/src/i18n/es.json +16 -6
- package/src/i18n/fr.json +16 -6
- package/src/i18n/it.json +16 -6
- package/src/i18n/ja.json +16 -6
- package/src/i18n/pt.json +16 -6
- package/src/i18n/ru.json +16 -6
- package/src/i18n/zh.json +16 -6
- package/src/lib/color-tokens.ts +1 -1
- package/src/lib/i18n.ts +32 -4
- package/src/lib/utils.ts +21 -1
- package/src/runtime/ui-vintage-i18n-plugin.ts +11 -1
- package/src/styles.css +12 -16
- package/skills/ui-vintage/SKILL.md +0 -42
|
@@ -9,16 +9,18 @@ const props = withDefaults(defineProps<{
|
|
|
9
9
|
</script>
|
|
10
10
|
|
|
11
11
|
<template>
|
|
12
|
-
<div data-aos="blur-up" class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between md:mb-8">
|
|
12
|
+
<div data-aos="blur-up" class="relative mb-6 flex flex-col gap-3 border-b border-border pb-4 sm:flex-row sm:items-end sm:justify-between md:mb-8">
|
|
13
|
+
<!-- Primary segment at the start of the rule -->
|
|
14
|
+
<span aria-hidden="true" class="absolute -bottom-px left-0 h-0.5 w-12 bg-primary" />
|
|
15
|
+
|
|
13
16
|
<div class="min-w-0">
|
|
14
|
-
<!-- Title
|
|
15
|
-
<h2 class="
|
|
16
|
-
<span aria-hidden="true" class="h-6 w-1 shrink-0 rounded-full bg-primary shadow-glow md:h-7" />
|
|
17
|
+
<!-- Title -->
|
|
18
|
+
<h2 class="text-xl font-semibold tracking-tight text-foreground md:text-2xl">
|
|
17
19
|
{{ props.title }}
|
|
18
20
|
</h2>
|
|
19
21
|
|
|
20
22
|
<!-- Description -->
|
|
21
|
-
<p v-if="props.description" class="mt-
|
|
23
|
+
<p v-if="props.description" class="mt-1 max-w-2xl text-xs leading-6 text-muted-foreground md:text-sm">
|
|
22
24
|
{{ props.description }}
|
|
23
25
|
</p>
|
|
24
26
|
</div>
|
|
@@ -28,4 +30,4 @@ const props = withDefaults(defineProps<{
|
|
|
28
30
|
<slot name="action" />
|
|
29
31
|
</div>
|
|
30
32
|
</div>
|
|
31
|
-
</template>
|
|
33
|
+
</template>
|
|
@@ -6,20 +6,29 @@ const props = withDefaults(defineProps<{
|
|
|
6
6
|
slug: string;
|
|
7
7
|
count: number;
|
|
8
8
|
}>;
|
|
9
|
+
blogPath?: string;
|
|
9
10
|
}>(), {
|
|
10
|
-
tags: () => []
|
|
11
|
+
tags: () => [],
|
|
12
|
+
blogPath: '/blog'
|
|
11
13
|
});
|
|
12
14
|
</script>
|
|
13
15
|
|
|
14
16
|
<template>
|
|
15
|
-
<div v-if="props.tags.length" class="flex flex-wrap gap-
|
|
17
|
+
<div v-if="props.tags.length" class="flex flex-wrap gap-2">
|
|
16
18
|
<!-- Tag chip -->
|
|
17
19
|
<span v-for="(tag, index) in props.tags" :key="tag.slug" data-aos="blur-up" :data-aos-delay="Math.min(index * 40, 400)" class="inline-flex">
|
|
18
|
-
<NuxtLinkLocale :to="
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
<NuxtLinkLocale :to="`${props.blogPath}/tags/${tag.slug}`" class="group/tag inline-flex items-stretch overflow-hidden rounded border border-border text-xs shadow-elevated-sm outline-none transition-colors duration-150 hover:border-primary/40 focus-visible:ring-[3px] focus-visible:ring-ring/45 sm:text-sm">
|
|
21
|
+
<!-- Name -->
|
|
22
|
+
<span class="inline-flex items-center gap-1.5 bg-card px-3 py-1.5 font-medium text-foreground transition-colors duration-150 group-hover/tag:bg-primary/10 group-hover/tag:text-primary">
|
|
23
|
+
<span aria-hidden="true" class="text-primary">#</span>
|
|
24
|
+
{{ tag.name }}
|
|
25
|
+
</span>
|
|
26
|
+
|
|
27
|
+
<!-- Post count -->
|
|
28
|
+
<span class="inline-flex items-center border-l border-border bg-surface px-2 text-[11px] font-semibold text-muted-foreground tabular-nums transition-colors duration-150 group-hover/tag:border-primary/40 group-hover/tag:bg-primary/10 group-hover/tag:text-primary">
|
|
29
|
+
{{ tag.count }}
|
|
30
|
+
</span>
|
|
22
31
|
</NuxtLinkLocale>
|
|
23
32
|
</span>
|
|
24
33
|
</div>
|
|
25
|
-
</template>
|
|
34
|
+
</template>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
|
-
import
|
|
3
|
+
import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
|
|
4
|
+
import Folder01Icon from '@hugeicons/core-free-icons/Folder01Icon';
|
|
4
5
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
5
6
|
import { Card } from '../ui/card';
|
|
6
7
|
|
|
@@ -15,31 +16,26 @@ const props = withDefaults(defineProps<{
|
|
|
15
16
|
</script>
|
|
16
17
|
|
|
17
18
|
<template>
|
|
18
|
-
<Card
|
|
19
|
-
|
|
20
|
-
|
|
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-
|
|
19
|
+
<Card class="group/category h-full gap-0! overflow-hidden p-0! transition-[translate,box-shadow] duration-300 ease-out-expo hover:-translate-y-0.5 hover:shadow-elevated-md">
|
|
20
|
+
<!-- Image -->
|
|
21
|
+
<div class="h-32 w-full overflow-hidden border-b border-border bg-surface">
|
|
22
|
+
<NuxtImg v-if="props.image" :src="props.image" :alt="props.name" height="225" width="400" format="avif" quality="35" :sizes="{ 480: '480px', 1280: '400px' }" loading="lazy" decoding="async" class="size-full object-cover transition-[scale] duration-700 ease-out-expo group-hover/category:scale-[1.03]" />
|
|
22
23
|
|
|
23
24
|
<!-- Fallback when there is no image -->
|
|
24
|
-
<div v-else aria-hidden="true" class="size-full
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
<div v-else aria-hidden="true" class="flex size-full items-center justify-center text-primary/60">
|
|
26
|
+
<HugeiconsIcon :icon="Folder01Icon" class="size-8" />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
<!-- Name, post count and arrow -->
|
|
31
|
+
<div class="flex grow items-center gap-3 bg-surface px-4 py-3 transition-colors duration-200 group-hover/category:bg-primary/10">
|
|
32
|
+
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground transition-colors duration-200 group-hover/category:text-primary">
|
|
33
|
+
{{ props.name }}
|
|
32
34
|
</span>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
{{ props.name }}
|
|
38
|
-
</span>
|
|
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>
|
|
42
|
-
</div>
|
|
35
|
+
<span class="shrink-0 rounded border border-primary/30 px-1.5 text-[11px] font-semibold leading-5 text-primary tabular-nums">
|
|
36
|
+
{{ props.count }}
|
|
37
|
+
</span>
|
|
38
|
+
<HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 shrink-0 text-muted-foreground transition-[translate,color] duration-300 ease-spring group-hover/category:translate-x-1 group-hover/category:text-primary" />
|
|
43
39
|
</div>
|
|
44
40
|
</Card>
|
|
45
|
-
</template>
|
|
41
|
+
</template>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
|
-
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
|
3
|
+
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue';
|
|
4
4
|
import { useI18n } from 'vue-i18n';
|
|
5
|
-
import
|
|
5
|
+
import ArrowLeft01Icon from '@hugeicons/core-free-icons/ArrowLeft01Icon';
|
|
6
|
+
import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
|
|
6
7
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
7
8
|
import { Badge } from '../ui/badge';
|
|
8
9
|
import { Button } from '../ui/button';
|
|
@@ -30,8 +31,17 @@ const SWIPE_THRESHOLD_PX = 50;
|
|
|
30
31
|
// Carousel state
|
|
31
32
|
const currentSlide = ref(0);
|
|
32
33
|
const isPaused = ref(false);
|
|
33
|
-
const
|
|
34
|
+
const motionAllowed = ref(false);
|
|
34
35
|
const slideCount = computed(() => props.featuredPosts.length || 0);
|
|
36
|
+
const autoplayEnabled = computed(() => motionAllowed.value && slideCount.value > 1);
|
|
37
|
+
const slideIdPrefix = `carousel-${useId()}-slide`;
|
|
38
|
+
|
|
39
|
+
// Keep the active slide in range when the list of posts shrinks
|
|
40
|
+
watch(slideCount, (count) => {
|
|
41
|
+
if (currentSlide.value >= count) {
|
|
42
|
+
currentSlide.value = Math.max(0, count - 1);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
35
45
|
let swipeStartX: number | null = null;
|
|
36
46
|
|
|
37
47
|
// Handle next slide press
|
|
@@ -84,9 +94,9 @@ const handleProgressEnd = () => {
|
|
|
84
94
|
if (autoplayEnabled.value) nextSlide();
|
|
85
95
|
};
|
|
86
96
|
|
|
87
|
-
//
|
|
97
|
+
// Allow autoplay once mounted (never with reduced motion); it starts as soon as there are at least two posts
|
|
88
98
|
onMounted(() => {
|
|
89
|
-
|
|
99
|
+
motionAllowed.value = !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
90
100
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
91
101
|
});
|
|
92
102
|
|
|
@@ -100,7 +110,7 @@ onUnmounted(() => {
|
|
|
100
110
|
<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
111
|
<!-- Slides (stacked; the active one crossfades in) -->
|
|
102
112
|
<div class="relative h-[28rem] w-full md:h-[32rem]">
|
|
103
|
-
<div v-for="(post, index) in props.featuredPosts" :id="
|
|
113
|
+
<div v-for="(post, index) in props.featuredPosts" :id="`${slideIdPrefix}-${index}`" :key="post.path || post.title || index" role="group" aria-roledescription="slide" :aria-label="`${index + 1} / ${slideCount}`" :aria-hidden="currentSlide !== index" :inert="currentSlide !== index || undefined" :class="cn('absolute inset-0 transition-[opacity,scale] duration-700 ease-out-expo', currentSlide === index ? 'z-10 scale-100 opacity-100' : 'z-0 scale-[1.02] opacity-0')">
|
|
104
114
|
<!-- Slide image with a slow zoom -->
|
|
105
115
|
<NuxtLink :to="post.path" tabindex="-1" aria-hidden="true" class="absolute inset-0 overflow-hidden">
|
|
106
116
|
<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')" />
|
|
@@ -147,7 +157,7 @@ onUnmounted(() => {
|
|
|
147
157
|
<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
158
|
<!-- Slide indicators (the active one fills up until the next slide) -->
|
|
149
159
|
<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="
|
|
160
|
+
<button v-for="(_, index) in props.featuredPosts" :key="index" type="button" role="tab" :aria-selected="currentSlide === index" :aria-controls="`${slideIdPrefix}-${index}`" :aria-label="t('uiVintage.blog.goToSlide', { index: index + 1 })" :class="cn('relative h-1.5 cursor-pointer overflow-hidden rounded-full bg-white/30 outline-none transition-[width,background-color] duration-500 ease-spring hover:bg-white/50 focus-visible:ring-2 focus-visible:ring-white', currentSlide === index ? 'w-12' : 'w-4')" @click="goToSlide(index)">
|
|
151
161
|
<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
162
|
</button>
|
|
153
163
|
</div>
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
|
-
import
|
|
4
|
+
import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
|
|
5
5
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
|
-
import { Badge } from '../ui/badge';
|
|
7
6
|
import { Card } from '../ui/card';
|
|
8
7
|
|
|
9
8
|
const { t } = useI18n();
|
|
@@ -23,25 +22,19 @@ const props = withDefaults(defineProps<{
|
|
|
23
22
|
</script>
|
|
24
23
|
|
|
25
24
|
<template>
|
|
26
|
-
<Card
|
|
25
|
+
<Card class="group/post h-full gap-0! overflow-hidden p-0! transition-[translate,box-shadow] duration-300 ease-out-expo hover:-translate-y-0.5 hover:shadow-elevated-md">
|
|
27
26
|
<!-- Featured image -->
|
|
28
|
-
<div v-if="props.image" class="
|
|
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
|
|
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" />
|
|
27
|
+
<div v-if="props.image" class="aspect-video w-full overflow-hidden border-b border-border bg-surface">
|
|
28
|
+
<NuxtImg :src="props.image" :alt="props.title" height="225" width="400" format="avif" quality="35" :sizes="{ 480: '480px', 1280: '400px' }" loading="lazy" decoding="async" class="size-full object-cover transition-[scale] duration-700 ease-out-expo group-hover/post:scale-[1.03]" />
|
|
36
29
|
</div>
|
|
37
30
|
|
|
38
31
|
<!-- Content -->
|
|
39
|
-
<div class="flex grow flex-col gap-
|
|
40
|
-
<!-- Category
|
|
41
|
-
<
|
|
32
|
+
<div class="flex grow flex-col gap-2 px-5 py-4">
|
|
33
|
+
<!-- Category -->
|
|
34
|
+
<span v-if="props.category" class="text-[11px] font-semibold uppercase tracking-wider text-primary">{{ props.category }}</span>
|
|
42
35
|
|
|
43
36
|
<!-- Post title -->
|
|
44
|
-
<h2 class="line-clamp-2 text-
|
|
37
|
+
<h2 class="line-clamp-2 text-sm leading-snug font-semibold text-foreground transition-colors duration-200 group-hover/post:text-primary md:text-base">
|
|
45
38
|
{{ props.title }}
|
|
46
39
|
</h2>
|
|
47
40
|
|
|
@@ -49,12 +42,12 @@ const props = withDefaults(defineProps<{
|
|
|
49
42
|
<p class="line-clamp-3 grow text-xs leading-relaxed text-muted-foreground md:text-sm">
|
|
50
43
|
{{ props.description }}
|
|
51
44
|
</p>
|
|
45
|
+
</div>
|
|
52
46
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
</span>
|
|
47
|
+
<!-- Read more -->
|
|
48
|
+
<div class="flex items-center justify-between border-t border-border bg-surface px-5 py-2.5 text-xs text-muted-foreground transition-colors duration-200 group-hover/post:bg-primary/10 group-hover/post:text-primary">
|
|
49
|
+
<span class="font-medium">{{ t('uiVintage.blog.readMore') }}</span>
|
|
50
|
+
<HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/post:translate-x-1" />
|
|
58
51
|
</div>
|
|
59
52
|
</Card>
|
|
60
|
-
</template>
|
|
53
|
+
</template>
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, ref } from 'vue';
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
|
-
import
|
|
4
|
+
import Facebook01Icon from '@hugeicons/core-free-icons/Facebook01Icon';
|
|
5
|
+
import Link01Icon from '@hugeicons/core-free-icons/Link01Icon';
|
|
6
|
+
import Linkedin01Icon from '@hugeicons/core-free-icons/Linkedin01Icon';
|
|
7
|
+
import NewTwitterIcon from '@hugeicons/core-free-icons/NewTwitterIcon';
|
|
8
|
+
import Share08Icon from '@hugeicons/core-free-icons/Share08Icon';
|
|
9
|
+
import TelegramIcon from '@hugeicons/core-free-icons/TelegramIcon';
|
|
10
|
+
import Tick02Icon from '@hugeicons/core-free-icons/Tick02Icon';
|
|
11
|
+
import WhatsappIcon from '@hugeicons/core-free-icons/WhatsappIcon';
|
|
5
12
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
13
|
|
|
7
14
|
// Props
|
|
@@ -13,7 +20,7 @@ const props = defineProps<{
|
|
|
13
20
|
const { t } = useI18n();
|
|
14
21
|
const copied = ref(false);
|
|
15
22
|
|
|
16
|
-
// Social platforms config (each
|
|
23
|
+
// Social platforms config (each icon turns its brand color on hover)
|
|
17
24
|
const socialPlatforms = computed(() => {
|
|
18
25
|
const encodedUrl = encodeURIComponent(props.url);
|
|
19
26
|
const encodedTitle = encodeURIComponent(props.title);
|
|
@@ -22,37 +29,37 @@ const socialPlatforms = computed(() => {
|
|
|
22
29
|
name: 'facebook',
|
|
23
30
|
url: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
|
24
31
|
icon: Facebook01Icon,
|
|
25
|
-
color: 'hover:
|
|
32
|
+
color: 'hover:text-[#1877F2]',
|
|
26
33
|
label: t('uiVintage.blog.share.facebook')
|
|
27
34
|
}, {
|
|
28
35
|
name: 'twitter',
|
|
29
36
|
url: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`,
|
|
30
37
|
icon: NewTwitterIcon,
|
|
31
|
-
color: 'hover:
|
|
38
|
+
color: 'hover:text-foreground',
|
|
32
39
|
label: t('uiVintage.blog.share.x')
|
|
33
40
|
}, {
|
|
34
41
|
name: 'whatsapp',
|
|
35
42
|
url: `https://api.whatsapp.com/send?text=${encodedTitle}%20${encodedUrl}`,
|
|
36
43
|
icon: WhatsappIcon,
|
|
37
|
-
color: 'hover:
|
|
44
|
+
color: 'hover:text-[#25D366]',
|
|
38
45
|
label: t('uiVintage.blog.share.whatsapp')
|
|
39
46
|
}, {
|
|
40
47
|
name: 'linkedin',
|
|
41
48
|
url: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
|
|
42
49
|
icon: Linkedin01Icon,
|
|
43
|
-
color: 'hover:
|
|
50
|
+
color: 'hover:text-[#0A66C2]',
|
|
44
51
|
label: t('uiVintage.blog.share.linkedin')
|
|
45
52
|
}, {
|
|
46
53
|
name: 'telegram',
|
|
47
54
|
url: `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`,
|
|
48
55
|
icon: TelegramIcon,
|
|
49
|
-
color: 'hover:
|
|
56
|
+
color: 'hover:text-[#229ED9]',
|
|
50
57
|
label: t('uiVintage.blog.share.telegram')
|
|
51
58
|
}];
|
|
52
59
|
});
|
|
53
60
|
|
|
54
61
|
// Shared button classes
|
|
55
|
-
const buttonClasses = 'flex size-
|
|
62
|
+
const buttonClasses = 'flex size-9 cursor-pointer items-center justify-center rounded text-muted-foreground outline-none [transition:background-color_150ms,color_150ms,scale_300ms_var(--ease-spring)] hover:bg-accent active:scale-95 focus-visible:ring-[3px] focus-visible:ring-ring/45';
|
|
56
63
|
|
|
57
64
|
// Copy the article link to the clipboard
|
|
58
65
|
const copyLink = async () => {
|
|
@@ -69,9 +76,12 @@ const copyLink = async () => {
|
|
|
69
76
|
<Teleport to="body">
|
|
70
77
|
<!-- Desktop sidebar on the right -->
|
|
71
78
|
<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-
|
|
79
|
+
<div class="flex flex-col items-center gap-0.5 rounded border border-border bg-card p-1.5 shadow-elevated-sm">
|
|
73
80
|
<!-- Share icon -->
|
|
74
|
-
<
|
|
81
|
+
<span aria-hidden="true" class="flex size-9 items-center justify-center text-primary">
|
|
82
|
+
<HugeiconsIcon :icon="Share08Icon" class="size-4" />
|
|
83
|
+
</span>
|
|
84
|
+
<span aria-hidden="true" class="my-1 h-px w-6 bg-border" />
|
|
75
85
|
|
|
76
86
|
<!-- Social share links (desktop) -->
|
|
77
87
|
<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]">
|
|
@@ -79,8 +89,8 @@ const copyLink = async () => {
|
|
|
79
89
|
</a>
|
|
80
90
|
|
|
81
91
|
<!-- Copy link -->
|
|
82
|
-
<
|
|
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:
|
|
92
|
+
<span aria-hidden="true" class="my-1 h-px w-6 bg-border" />
|
|
93
|
+
<button type="button" :aria-label="copied ? t('uiVintage.blog.share.copied') : t('uiVintage.blog.share.copy')" :title="t('uiVintage.blog.share.copy')" :class="[buttonClasses, 'hover:text-primary', copied && 'text-primary']" @click="copyLink">
|
|
84
94
|
<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
95
|
<HugeiconsIcon :key="String(copied)" :icon="copied ? Tick02Icon : Link01Icon" class="size-4" />
|
|
86
96
|
</Transition>
|
|
@@ -90,17 +100,24 @@ const copyLink = async () => {
|
|
|
90
100
|
|
|
91
101
|
<!-- Mobile view (floating bar at bottom) -->
|
|
92
102
|
<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-
|
|
103
|
+
<div class="flex items-center gap-0.5 rounded border border-border bg-card p-1.5 shadow-elevated-sm">
|
|
104
|
+
<!-- Share icon -->
|
|
105
|
+
<span aria-hidden="true" class="flex size-9 items-center justify-center text-primary">
|
|
106
|
+
<HugeiconsIcon :icon="Share08Icon" class="size-4" />
|
|
107
|
+
</span>
|
|
108
|
+
<span aria-hidden="true" class="mx-1 h-6 w-px bg-border" />
|
|
109
|
+
|
|
94
110
|
<!-- Social share links (mobile) -->
|
|
95
111
|
<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
112
|
<HugeiconsIcon :icon="platform.icon" class="size-4" />
|
|
97
113
|
</a>
|
|
98
114
|
|
|
99
115
|
<!-- Copy link -->
|
|
100
|
-
<
|
|
116
|
+
<span aria-hidden="true" class="mx-1 h-6 w-px bg-border" />
|
|
117
|
+
<button type="button" :aria-label="copied ? t('uiVintage.blog.share.copied') : t('uiVintage.blog.share.copy')" :class="[buttonClasses, 'hover:text-primary', copied && 'text-primary']" @click="copyLink">
|
|
101
118
|
<HugeiconsIcon :icon="copied ? Tick02Icon : Link01Icon" class="size-4" />
|
|
102
119
|
</button>
|
|
103
120
|
</div>
|
|
104
121
|
</div>
|
|
105
122
|
</Teleport>
|
|
106
|
-
</template>
|
|
123
|
+
</template>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
|
-
import
|
|
4
|
+
import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
|
|
5
|
+
import Bookmark01Icon from '@hugeicons/core-free-icons/Bookmark01Icon';
|
|
6
|
+
import Cancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';
|
|
5
7
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
8
|
import { Accordion } from '../ui/accordion';
|
|
7
9
|
import { Button } from '../ui/button';
|
|
@@ -18,6 +20,7 @@ interface TocHeading {
|
|
|
18
20
|
id: string;
|
|
19
21
|
text: string;
|
|
20
22
|
level: number;
|
|
23
|
+
number: string;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
// Props
|
|
@@ -68,7 +71,8 @@ const headings = computed(() => {
|
|
|
68
71
|
const currentHeading = {
|
|
69
72
|
id: link.id ?? '',
|
|
70
73
|
text: link.text ?? '',
|
|
71
|
-
level: link.depth ?? 0
|
|
74
|
+
level: link.depth ?? 0,
|
|
75
|
+
number: ''
|
|
72
76
|
};
|
|
73
77
|
|
|
74
78
|
// Recursively flatten the children links
|
|
@@ -77,13 +81,23 @@ const headings = computed(() => {
|
|
|
77
81
|
});
|
|
78
82
|
};
|
|
79
83
|
|
|
80
|
-
//
|
|
81
|
-
|
|
84
|
+
// Keep h2 and h3 headings and number the main sections
|
|
85
|
+
let sectionNumber = 0;
|
|
86
|
+
return flattenLinks(tocLinks)
|
|
87
|
+
.filter(heading => heading.id && heading.text && heading.level >= 2 && heading.level <= 3)
|
|
88
|
+
.map(heading => ({
|
|
89
|
+
...heading,
|
|
90
|
+
number: heading.level === 2 ? String(++sectionNumber).padStart(2, '0') : ''
|
|
91
|
+
}));
|
|
82
92
|
});
|
|
83
93
|
|
|
94
|
+
// Number of main sections shown in the header
|
|
95
|
+
const sectionCount = computed(() => headings.value.filter(heading => heading.level === 2).length);
|
|
96
|
+
|
|
84
97
|
// Title and description for the table of contents
|
|
85
98
|
const tocTitle = computed(() => t('uiVintage.blog.tableOfContents'));
|
|
86
99
|
const tocDescription = computed(() => t('uiVintage.blog.tableOfContentsDescription'));
|
|
100
|
+
const tocSections = computed(() => t('uiVintage.blog.tableOfContentsSections', { count: sectionCount.value }, sectionCount.value));
|
|
87
101
|
|
|
88
102
|
// Prefer reduced motion for heading scroll
|
|
89
103
|
const prefersReducedMotion = () => {
|
|
@@ -265,11 +279,14 @@ const scrollToHeading = (id: string, options: { closeMobile?: boolean } = {}) =>
|
|
|
265
279
|
history.replaceState(null, '', `#${id}`);
|
|
266
280
|
}
|
|
267
281
|
};
|
|
268
|
-
restoreMobileTriggerFocus = false;
|
|
269
|
-
|
|
270
282
|
if (options.closeMobile && isMobileOpen.value) {
|
|
283
|
+
// The heading takes focus after the scroll, so skip the trigger focus restore for this close only
|
|
284
|
+
restoreMobileTriggerFocus = false;
|
|
271
285
|
mobileTriggerRef.value?.focus();
|
|
272
286
|
isMobileOpen.value = false;
|
|
287
|
+
nextTick(() => {
|
|
288
|
+
restoreMobileTriggerFocus = true;
|
|
289
|
+
});
|
|
273
290
|
window.clearTimeout(mobileScrollTimeout);
|
|
274
291
|
mobileScrollTimeout = window.setTimeout(runScroll, MOBILE_CLOSE_SCROLL_DELAY_MS);
|
|
275
292
|
return;
|
|
@@ -286,21 +303,19 @@ const openMobileToc = () => {
|
|
|
286
303
|
// Get classes for heading button
|
|
287
304
|
const getHeadingButtonClasses = (level: number, isActive: boolean) => {
|
|
288
305
|
// Base classes for all titles
|
|
289
|
-
const baseClasses = '
|
|
290
|
-
|
|
291
|
-
// Add classes based on heading level
|
|
292
|
-
let levelClasses = '';
|
|
293
|
-
if (level === 2) {
|
|
294
|
-
levelClasses = 'text-foreground font-semibold text-xs md:text-sm';
|
|
295
|
-
} else {
|
|
296
|
-
levelClasses = 'text-muted-foreground pl-6! text-xs md:text-sm';
|
|
297
|
-
}
|
|
306
|
+
const baseClasses = 'flex w-full cursor-pointer gap-3 rounded-r py-1.5 pr-2 text-left text-xs outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/45 md:text-sm';
|
|
298
307
|
|
|
299
|
-
//
|
|
300
|
-
const
|
|
308
|
+
// Main sections carry a number, subsections align with their text
|
|
309
|
+
const levelClasses = level === 2 ? 'pl-4 font-medium' : 'pl-12 md:text-xs';
|
|
310
|
+
|
|
311
|
+
// Add classes based on the active state
|
|
312
|
+
let stateClasses = level === 2 ? 'text-foreground/80' : 'text-muted-foreground';
|
|
313
|
+
if (isActive) {
|
|
314
|
+
stateClasses = 'font-semibold text-foreground';
|
|
315
|
+
}
|
|
301
316
|
|
|
302
317
|
// Return combined classes
|
|
303
|
-
return [baseClasses, levelClasses,
|
|
318
|
+
return [baseClasses, levelClasses, stateClasses];
|
|
304
319
|
};
|
|
305
320
|
|
|
306
321
|
// Watch for changes in the headings and update the visible heading IDs and active heading ID accordingly
|
|
@@ -343,10 +358,22 @@ onUnmounted(() => {
|
|
|
343
358
|
<!-- In-flow accordion; replaced by the mobile control after it scrolls away -->
|
|
344
359
|
<div ref="inlineRef" class="mb-6" :aria-hidden="isDocked" :inert="isDocked">
|
|
345
360
|
<Accordion :title="tocTitle" :icon="Bookmark01Icon">
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
361
|
+
<!-- Section count -->
|
|
362
|
+
<template #trailing>
|
|
363
|
+
<span class="shrink-0 text-xs text-muted-foreground">{{ tocSections }}</span>
|
|
364
|
+
</template>
|
|
365
|
+
|
|
366
|
+
<nav :aria-label="tocTitle">
|
|
367
|
+
<ol>
|
|
368
|
+
<li v-for="heading in headings" :key="`inline-${heading.id}`" class="relative border-l border-border">
|
|
369
|
+
<!-- Active marker on the track -->
|
|
370
|
+
<span aria-hidden="true" :class="['absolute inset-y-1 -left-px w-0.5 origin-center bg-primary transition-transform duration-300 ease-spring', heading.id === activeHeadingId ? 'scale-y-100' : 'scale-y-0']" />
|
|
371
|
+
<button type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id)">
|
|
372
|
+
<span v-if="heading.number" :class="['w-5 shrink-0 text-[11px] leading-5 tabular-nums transition-colors', heading.id === activeHeadingId ? 'text-primary' : 'text-muted-foreground']">{{ heading.number }}</span>
|
|
373
|
+
<span>{{ heading.text }}</span>
|
|
374
|
+
</button>
|
|
375
|
+
</li>
|
|
376
|
+
</ol>
|
|
350
377
|
</nav>
|
|
351
378
|
</Accordion>
|
|
352
379
|
</div>
|
|
@@ -366,8 +393,8 @@ onUnmounted(() => {
|
|
|
366
393
|
<!-- Mobile table of contents header -->
|
|
367
394
|
<div class="flex min-w-0 items-center gap-3">
|
|
368
395
|
<!-- Mobile table of contents icon -->
|
|
369
|
-
<div class="flex size-
|
|
370
|
-
<HugeiconsIcon :icon="Bookmark01Icon" class="size-
|
|
396
|
+
<div class="flex size-7 shrink-0 items-center justify-center rounded border border-primary/30 text-primary">
|
|
397
|
+
<HugeiconsIcon :icon="Bookmark01Icon" class="size-3.5" />
|
|
371
398
|
</div>
|
|
372
399
|
|
|
373
400
|
<!-- Mobile table of contents title and description -->
|
|
@@ -378,6 +405,9 @@ onUnmounted(() => {
|
|
|
378
405
|
<SheetDescription class="sr-only">
|
|
379
406
|
{{ tocDescription }}
|
|
380
407
|
</SheetDescription>
|
|
408
|
+
<p aria-hidden="true" class="text-xs text-muted-foreground">
|
|
409
|
+
{{ tocSections }}
|
|
410
|
+
</p>
|
|
381
411
|
</div>
|
|
382
412
|
</div>
|
|
383
413
|
|
|
@@ -391,11 +421,16 @@ onUnmounted(() => {
|
|
|
391
421
|
|
|
392
422
|
<!-- Mobile table of contents navigation -->
|
|
393
423
|
<nav :aria-label="tocTitle" class="flex-1 overflow-y-auto p-5">
|
|
394
|
-
<
|
|
395
|
-
<
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
424
|
+
<ol>
|
|
425
|
+
<li v-for="heading in headings" :key="`mobile-${heading.id}`" class="relative border-l border-border">
|
|
426
|
+
<!-- Active marker on the track -->
|
|
427
|
+
<span aria-hidden="true" :class="['absolute inset-y-1 -left-px w-0.5 bg-primary', heading.id === activeHeadingId ? 'opacity-100' : 'opacity-0']" />
|
|
428
|
+
<button type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id, { closeMobile: true })">
|
|
429
|
+
<span v-if="heading.number" :class="['w-5 shrink-0 text-[11px] leading-5 tabular-nums', heading.id === activeHeadingId ? 'text-primary' : 'text-muted-foreground']">{{ heading.number }}</span>
|
|
430
|
+
<span>{{ heading.text }}</span>
|
|
431
|
+
</button>
|
|
432
|
+
</li>
|
|
433
|
+
</ol>
|
|
399
434
|
</nav>
|
|
400
435
|
</SheetContent>
|
|
401
436
|
</Sheet>
|
|
@@ -2,6 +2,7 @@ export { default as AllPostsSection } from './AllPostsSection.vue';
|
|
|
2
2
|
export { default as BlogContentRenderer } from './BlogContentRenderer.vue';
|
|
3
3
|
export { default as BlogFAQSection } from './BlogFAQSection.vue';
|
|
4
4
|
export { default as BlogInfoSection } from './BlogInfoSection.vue';
|
|
5
|
+
export { default as BlogPostHeader } from './BlogPostHeader.vue';
|
|
5
6
|
export { default as BlogHeaderSection } from './BlogHeaderSection.vue';
|
|
6
7
|
export { default as BlogSectionTitle } from './BlogSectionTitle.vue';
|
|
7
8
|
export { default as BlogTagsSection } from './BlogTagsSection.vue';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts" generic="T extends { id: PropertyKey }">
|
|
2
|
-
import
|
|
2
|
+
import PackageIcon from '@hugeicons/core-free-icons/PackageIcon';
|
|
3
3
|
import { EmptyStateCard } from '../empty-state-card';
|
|
4
4
|
import { LoadMoreButton } from '../load-more-button';
|
|
5
5
|
import { LoadingStateCard } from '../loading-state-card';
|
|
@@ -52,8 +52,8 @@ const handleLoadMore = () => {
|
|
|
52
52
|
<slot name="card" v-for="item in props.items" :item="item" :key="item.id" />
|
|
53
53
|
</div>
|
|
54
54
|
|
|
55
|
-
<!-- Load more button -->
|
|
56
|
-
<div v-if="props.
|
|
55
|
+
<!-- Load more button (only under a grid, never under the loading or empty state) -->
|
|
56
|
+
<div v-if="!props.busy && props.items.length > 0 && (props.hasMore || props.loadMoreBusy)" class="flex justify-center mt-6">
|
|
57
57
|
<LoadMoreButton :busy="props.loadMoreBusy"
|
|
58
58
|
@load-more="handleLoadMore" />
|
|
59
59
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import Cancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';
|
|
3
3
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
4
4
|
import { computed } from 'vue';
|
|
5
5
|
import type { HTMLAttributes } from 'vue';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
3
3
|
import { computed, ref } from 'vue';
|
|
4
|
-
import
|
|
4
|
+
import InformationCircleIcon from '@hugeicons/core-free-icons/InformationCircleIcon';
|
|
5
5
|
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '../ui/alert-dialog';
|
|
6
6
|
import { Button } from '../ui/button';
|
|
7
7
|
import { cancelActiveConfirmDialog, closeConfirmDialog, confirmActiveDialog, confirmDialogState } from './confirm-dialog-state';
|
|
@@ -11,6 +11,11 @@ const pendingAction = ref<'confirm' | 'cancel' | null>(null);
|
|
|
11
11
|
|
|
12
12
|
// Run an action while showing a spinner on the button that triggered it
|
|
13
13
|
const runAction = async (action: 'confirm' | 'cancel') => {
|
|
14
|
+
// Ignore clicks while another action is still running
|
|
15
|
+
if (pendingAction.value) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
pendingAction.value = action;
|
|
15
20
|
try {
|
|
16
21
|
await (action === 'confirm' ? confirmActiveDialog() : cancelActiveConfirmDialog());
|