@brumaombra/ui-vintage 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +41 -0
- package/COMPONENTS.md +88 -17
- 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/BlogHeaderSection.vue +93 -19
- package/src/components/blog/BlogInfoSection.vue +39 -36
- 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 +18 -23
- package/src/components/blog/HeaderCarousel.vue +15 -6
- package/src/components/blog/PostCard.vue +13 -20
- package/src/components/blog/SocialShareSidebar.vue +24 -14
- package/src/components/blog/TableOfContents.vue +61 -28
- package/src/components/blog/index.ts +1 -0
- package/src/components/card-grid/CardGrid.vue +2 -2
- package/src/components/confirm-dialog/ConfirmDialog.vue +5 -0
- package/src/components/confirm-dialog/confirm-dialog-state.ts +22 -22
- package/src/components/content/BlogList.vue +28 -52
- package/src/components/content/BlogTable.vue +26 -30
- package/src/components/content/CodeBlock.vue +96 -22
- package/src/components/content/Flow.vue +29 -35
- package/src/components/content/ProseHr.vue +5 -5
- package/src/components/content/ProsePre.vue +9 -4
- package/src/components/content/Terminal.vue +42 -20
- 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 +2 -2
- package/src/components/error-page/ErrorPage.vue +3 -3
- package/src/components/info-card/InfoCard.vue +4 -4
- package/src/components/landing/landing-footer/LandingFooter.vue +16 -11
- 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 +18 -10
- package/src/components/load-more-button/LoadMoreButton.vue +7 -12
- package/src/components/loading-state-card/LoadingStateCard.vue +2 -2
- package/src/components/message-dialog/MessageDialog.vue +11 -11
- package/src/components/message-dialog/message-dialog-state.ts +1 -4
- package/src/components/message-toast/MessageToast.vue +24 -9
- 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 +37 -39
- 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 +2 -2
- package/src/components/theme-selector/theme-transition.ts +15 -3
- package/src/components/ui/accordion/Accordion.vue +18 -18
- 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 +2 -2
- 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 +1 -1
- package/src/components/ui/calendar/CalendarPrevButton.vue +1 -1
- 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/command/CommandGroup.vue +17 -3
- package/src/components/ui/command/CommandInput.vue +1 -1
- package/src/components/ui/command/CommandList.vue +5 -1
- package/src/components/ui/data-table/DataTable.vue +11 -3
- package/src/components/ui/date-picker/DatePicker.vue +7 -1
- package/src/components/ui/date-time-picker/DateTimePicker.vue +5 -1
- package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +1 -1
- 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 +4 -4
- package/src/components/ui/input/Input.vue +5 -1
- package/src/components/ui/native-select/NativeSelect.vue +6 -2
- package/src/components/ui/number-field/NumberField.vue +5 -1
- package/src/components/ui/pagination/PaginationItem.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 +1 -1
- package/src/components/ui/select/SelectItemContent.vue +1 -1
- package/src/components/ui/select/SelectTrigger.vue +5 -1
- 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/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/StepperDescription.vue +5 -3
- package/src/components/ui/stepper/StepperIndicator.vue +2 -2
- 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/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
|
@@ -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,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
|
-
import {
|
|
3
|
+
import { ArrowRight01Icon, Folder01Icon } from '@hugeicons/core-free-icons';
|
|
4
4
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
5
5
|
import { Card } from '../ui/card';
|
|
6
6
|
|
|
@@ -15,31 +15,26 @@ const props = withDefaults(defineProps<{
|
|
|
15
15
|
</script>
|
|
16
16
|
|
|
17
17
|
<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-
|
|
18
|
+
<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">
|
|
19
|
+
<!-- Image -->
|
|
20
|
+
<div class="h-32 w-full overflow-hidden border-b border-border bg-surface">
|
|
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-[1.03]" />
|
|
22
22
|
|
|
23
23
|
<!-- Fallback when there is no image -->
|
|
24
|
-
<div v-else aria-hidden="true" class="size-full
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
<div v-else aria-hidden="true" class="flex size-full items-center justify-center text-primary/60">
|
|
25
|
+
<HugeiconsIcon :icon="Folder01Icon" class="size-8" />
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
<!-- Name, post count and arrow -->
|
|
30
|
+
<div class="flex grow items-center gap-3 bg-surface px-4 py-3 transition-colors duration-200 group-hover/category:bg-primary/10">
|
|
31
|
+
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground transition-colors duration-200 group-hover/category:text-primary">
|
|
32
|
+
{{ props.name }}
|
|
32
33
|
</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>
|
|
34
|
+
<span class="shrink-0 rounded border border-primary/30 px-1.5 text-[11px] font-semibold leading-5 text-primary tabular-nums">
|
|
35
|
+
{{ props.count }}
|
|
36
|
+
</span>
|
|
37
|
+
<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
38
|
</div>
|
|
44
39
|
</Card>
|
|
45
|
-
</template>
|
|
40
|
+
</template>
|
|
@@ -1,6 +1,6 @@
|
|
|
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
5
|
import { ArrowLeft01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons';
|
|
6
6
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
@@ -30,8 +30,17 @@ const SWIPE_THRESHOLD_PX = 50;
|
|
|
30
30
|
// Carousel state
|
|
31
31
|
const currentSlide = ref(0);
|
|
32
32
|
const isPaused = ref(false);
|
|
33
|
-
const
|
|
33
|
+
const motionAllowed = ref(false);
|
|
34
34
|
const slideCount = computed(() => props.featuredPosts.length || 0);
|
|
35
|
+
const autoplayEnabled = computed(() => motionAllowed.value && slideCount.value > 1);
|
|
36
|
+
const slideIdPrefix = `carousel-${useId()}-slide`;
|
|
37
|
+
|
|
38
|
+
// Keep the active slide in range when the list of posts shrinks
|
|
39
|
+
watch(slideCount, (count) => {
|
|
40
|
+
if (currentSlide.value >= count) {
|
|
41
|
+
currentSlide.value = Math.max(0, count - 1);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
35
44
|
let swipeStartX: number | null = null;
|
|
36
45
|
|
|
37
46
|
// Handle next slide press
|
|
@@ -84,9 +93,9 @@ const handleProgressEnd = () => {
|
|
|
84
93
|
if (autoplayEnabled.value) nextSlide();
|
|
85
94
|
};
|
|
86
95
|
|
|
87
|
-
//
|
|
96
|
+
// Allow autoplay once mounted (never with reduced motion); it starts as soon as there are at least two posts
|
|
88
97
|
onMounted(() => {
|
|
89
|
-
|
|
98
|
+
motionAllowed.value = !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
90
99
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
91
100
|
});
|
|
92
101
|
|
|
@@ -100,7 +109,7 @@ onUnmounted(() => {
|
|
|
100
109
|
<div v-if="props.featuredPosts.length > 0" role="region" aria-roledescription="carousel" :aria-label="t('uiVintage.blog.carouselNavigation')" class="group/carousel relative isolate overflow-hidden rounded border border-border bg-card shadow-elevated-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45" tabindex="0" @mouseenter="pause" @mouseleave="resume" @focusin="pause" @focusout="resume" @keydown="handleKeydown" @pointerdown="handlePointerDown" @pointerup="handlePointerUp">
|
|
101
110
|
<!-- Slides (stacked; the active one crossfades in) -->
|
|
102
111
|
<div class="relative h-[28rem] w-full md:h-[32rem]">
|
|
103
|
-
<div v-for="(post, index) in props.featuredPosts" :id="
|
|
112
|
+
<div v-for="(post, index) in props.featuredPosts" :id="`${slideIdPrefix}-${index}`" :key="post.path || post.title || index" role="group" aria-roledescription="slide" :aria-label="`${index + 1} / ${slideCount}`" :aria-hidden="currentSlide !== index" :inert="currentSlide !== index || undefined" :class="cn('absolute inset-0 transition-[opacity,scale] duration-700 ease-out-expo', currentSlide === index ? 'z-10 scale-100 opacity-100' : 'z-0 scale-[1.02] opacity-0')">
|
|
104
113
|
<!-- Slide image with a slow zoom -->
|
|
105
114
|
<NuxtLink :to="post.path" tabindex="-1" aria-hidden="true" class="absolute inset-0 overflow-hidden">
|
|
106
115
|
<NuxtImg v-if="post.image" :src="post.image" :alt="post.title" width="1200" height="640" format="avif" quality="45" :sizes="{ 480: '480px', 1536: '1152px' }" :loading="index === 0 ? 'eager' : 'lazy'" :fetchpriority="index === 0 ? 'high' : 'auto'" :preload="index === 0" :class="cn('size-full object-cover', currentSlide === index && 'animate-uv-ken-burns')" />
|
|
@@ -147,7 +156,7 @@ onUnmounted(() => {
|
|
|
147
156
|
<div v-if="slideCount > 1" class="absolute inset-x-0 bottom-0 z-20 flex items-center justify-between gap-4 p-6 md:px-10 md:pb-8">
|
|
148
157
|
<!-- Slide indicators (the active one fills up until the next slide) -->
|
|
149
158
|
<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="
|
|
159
|
+
<button v-for="(_, index) in props.featuredPosts" :key="index" type="button" role="tab" :aria-selected="currentSlide === index" :aria-controls="`${slideIdPrefix}-${index}`" :aria-label="t('uiVintage.blog.goToSlide', { index: index + 1 })" :class="cn('relative h-1.5 cursor-pointer overflow-hidden rounded-full bg-white/30 outline-none transition-[width,background-color] duration-500 ease-spring hover:bg-white/50 focus-visible:ring-2 focus-visible:ring-white', currentSlide === index ? 'w-12' : 'w-4')" @click="goToSlide(index)">
|
|
151
160
|
<span v-if="currentSlide === index" :key="`progress-${currentSlide}`" class="absolute inset-0 origin-left rounded-full bg-primary" :style="autoplayEnabled ? { animation: `uv-grow-x ${AUTOPLAY_DURATION_MS}ms linear both`, animationPlayState: isPaused ? 'paused' : 'running' } : undefined" @animationend="handleProgressEnd" />
|
|
152
161
|
</button>
|
|
153
162
|
</div>
|
|
@@ -3,7 +3,6 @@ import { NuxtImg } from '#components';
|
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
4
|
import { ArrowRight01Icon } from '@hugeicons/core-free-icons';
|
|
5
5
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
|
-
import { Badge } from '../ui/badge';
|
|
7
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>
|
|
@@ -13,7 +13,7 @@ const props = defineProps<{
|
|
|
13
13
|
const { t } = useI18n();
|
|
14
14
|
const copied = ref(false);
|
|
15
15
|
|
|
16
|
-
// Social platforms config (each
|
|
16
|
+
// Social platforms config (each icon turns its brand color on hover)
|
|
17
17
|
const socialPlatforms = computed(() => {
|
|
18
18
|
const encodedUrl = encodeURIComponent(props.url);
|
|
19
19
|
const encodedTitle = encodeURIComponent(props.title);
|
|
@@ -22,37 +22,37 @@ 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: 'hover:
|
|
25
|
+
color: 'hover:text-[#1877F2]',
|
|
26
26
|
label: t('uiVintage.blog.share.facebook')
|
|
27
27
|
}, {
|
|
28
28
|
name: 'twitter',
|
|
29
29
|
url: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`,
|
|
30
30
|
icon: NewTwitterIcon,
|
|
31
|
-
color: 'hover:
|
|
31
|
+
color: 'hover:text-foreground',
|
|
32
32
|
label: t('uiVintage.blog.share.x')
|
|
33
33
|
}, {
|
|
34
34
|
name: 'whatsapp',
|
|
35
35
|
url: `https://api.whatsapp.com/send?text=${encodedTitle}%20${encodedUrl}`,
|
|
36
36
|
icon: WhatsappIcon,
|
|
37
|
-
color: 'hover:
|
|
37
|
+
color: 'hover:text-[#25D366]',
|
|
38
38
|
label: t('uiVintage.blog.share.whatsapp')
|
|
39
39
|
}, {
|
|
40
40
|
name: 'linkedin',
|
|
41
41
|
url: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
|
|
42
42
|
icon: Linkedin01Icon,
|
|
43
|
-
color: 'hover:
|
|
43
|
+
color: 'hover:text-[#0A66C2]',
|
|
44
44
|
label: t('uiVintage.blog.share.linkedin')
|
|
45
45
|
}, {
|
|
46
46
|
name: 'telegram',
|
|
47
47
|
url: `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`,
|
|
48
48
|
icon: TelegramIcon,
|
|
49
|
-
color: 'hover:
|
|
49
|
+
color: 'hover:text-[#229ED9]',
|
|
50
50
|
label: t('uiVintage.blog.share.telegram')
|
|
51
51
|
}];
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
// Shared button classes
|
|
55
|
-
const buttonClasses = 'flex size-
|
|
55
|
+
const buttonClasses = 'flex size-9 cursor-pointer items-center justify-center rounded text-muted-foreground outline-none [transition:background-color_150ms,color_150ms,scale_300ms_var(--ease-spring)] hover:bg-accent active:scale-95 focus-visible:ring-[3px] focus-visible:ring-ring/45';
|
|
56
56
|
|
|
57
57
|
// Copy the article link to the clipboard
|
|
58
58
|
const copyLink = async () => {
|
|
@@ -69,9 +69,12 @@ const copyLink = async () => {
|
|
|
69
69
|
<Teleport to="body">
|
|
70
70
|
<!-- Desktop sidebar on the right -->
|
|
71
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-
|
|
72
|
+
<div class="flex flex-col items-center gap-0.5 rounded border border-border bg-card p-1.5 shadow-elevated-sm">
|
|
73
73
|
<!-- Share icon -->
|
|
74
|
-
<
|
|
74
|
+
<span aria-hidden="true" class="flex size-9 items-center justify-center text-primary">
|
|
75
|
+
<HugeiconsIcon :icon="Share08Icon" class="size-4" />
|
|
76
|
+
</span>
|
|
77
|
+
<span aria-hidden="true" class="my-1 h-px w-6 bg-border" />
|
|
75
78
|
|
|
76
79
|
<!-- Social share links (desktop) -->
|
|
77
80
|
<a v-for="platform in socialPlatforms" :key="platform.name" :href="platform.url" target="_blank" rel="noopener noreferrer" :aria-label="platform.label" :title="platform.label" :class="[buttonClasses, platform.color]">
|
|
@@ -79,8 +82,8 @@ const copyLink = async () => {
|
|
|
79
82
|
</a>
|
|
80
83
|
|
|
81
84
|
<!-- 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:
|
|
85
|
+
<span aria-hidden="true" class="my-1 h-px w-6 bg-border" />
|
|
86
|
+
<button type="button" :aria-label="copied ? t('uiVintage.blog.share.copied') : t('uiVintage.blog.share.copy')" :title="t('uiVintage.blog.share.copy')" :class="[buttonClasses, 'hover:text-primary', copied && 'text-primary']" @click="copyLink">
|
|
84
87
|
<Transition mode="out-in" enter-active-class="transition-[opacity,scale] duration-300 ease-bounce" enter-from-class="opacity-0 scale-50" leave-active-class="transition-opacity duration-100" leave-to-class="opacity-0">
|
|
85
88
|
<HugeiconsIcon :key="String(copied)" :icon="copied ? Tick02Icon : Link01Icon" class="size-4" />
|
|
86
89
|
</Transition>
|
|
@@ -90,17 +93,24 @@ const copyLink = async () => {
|
|
|
90
93
|
|
|
91
94
|
<!-- Mobile view (floating bar at bottom) -->
|
|
92
95
|
<div class="fixed bottom-4 left-1/2 z-20 -translate-x-1/2 animate-[uv-fade-up_0.7s_var(--ease-out-expo)_0.8s_both] xl:hidden">
|
|
93
|
-
<div class="flex items-center gap-
|
|
96
|
+
<div class="flex items-center gap-0.5 rounded border border-border bg-card p-1.5 shadow-elevated-sm">
|
|
97
|
+
<!-- Share icon -->
|
|
98
|
+
<span aria-hidden="true" class="flex size-9 items-center justify-center text-primary">
|
|
99
|
+
<HugeiconsIcon :icon="Share08Icon" class="size-4" />
|
|
100
|
+
</span>
|
|
101
|
+
<span aria-hidden="true" class="mx-1 h-6 w-px bg-border" />
|
|
102
|
+
|
|
94
103
|
<!-- Social share links (mobile) -->
|
|
95
104
|
<a v-for="platform in socialPlatforms" :key="platform.name" :href="platform.url" target="_blank" rel="noopener noreferrer" :aria-label="platform.label" :class="[buttonClasses, platform.color]">
|
|
96
105
|
<HugeiconsIcon :icon="platform.icon" class="size-4" />
|
|
97
106
|
</a>
|
|
98
107
|
|
|
99
108
|
<!-- Copy link -->
|
|
100
|
-
<
|
|
109
|
+
<span aria-hidden="true" class="mx-1 h-6 w-px bg-border" />
|
|
110
|
+
<button type="button" :aria-label="copied ? t('uiVintage.blog.share.copied') : t('uiVintage.blog.share.copy')" :class="[buttonClasses, 'hover:text-primary', copied && 'text-primary']" @click="copyLink">
|
|
101
111
|
<HugeiconsIcon :icon="copied ? Tick02Icon : Link01Icon" class="size-4" />
|
|
102
112
|
</button>
|
|
103
113
|
</div>
|
|
104
114
|
</div>
|
|
105
115
|
</Teleport>
|
|
106
|
-
</template>
|
|
116
|
+
</template>
|
|
@@ -18,6 +18,7 @@ interface TocHeading {
|
|
|
18
18
|
id: string;
|
|
19
19
|
text: string;
|
|
20
20
|
level: number;
|
|
21
|
+
number: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
// Props
|
|
@@ -68,7 +69,8 @@ const headings = computed(() => {
|
|
|
68
69
|
const currentHeading = {
|
|
69
70
|
id: link.id ?? '',
|
|
70
71
|
text: link.text ?? '',
|
|
71
|
-
level: link.depth ?? 0
|
|
72
|
+
level: link.depth ?? 0,
|
|
73
|
+
number: ''
|
|
72
74
|
};
|
|
73
75
|
|
|
74
76
|
// Recursively flatten the children links
|
|
@@ -77,13 +79,23 @@ const headings = computed(() => {
|
|
|
77
79
|
});
|
|
78
80
|
};
|
|
79
81
|
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
+
// Keep h2 and h3 headings and number the main sections
|
|
83
|
+
let sectionNumber = 0;
|
|
84
|
+
return flattenLinks(tocLinks)
|
|
85
|
+
.filter(heading => heading.id && heading.text && heading.level >= 2 && heading.level <= 3)
|
|
86
|
+
.map(heading => ({
|
|
87
|
+
...heading,
|
|
88
|
+
number: heading.level === 2 ? String(++sectionNumber).padStart(2, '0') : ''
|
|
89
|
+
}));
|
|
82
90
|
});
|
|
83
91
|
|
|
92
|
+
// Number of main sections shown in the header
|
|
93
|
+
const sectionCount = computed(() => headings.value.filter(heading => heading.level === 2).length);
|
|
94
|
+
|
|
84
95
|
// Title and description for the table of contents
|
|
85
96
|
const tocTitle = computed(() => t('uiVintage.blog.tableOfContents'));
|
|
86
97
|
const tocDescription = computed(() => t('uiVintage.blog.tableOfContentsDescription'));
|
|
98
|
+
const tocSections = computed(() => t('uiVintage.blog.tableOfContentsSections', { count: sectionCount.value }, sectionCount.value));
|
|
87
99
|
|
|
88
100
|
// Prefer reduced motion for heading scroll
|
|
89
101
|
const prefersReducedMotion = () => {
|
|
@@ -265,11 +277,14 @@ const scrollToHeading = (id: string, options: { closeMobile?: boolean } = {}) =>
|
|
|
265
277
|
history.replaceState(null, '', `#${id}`);
|
|
266
278
|
}
|
|
267
279
|
};
|
|
268
|
-
restoreMobileTriggerFocus = false;
|
|
269
|
-
|
|
270
280
|
if (options.closeMobile && isMobileOpen.value) {
|
|
281
|
+
// The heading takes focus after the scroll, so skip the trigger focus restore for this close only
|
|
282
|
+
restoreMobileTriggerFocus = false;
|
|
271
283
|
mobileTriggerRef.value?.focus();
|
|
272
284
|
isMobileOpen.value = false;
|
|
285
|
+
nextTick(() => {
|
|
286
|
+
restoreMobileTriggerFocus = true;
|
|
287
|
+
});
|
|
273
288
|
window.clearTimeout(mobileScrollTimeout);
|
|
274
289
|
mobileScrollTimeout = window.setTimeout(runScroll, MOBILE_CLOSE_SCROLL_DELAY_MS);
|
|
275
290
|
return;
|
|
@@ -286,21 +301,19 @@ const openMobileToc = () => {
|
|
|
286
301
|
// Get classes for heading button
|
|
287
302
|
const getHeadingButtonClasses = (level: number, isActive: boolean) => {
|
|
288
303
|
// 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
|
-
}
|
|
304
|
+
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
305
|
|
|
299
|
-
//
|
|
300
|
-
const
|
|
306
|
+
// Main sections carry a number, subsections align with their text
|
|
307
|
+
const levelClasses = level === 2 ? 'pl-4 font-medium' : 'pl-12 md:text-xs';
|
|
308
|
+
|
|
309
|
+
// Add classes based on the active state
|
|
310
|
+
let stateClasses = level === 2 ? 'text-foreground/80' : 'text-muted-foreground';
|
|
311
|
+
if (isActive) {
|
|
312
|
+
stateClasses = 'font-semibold text-foreground';
|
|
313
|
+
}
|
|
301
314
|
|
|
302
315
|
// Return combined classes
|
|
303
|
-
return [baseClasses, levelClasses,
|
|
316
|
+
return [baseClasses, levelClasses, stateClasses];
|
|
304
317
|
};
|
|
305
318
|
|
|
306
319
|
// Watch for changes in the headings and update the visible heading IDs and active heading ID accordingly
|
|
@@ -343,10 +356,22 @@ onUnmounted(() => {
|
|
|
343
356
|
<!-- In-flow accordion; replaced by the mobile control after it scrolls away -->
|
|
344
357
|
<div ref="inlineRef" class="mb-6" :aria-hidden="isDocked" :inert="isDocked">
|
|
345
358
|
<Accordion :title="tocTitle" :icon="Bookmark01Icon">
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
359
|
+
<!-- Section count -->
|
|
360
|
+
<template #trailing>
|
|
361
|
+
<span class="shrink-0 text-xs text-muted-foreground">{{ tocSections }}</span>
|
|
362
|
+
</template>
|
|
363
|
+
|
|
364
|
+
<nav :aria-label="tocTitle">
|
|
365
|
+
<ol>
|
|
366
|
+
<li v-for="heading in headings" :key="`inline-${heading.id}`" class="relative border-l border-border">
|
|
367
|
+
<!-- Active marker on the track -->
|
|
368
|
+
<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']" />
|
|
369
|
+
<button type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id)">
|
|
370
|
+
<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>
|
|
371
|
+
<span>{{ heading.text }}</span>
|
|
372
|
+
</button>
|
|
373
|
+
</li>
|
|
374
|
+
</ol>
|
|
350
375
|
</nav>
|
|
351
376
|
</Accordion>
|
|
352
377
|
</div>
|
|
@@ -366,8 +391,8 @@ onUnmounted(() => {
|
|
|
366
391
|
<!-- Mobile table of contents header -->
|
|
367
392
|
<div class="flex min-w-0 items-center gap-3">
|
|
368
393
|
<!-- Mobile table of contents icon -->
|
|
369
|
-
<div class="flex size-
|
|
370
|
-
<HugeiconsIcon :icon="Bookmark01Icon" class="size-
|
|
394
|
+
<div class="flex size-7 shrink-0 items-center justify-center rounded border border-primary/30 text-primary">
|
|
395
|
+
<HugeiconsIcon :icon="Bookmark01Icon" class="size-3.5" />
|
|
371
396
|
</div>
|
|
372
397
|
|
|
373
398
|
<!-- Mobile table of contents title and description -->
|
|
@@ -378,6 +403,9 @@ onUnmounted(() => {
|
|
|
378
403
|
<SheetDescription class="sr-only">
|
|
379
404
|
{{ tocDescription }}
|
|
380
405
|
</SheetDescription>
|
|
406
|
+
<p aria-hidden="true" class="text-xs text-muted-foreground">
|
|
407
|
+
{{ tocSections }}
|
|
408
|
+
</p>
|
|
381
409
|
</div>
|
|
382
410
|
</div>
|
|
383
411
|
|
|
@@ -391,11 +419,16 @@ onUnmounted(() => {
|
|
|
391
419
|
|
|
392
420
|
<!-- Mobile table of contents navigation -->
|
|
393
421
|
<nav :aria-label="tocTitle" class="flex-1 overflow-y-auto p-5">
|
|
394
|
-
<
|
|
395
|
-
<
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
422
|
+
<ol>
|
|
423
|
+
<li v-for="heading in headings" :key="`mobile-${heading.id}`" class="relative border-l border-border">
|
|
424
|
+
<!-- Active marker on the track -->
|
|
425
|
+
<span aria-hidden="true" :class="['absolute inset-y-1 -left-px w-0.5 bg-primary', heading.id === activeHeadingId ? 'opacity-100' : 'opacity-0']" />
|
|
426
|
+
<button type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id, { closeMobile: true })">
|
|
427
|
+
<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>
|
|
428
|
+
<span>{{ heading.text }}</span>
|
|
429
|
+
</button>
|
|
430
|
+
</li>
|
|
431
|
+
</ol>
|
|
399
432
|
</nav>
|
|
400
433
|
</SheetContent>
|
|
401
434
|
</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';
|
|
@@ -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>
|
|
@@ -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());
|
|
@@ -120,10 +120,10 @@ const clearCurrentConfirmDialog = () => {
|
|
|
120
120
|
openNextConfirmDialog();
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
-
// Close the current dialog after the leave transition
|
|
124
|
-
const scheduleCloseCurrentConfirmDialog = () => {
|
|
125
|
-
// Guard against missing or already closed dialogs
|
|
126
|
-
if (
|
|
123
|
+
// Close the current dialog after the leave transition (only if it is still the given request)
|
|
124
|
+
const scheduleCloseCurrentConfirmDialog = (request: ConfirmDialogRequest) => {
|
|
125
|
+
// Guard against missing, replaced, or already closed dialogs
|
|
126
|
+
if (confirmDialogState.current !== request || !confirmDialogState.isOpen) return;
|
|
127
127
|
confirmDialogState.isOpen = false;
|
|
128
128
|
|
|
129
129
|
// Clear any existing close timer to avoid racing conditions
|
|
@@ -138,40 +138,40 @@ const scheduleCloseCurrentConfirmDialog = () => {
|
|
|
138
138
|
}, DIALOG_CLOSE_DURATION_MS);
|
|
139
139
|
};
|
|
140
140
|
|
|
141
|
-
// Resolve the dialog as cancelled
|
|
141
|
+
// Resolve the dialog as cancelled (ignored while a confirm or cancel handler is still running)
|
|
142
142
|
export const closeConfirmDialog = () => {
|
|
143
143
|
const current = confirmDialogState.current;
|
|
144
|
-
if (!current || !confirmDialogState.isOpen) return;
|
|
144
|
+
if (!current || !confirmDialogState.isOpen || confirmDialogState.busy) return;
|
|
145
145
|
current.resolve(false);
|
|
146
|
-
scheduleCloseCurrentConfirmDialog();
|
|
146
|
+
scheduleCloseCurrentConfirmDialog(current);
|
|
147
147
|
};
|
|
148
148
|
|
|
149
|
-
// Run
|
|
150
|
-
|
|
149
|
+
// Run a dialog handler, then resolve and close the dialog it belongs to
|
|
150
|
+
const runConfirmDialogHandler = async (handler: (() => void | Promise<void>) | null, result: boolean) => {
|
|
151
151
|
const current = confirmDialogState.current;
|
|
152
|
-
if (!current || confirmDialogState.busy) return;
|
|
152
|
+
if (!current || !confirmDialogState.isOpen || confirmDialogState.busy) return;
|
|
153
153
|
confirmDialogState.busy = true;
|
|
154
154
|
|
|
155
155
|
try {
|
|
156
|
-
await
|
|
156
|
+
await handler?.();
|
|
157
|
+
current.resolve(result);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// Settle the caller's promise even when the handler throws, then surface the error
|
|
157
160
|
current.resolve(false);
|
|
161
|
+
throw error;
|
|
158
162
|
} finally {
|
|
159
|
-
scheduleCloseCurrentConfirmDialog();
|
|
163
|
+
scheduleCloseCurrentConfirmDialog(current);
|
|
160
164
|
}
|
|
161
165
|
};
|
|
162
166
|
|
|
167
|
+
// Run the cancel handler for the active dialog
|
|
168
|
+
export const cancelActiveConfirmDialog = async () => {
|
|
169
|
+
await runConfirmDialogHandler(confirmDialogState.current?.options.onCancel ?? null, false);
|
|
170
|
+
};
|
|
171
|
+
|
|
163
172
|
// Run the confirm handler for the active dialog
|
|
164
173
|
export const confirmActiveDialog = async () => {
|
|
165
|
-
|
|
166
|
-
if (!current || confirmDialogState.busy) return;
|
|
167
|
-
confirmDialogState.busy = true;
|
|
168
|
-
|
|
169
|
-
try {
|
|
170
|
-
await current.options.onConfirm?.();
|
|
171
|
-
current.resolve(true);
|
|
172
|
-
} finally {
|
|
173
|
-
scheduleCloseCurrentConfirmDialog();
|
|
174
|
-
}
|
|
174
|
+
await runConfirmDialogHandler(confirmDialogState.current?.options.onConfirm ?? null, true);
|
|
175
175
|
};
|
|
176
176
|
|
|
177
177
|
// Queue and show a confirm dialog
|