@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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import CheckmarkCircle02Icon from '@hugeicons/core-free-icons/CheckmarkCircle02Icon';
|
|
3
|
+
import Copy01Icon from '@hugeicons/core-free-icons/Copy01Icon';
|
|
4
|
+
import TerminalIcon from '@hugeicons/core-free-icons/TerminalIcon';
|
|
3
5
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
4
6
|
import { computed, ref } from 'vue';
|
|
5
7
|
import { useI18n } from 'vue-i18n';
|
|
6
8
|
import { Button } from '../ui/button';
|
|
7
|
-
import { Card } from '../ui/card';
|
|
8
9
|
|
|
9
10
|
// Props
|
|
10
11
|
const props = withDefaults(defineProps<{
|
|
@@ -42,35 +43,58 @@ const copyCommands = async () => {
|
|
|
42
43
|
|
|
43
44
|
<template>
|
|
44
45
|
<div class="not-prose my-6 sm:my-8">
|
|
45
|
-
<
|
|
46
|
-
<!--
|
|
47
|
-
<div class="flex items-center
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<span
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
<div data-aos="blur-up" data-terminal class="overflow-hidden rounded border border-border bg-card text-foreground shadow-elevated-sm">
|
|
47
|
+
<!-- Window title bar -->
|
|
48
|
+
<div class="relative flex h-8 items-center border-b border-border bg-surface px-3">
|
|
49
|
+
<!-- Traffic lights -->
|
|
50
|
+
<span aria-hidden="true" class="flex items-center gap-2">
|
|
51
|
+
<span class="size-3 rounded-full bg-destructive/85" />
|
|
52
|
+
<span class="size-3 rounded-full bg-warning/85" />
|
|
53
|
+
<span class="size-3 rounded-full bg-success/85" />
|
|
54
|
+
</span>
|
|
55
|
+
|
|
56
|
+
<!-- Window title -->
|
|
57
|
+
<div class="pointer-events-none absolute inset-x-24 flex items-center justify-center gap-1.5 text-muted-foreground">
|
|
58
|
+
<HugeiconsIcon :icon="TerminalIcon" class="size-3.5 shrink-0" />
|
|
59
|
+
<span class="truncate text-xs font-medium">{{ resolvedTitle }}</span>
|
|
57
60
|
</div>
|
|
58
61
|
|
|
59
62
|
<!-- Copy command button -->
|
|
60
63
|
<Button v-if="props.copyable"
|
|
61
64
|
variant="ghost"
|
|
62
65
|
size="icon"
|
|
63
|
-
class="size-
|
|
64
|
-
:aria-label="t('uiVintage.terminal.copy')"
|
|
65
|
-
:title="t('uiVintage.terminal.copy')"
|
|
66
|
+
class="ml-auto size-6 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
67
|
+
:aria-label="copied ? t('uiVintage.terminal.copied') : t('uiVintage.terminal.copy')"
|
|
68
|
+
:title="copied ? t('uiVintage.terminal.copied') : t('uiVintage.terminal.copy')"
|
|
66
69
|
:disabled="!commandText"
|
|
67
70
|
@click="copyCommands">
|
|
68
|
-
<HugeiconsIcon :icon="copied ? CheckmarkCircle02Icon : Copy01Icon" class="size-
|
|
71
|
+
<HugeiconsIcon :icon="copied ? CheckmarkCircle02Icon : Copy01Icon" class="size-3.5" />
|
|
69
72
|
</Button>
|
|
73
|
+
|
|
74
|
+
<!-- Announce the copy to screen readers -->
|
|
75
|
+
<span v-if="props.copyable" class="sr-only" aria-live="polite">{{ copied ? t('uiVintage.terminal.copied') : '' }}</span>
|
|
70
76
|
</div>
|
|
71
77
|
|
|
72
78
|
<!-- Terminal commands -->
|
|
73
|
-
<pre class="overflow-x-auto
|
|
74
|
-
</
|
|
79
|
+
<pre class="overflow-x-auto px-4 py-3 text-xs leading-6 sm:px-5 sm:py-4 sm:text-sm"><code><span v-for="(command, index) in props.commands" :key="`${command}-${index}`" class="flex min-w-max"><span class="mr-2 select-none text-primary" aria-hidden="true">{{ props.prompt }}</span><span>{{ command }}</span><span v-if="index === props.commands.length - 1" class="terminal-cursor ml-1 inline-block w-[0.6em] bg-primary/70" aria-hidden="true" /></span></code></pre>
|
|
80
|
+
</div>
|
|
75
81
|
</div>
|
|
76
|
-
</template>
|
|
82
|
+
</template>
|
|
83
|
+
|
|
84
|
+
<style scoped>
|
|
85
|
+
.terminal-cursor {
|
|
86
|
+
animation: terminal-blink 1.1s steps(1) infinite;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@keyframes terminal-blink {
|
|
90
|
+
50% {
|
|
91
|
+
opacity: 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@media (prefers-reduced-motion: reduce) {
|
|
96
|
+
.terminal-cursor {
|
|
97
|
+
animation: none;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
</style>
|
|
@@ -70,7 +70,7 @@ onMounted(() => {
|
|
|
70
70
|
<Card class="gap-3! p-4 shadow-elevated-sm">
|
|
71
71
|
<p class="text-sm text-foreground">
|
|
72
72
|
{{ resolvedMessage }}
|
|
73
|
-
<NuxtLinkLocale v-if="props.policyTo" :to="props.policyTo" class="font-
|
|
73
|
+
<NuxtLinkLocale v-if="props.policyTo" :to="props.policyTo" class="font-semibold text-primary underline underline-offset-2">{{ resolvedPolicyLabel }}</NuxtLinkLocale>
|
|
74
74
|
</p>
|
|
75
75
|
<div class="flex flex-wrap justify-end gap-2">
|
|
76
76
|
<Button variant="secondary" size="sm" @click="handleDecline">{{ resolvedDeclineLabel }}</Button>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
|
+
import { computed } from 'vue';
|
|
4
|
+
import { useI18n } from 'vue-i18n';
|
|
3
5
|
import type { Component, HTMLAttributes } from 'vue';
|
|
4
6
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
5
7
|
import BackgroundGrid from '../background-grid/BackgroundGrid.vue';
|
|
@@ -13,6 +15,7 @@ import type { HugeiconsIconDefinition } from '../../lib/common-types';
|
|
|
13
15
|
const props = withDefaults(defineProps<{
|
|
14
16
|
appName?: string;
|
|
15
17
|
appLogo?: string;
|
|
18
|
+
appLogoDark?: string;
|
|
16
19
|
appLinkTo?: string;
|
|
17
20
|
title?: string;
|
|
18
21
|
description?: string;
|
|
@@ -40,6 +43,7 @@ const props = withDefaults(defineProps<{
|
|
|
40
43
|
}>(), {
|
|
41
44
|
appName: '',
|
|
42
45
|
appLogo: '',
|
|
46
|
+
appLogoDark: '',
|
|
43
47
|
appLinkTo: '/',
|
|
44
48
|
title: '',
|
|
45
49
|
description: '',
|
|
@@ -68,6 +72,16 @@ const getSidebarItemLinkProps = (item: NonNullable<typeof props.sidebarSections>
|
|
|
68
72
|
...sharedProps
|
|
69
73
|
};
|
|
70
74
|
};
|
|
75
|
+
|
|
76
|
+
const { t } = useI18n();
|
|
77
|
+
|
|
78
|
+
// Logo sources for each theme (either one falls back to the other)
|
|
79
|
+
const lightLogo = computed(() => props.appLogo || props.appLogoDark);
|
|
80
|
+
const darkLogo = computed(() => props.appLogoDark || props.appLogo);
|
|
81
|
+
const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
|
|
82
|
+
|
|
83
|
+
// Logo alt text (also names the home link when the app name is not shown)
|
|
84
|
+
const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name: props.appName }) : t('uiVintage.common.home'));
|
|
71
85
|
</script>
|
|
72
86
|
|
|
73
87
|
<template>
|
|
@@ -75,7 +89,7 @@ const getSidebarItemLinkProps = (item: NonNullable<typeof props.sidebarSections>
|
|
|
75
89
|
<!-- Sidebar -->
|
|
76
90
|
<Sidebar :collapsible="props.collapsible" :compact="props.compact">
|
|
77
91
|
<!-- Sidebar header -->
|
|
78
|
-
<SidebarHeader v-if="props.appName || $slots['sidebar-header']" class="h-16 justify-center border-b border-sidebar-border">
|
|
92
|
+
<SidebarHeader v-if="props.appName || lightLogo || $slots['sidebar-header']" class="h-16 justify-center border-b border-sidebar-border">
|
|
79
93
|
<slot name="sidebar-header">
|
|
80
94
|
<SidebarMenu>
|
|
81
95
|
<SidebarMenuItem>
|
|
@@ -83,10 +97,13 @@ const getSidebarItemLinkProps = (item: NonNullable<typeof props.sidebarSections>
|
|
|
83
97
|
<a :href="props.appLinkTo" class="inline-flex items-center gap-2">
|
|
84
98
|
<div class="inline-flex items-center gap-2">
|
|
85
99
|
<!-- App logo -->
|
|
86
|
-
<
|
|
100
|
+
<template v-if="lightLogo">
|
|
101
|
+
<NuxtImg :src="lightLogo" :alt="logoAlt" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="eager" :class="['size-8 shrink-0 object-contain', hasDarkLogo && 'dark:hidden']" />
|
|
102
|
+
<NuxtImg v-if="hasDarkLogo" :src="darkLogo" :alt="logoAlt" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="eager" class="hidden size-8 shrink-0 object-contain dark:block" />
|
|
103
|
+
</template>
|
|
87
104
|
|
|
88
105
|
<!-- App name -->
|
|
89
|
-
<span v-if="props.appName" class="text-xl font-
|
|
106
|
+
<span v-if="props.appName" class="text-xl font-semibold tracking-tight text-foreground">{{ props.appName }}</span>
|
|
90
107
|
</div>
|
|
91
108
|
</a>
|
|
92
109
|
</SidebarMenuButton>
|
|
@@ -105,12 +122,12 @@ const getSidebarItemLinkProps = (item: NonNullable<typeof props.sidebarSections>
|
|
|
105
122
|
|
|
106
123
|
<!-- Section content -->
|
|
107
124
|
<SidebarGroupContent>
|
|
108
|
-
<SidebarMenu :class="cn('gap-
|
|
125
|
+
<SidebarMenu :class="cn('gap-1', props.compact && 'gap-0.5')">
|
|
109
126
|
<SidebarMenuItem v-for="item in section.items" :key="item.id">
|
|
110
|
-
<SidebarMenuButton as-child :is-active="item.active" :class="cn('group/nav relative h-auto min-h-
|
|
111
|
-
<component :is="props.sidebarLinkComponent" v-bind="getSidebarItemLinkProps(item)">
|
|
127
|
+
<SidebarMenuButton as-child :is-active="item.active" :class="cn('group/nav relative h-auto min-h-11 overflow-hidden rounded px-3 py-2.5 text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground data-[active=true]:bg-primary/10 data-[active=true]:text-foreground sm:px-3.5', 'before:absolute before:inset-y-2 before:left-0 before:w-0.5 before:origin-center before:scale-y-0 before:bg-primary before:transition-transform before:duration-300 before:ease-spring data-[active=true]:before:scale-y-100', item.description ? 'items-start' : 'items-center')">
|
|
128
|
+
<component :is="props.sidebarLinkComponent" v-bind="getSidebarItemLinkProps(item)" :aria-current="item.active ? 'page' : undefined">
|
|
112
129
|
<!-- Icon -->
|
|
113
|
-
<HugeiconsIcon v-if="item.icon" :icon="item.icon" :stroke-width="1.8" :class="cn('shrink-0 opacity-90 transition-[opacity,
|
|
130
|
+
<HugeiconsIcon v-if="item.icon" :icon="item.icon" :stroke-width="1.8" :class="cn('shrink-0 opacity-90 transition-[opacity,color] duration-150 group-hover/nav:opacity-100 group-data-[active=true]/nav:text-primary', item.description ? 'mt-0.5 size-5 sm:h-5 sm:w-5' : 'size-5 self-center sm:h-6 sm:w-6')" />
|
|
114
131
|
|
|
115
132
|
<!-- Label and optional description -->
|
|
116
133
|
<div :class="cn('min-w-0 flex-1 text-left leading-tight', item.description ? 'grid' : 'flex items-center')">
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue';
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
|
-
import
|
|
4
|
+
import PackageIcon from '@hugeicons/core-free-icons/PackageIcon';
|
|
5
5
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
6
|
import { Card, CardContent } from '../ui/card';
|
|
7
7
|
import type { HeadingLevel, HugeiconsIconDefinition } from '../../lib/common-types';
|
|
@@ -43,14 +43,14 @@ const resolvedDescription = computed(() => props.description
|
|
|
43
43
|
<Card class="flex flex-col items-center justify-center px-4 py-8 text-center md:py-12">
|
|
44
44
|
<CardContent class="flex flex-col items-center justify-center p-0! text-center">
|
|
45
45
|
<!-- Icon -->
|
|
46
|
-
<div class="flex items-center justify-center text-4xl text-muted-foreground opacity-40">
|
|
46
|
+
<div aria-hidden="true" class="flex items-center justify-center text-4xl text-muted-foreground opacity-40">
|
|
47
47
|
<HugeiconsIcon :icon="props.icon" class="size-10 animate-uv-float" />
|
|
48
48
|
</div>
|
|
49
49
|
|
|
50
50
|
<!-- Title -->
|
|
51
51
|
<component :is="`h${props.level}`"
|
|
52
52
|
v-if="resolvedTitle"
|
|
53
|
-
class="text-sm font-
|
|
53
|
+
class="text-sm font-semibold text-foreground md:text-base">
|
|
54
54
|
{{ resolvedTitle }}
|
|
55
55
|
</component>
|
|
56
56
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import ArrowLeft01Icon from '@hugeicons/core-free-icons/ArrowLeft01Icon';
|
|
3
3
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
4
4
|
import { computed, type HTMLAttributes } from 'vue';
|
|
5
5
|
import { useI18n } from 'vue-i18n';
|
|
@@ -64,7 +64,7 @@ const handleAction = () => {
|
|
|
64
64
|
</script>
|
|
65
65
|
|
|
66
66
|
<template>
|
|
67
|
-
<div :class="cn('relative min-h-screen bg-
|
|
67
|
+
<div :class="cn('relative min-h-screen bg-background text-foreground', props.class)">
|
|
68
68
|
<!-- Decorative background -->
|
|
69
69
|
<BackgroundGrid v-if="props.showBackground" />
|
|
70
70
|
|
|
@@ -85,12 +85,12 @@ const handleAction = () => {
|
|
|
85
85
|
<!-- Error copy -->
|
|
86
86
|
<div>
|
|
87
87
|
<!-- Status code -->
|
|
88
|
-
<div class="text-
|
|
88
|
+
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-primary">
|
|
89
89
|
{{ resolvedLabel }} {{ resolvedStatusCode }}
|
|
90
90
|
</div>
|
|
91
91
|
|
|
92
92
|
<!-- Title -->
|
|
93
|
-
<h1 class="text-2xl font-
|
|
93
|
+
<h1 class="mt-3 text-2xl font-semibold tracking-tight">
|
|
94
94
|
{{ resolvedTitle }}
|
|
95
95
|
</h1>
|
|
96
96
|
|
|
@@ -20,15 +20,15 @@ const props = withDefaults(defineProps<{
|
|
|
20
20
|
<Card :class="['flex flex-col items-center p-4! sm:flex-row', props.class]">
|
|
21
21
|
<CardContent class="flex w-full flex-col items-center gap-0 p-0! sm:flex-row">
|
|
22
22
|
<!-- Icon -->
|
|
23
|
-
<
|
|
24
|
-
<HugeiconsIcon :icon="props.icon" class="size-5" />
|
|
25
|
-
</
|
|
23
|
+
<span aria-hidden="true" class="mb-3 flex size-10 shrink-0 items-center justify-center rounded border border-primary/30 text-primary sm:mb-0 sm:mr-4">
|
|
24
|
+
<HugeiconsIcon :icon="props.icon" class="size-4.5" />
|
|
25
|
+
</span>
|
|
26
26
|
|
|
27
27
|
<!-- Content -->
|
|
28
28
|
<div class="flex w-full flex-1 items-center justify-between text-center sm:w-auto sm:text-left">
|
|
29
29
|
<div class="flex flex-col w-full gap-2 sm:gap-1">
|
|
30
30
|
<!-- Title -->
|
|
31
|
-
<span class="text-
|
|
31
|
+
<span class="text-sm font-semibold text-foreground md:text-base">
|
|
32
32
|
{{ props.title }}
|
|
33
33
|
</span>
|
|
34
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
|
-
import
|
|
3
|
+
import FavouriteIcon from '@hugeicons/core-free-icons/FavouriteIcon';
|
|
4
4
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
5
5
|
import { computed, type Component, type HTMLAttributes } from 'vue';
|
|
6
6
|
import { useI18n } from 'vue-i18n';
|
|
@@ -25,6 +25,7 @@ export interface LandingFooterSection {
|
|
|
25
25
|
const props = withDefaults(defineProps<{
|
|
26
26
|
appName: string;
|
|
27
27
|
appLogo?: string;
|
|
28
|
+
appLogoDark?: string;
|
|
28
29
|
appLinkTo?: string;
|
|
29
30
|
logoClass?: HTMLAttributes['class'];
|
|
30
31
|
appDescription?: string;
|
|
@@ -49,10 +50,11 @@ const props = withDefaults(defineProps<{
|
|
|
49
50
|
authorLink: '',
|
|
50
51
|
year: () => new Date().getFullYear(),
|
|
51
52
|
showBottomBar: true,
|
|
52
|
-
level:
|
|
53
|
+
level: 2
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
const { t } = useI18n();
|
|
57
|
+
const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name: props.appName }) : t('uiVintage.common.home'));
|
|
56
58
|
const hasSingleSection = computed(() => props.sections.length === 1);
|
|
57
59
|
|
|
58
60
|
// Get link props based on the type of link component and whether the link should open in a new tab
|
|
@@ -73,20 +75,25 @@ const getLinkProps = (link: LandingFooterLink) => {
|
|
|
73
75
|
...sharedProps
|
|
74
76
|
};
|
|
75
77
|
};
|
|
78
|
+
|
|
79
|
+
// Logo sources for each theme (either one falls back to the other)
|
|
80
|
+
const lightLogo = computed(() => props.appLogo || props.appLogoDark);
|
|
81
|
+
const darkLogo = computed(() => props.appLogoDark || props.appLogo);
|
|
82
|
+
const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
|
|
76
83
|
</script>
|
|
77
84
|
|
|
78
85
|
<template>
|
|
79
86
|
<footer :class="cn('relative z-10 border-t border-border bg-card', props.class)">
|
|
80
|
-
<!-- Accent line -->
|
|
81
|
-
<div aria-hidden="true" class="absolute inset-x-0 -top-px h-px bg-linear-to-r from-transparent via-primary/60 to-transparent" />
|
|
82
|
-
|
|
83
87
|
<div :class="cn('mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8', props.containerClass)">
|
|
84
88
|
<div :class="cn('flex flex-col gap-8 md:flex-row md:items-start md:justify-between')">
|
|
85
89
|
<!-- Brand section -->
|
|
86
90
|
<div :class="cn('max-w-md flex-1', props.brandClass)">
|
|
87
91
|
<!-- App logo and name -->
|
|
88
|
-
<a :href="props.appLinkTo" class="mb-4 inline-flex items-center gap-2 text-lg font-semibold tracking-tight text-foreground transition-
|
|
89
|
-
<
|
|
92
|
+
<a :href="props.appLinkTo" class="mb-4 inline-flex items-center gap-2 text-lg font-semibold tracking-tight text-foreground transition-opacity duration-150 hover:opacity-80">
|
|
93
|
+
<template v-if="lightLogo">
|
|
94
|
+
<NuxtImg :src="lightLogo" :alt="logoAlt" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="lazy" decoding="async" :class="cn('size-8 shrink-0 object-contain', hasDarkLogo && 'dark:hidden', props.logoClass)" />
|
|
95
|
+
<NuxtImg v-if="hasDarkLogo" :src="darkLogo" :alt="logoAlt" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="lazy" decoding="async" :class="cn('hidden size-8 shrink-0 object-contain dark:block', props.logoClass)" />
|
|
96
|
+
</template>
|
|
90
97
|
<span>{{ props.appName }}</span>
|
|
91
98
|
</a>
|
|
92
99
|
|
|
@@ -107,8 +114,7 @@ const getLinkProps = (link: LandingFooterLink) => {
|
|
|
107
114
|
<!-- Section links -->
|
|
108
115
|
<ul class="space-y-3 text-xs sm:text-sm">
|
|
109
116
|
<li v-for="link in props.sections[0]?.links" :key="link.id">
|
|
110
|
-
<component :is="props.linkComponent" v-bind="getLinkProps(link)" class="
|
|
111
|
-
<span class="h-px w-0 bg-primary transition-[width] duration-300 ease-spring group-hover/link:w-3" />
|
|
117
|
+
<component :is="props.linkComponent" v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
|
|
112
118
|
{{ link.label }}
|
|
113
119
|
</component>
|
|
114
120
|
</li>
|
|
@@ -127,8 +133,7 @@ const getLinkProps = (link: LandingFooterLink) => {
|
|
|
127
133
|
<!-- Section links -->
|
|
128
134
|
<ul class="space-y-3 text-xs sm:text-sm">
|
|
129
135
|
<li v-for="link in section.links" :key="link.id">
|
|
130
|
-
<component :is="props.linkComponent" v-bind="getLinkProps(link)" class="
|
|
131
|
-
<span class="h-px w-0 bg-primary transition-[width] duration-300 ease-spring group-hover/link:w-3" />
|
|
136
|
+
<component :is="props.linkComponent" v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
|
|
132
137
|
{{ link.label }}
|
|
133
138
|
</component>
|
|
134
139
|
</li>
|
|
@@ -147,7 +152,7 @@ const getLinkProps = (link: LandingFooterLink) => {
|
|
|
147
152
|
<!-- Author -->
|
|
148
153
|
<div v-if="props.authorName && props.authorLink" class="flex flex-wrap items-center justify-center gap-1.5 md:justify-end">
|
|
149
154
|
<span>{{ t('uiVintage.footer.madeWith') }}</span>
|
|
150
|
-
<HugeiconsIcon :icon="FavouriteIcon" class="size-4 shrink-0 text-
|
|
155
|
+
<HugeiconsIcon :icon="FavouriteIcon" class="size-4 shrink-0 text-destructive" />
|
|
151
156
|
<span>{{ t('uiVintage.footer.by') }}</span>
|
|
152
157
|
<a :href="props.authorLink" target="_blank" rel="noopener noreferrer" class="font-semibold text-foreground hover:underline">
|
|
153
158
|
{{ props.authorName }}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
|
-
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
|
3
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
4
4
|
import type { HTMLAttributes } from 'vue';
|
|
5
|
+
import { useI18n } from 'vue-i18n';
|
|
5
6
|
import BlogProgressBar from '../../blog/BlogProgressBar.vue';
|
|
6
7
|
import { cn } from '../../../lib/utils';
|
|
7
8
|
|
|
@@ -9,6 +10,7 @@ import { cn } from '../../../lib/utils';
|
|
|
9
10
|
const props = withDefaults(defineProps<{
|
|
10
11
|
appName?: string;
|
|
11
12
|
appLogo?: string;
|
|
13
|
+
appLogoDark?: string;
|
|
12
14
|
appLinkTo?: string;
|
|
13
15
|
logoClass?: HTMLAttributes['class'];
|
|
14
16
|
class?: HTMLAttributes['class'];
|
|
@@ -19,6 +21,7 @@ const props = withDefaults(defineProps<{
|
|
|
19
21
|
}>(), {
|
|
20
22
|
appName: '',
|
|
21
23
|
appLogo: '',
|
|
24
|
+
appLogoDark: '',
|
|
22
25
|
appLinkTo: '/',
|
|
23
26
|
showProgress: false
|
|
24
27
|
});
|
|
@@ -41,6 +44,16 @@ onMounted(() => {
|
|
|
41
44
|
onBeforeUnmount(() => {
|
|
42
45
|
window.removeEventListener('scroll', handleScroll);
|
|
43
46
|
});
|
|
47
|
+
|
|
48
|
+
// Logo sources for each theme (either one falls back to the other)
|
|
49
|
+
const lightLogo = computed(() => props.appLogo || props.appLogoDark);
|
|
50
|
+
const darkLogo = computed(() => props.appLogoDark || props.appLogo);
|
|
51
|
+
const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
|
|
52
|
+
|
|
53
|
+
const { t } = useI18n();
|
|
54
|
+
|
|
55
|
+
// Logo alt text (also names the home link when the app name is not shown)
|
|
56
|
+
const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name: props.appName }) : t('uiVintage.common.home'));
|
|
44
57
|
</script>
|
|
45
58
|
|
|
46
59
|
<template>
|
|
@@ -51,12 +64,15 @@ onBeforeUnmount(() => {
|
|
|
51
64
|
<!-- Left-aligned content -->
|
|
52
65
|
<div :class="cn('flex items-center', props.leftClass)">
|
|
53
66
|
<slot name="left">
|
|
54
|
-
<a :href="props.appLinkTo" class="inline-flex items-center gap-2 transition-
|
|
67
|
+
<a v-if="lightLogo || props.appName" :href="props.appLinkTo" class="inline-flex items-center gap-2 transition-opacity duration-150 hover:opacity-80">
|
|
55
68
|
<!-- App logo -->
|
|
56
|
-
<
|
|
69
|
+
<template v-if="lightLogo">
|
|
70
|
+
<NuxtImg :src="lightLogo" :alt="logoAlt" width="44" height="44" :sizes="{ 320: '44px', 640: '36px' }" loading="eager" fetchpriority="high" :class="cn('size-11 sm:size-9 shrink-0 object-contain', hasDarkLogo && 'dark:hidden', props.logoClass)" />
|
|
71
|
+
<NuxtImg v-if="hasDarkLogo" :src="darkLogo" :alt="logoAlt" width="44" height="44" :sizes="{ 320: '44px', 640: '36px' }" loading="eager" :class="cn('hidden size-11 sm:size-9 shrink-0 object-contain dark:block', props.logoClass)" />
|
|
72
|
+
</template>
|
|
57
73
|
|
|
58
74
|
<!-- App name -->
|
|
59
|
-
<span v-if="props.appName" class="hidden sm:inline! text-xl font-
|
|
75
|
+
<span v-if="props.appName" class="hidden sm:inline! text-xl font-semibold tracking-tight text-foreground">
|
|
60
76
|
{{ props.appName }}
|
|
61
77
|
</span>
|
|
62
78
|
</a>
|
|
@@ -69,8 +85,8 @@ onBeforeUnmount(() => {
|
|
|
69
85
|
</div>
|
|
70
86
|
</nav>
|
|
71
87
|
|
|
72
|
-
<!-- Reading progress along the bottom edge of the floating bar -->
|
|
73
|
-
<div v-if="props.showProgress" :class="cn('absolute inset-x-
|
|
88
|
+
<!-- Reading progress along the bottom edge of the floating bar (edge to edge, clipped to its rounded corners) -->
|
|
89
|
+
<div v-if="props.showProgress" :class="cn('absolute inset-x-0 bottom-0 overflow-hidden rounded-b-[inherit] transition-opacity duration-300', isScrolled ? 'opacity-100' : 'opacity-0')">
|
|
74
90
|
<BlogProgressBar />
|
|
75
91
|
</div>
|
|
76
92
|
</div>
|
|
@@ -24,13 +24,8 @@ const hasStructuredSlots = computed(() => {
|
|
|
24
24
|
|
|
25
25
|
<template>
|
|
26
26
|
<div :class="cn('relative flex min-h-screen flex-col bg-background text-foreground', props.rootClass)">
|
|
27
|
-
<!-- Background grid
|
|
28
|
-
<
|
|
29
|
-
<BackgroundGrid />
|
|
30
|
-
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 z-0 h-[36rem] overflow-hidden">
|
|
31
|
-
<div class="absolute -top-48 left-1/2 h-96 w-[56rem] max-w-[140%] -translate-x-1/2 rounded-full bg-primary/10 blur-[110px]" />
|
|
32
|
-
</div>
|
|
33
|
-
</template>
|
|
27
|
+
<!-- Background grid -->
|
|
28
|
+
<BackgroundGrid v-if="props.showBackground" />
|
|
34
29
|
|
|
35
30
|
<!-- If structured slots are provided, render them -->
|
|
36
31
|
<template v-if="hasStructuredSlots">
|
|
@@ -7,5 +7,6 @@ export { default as GermanFlag } from './flags/GermanFlag.vue';
|
|
|
7
7
|
export { default as ItalianFlag } from './flags/ItalianFlag.vue';
|
|
8
8
|
export { default as ChineseFlag } from './flags/ChineseFlag.vue';
|
|
9
9
|
export { default as JapaneseFlag } from './flags/JapaneseFlag.vue';
|
|
10
|
+
export { default as PortugueseFlag } from './flags/PortugueseFlag.vue';
|
|
10
11
|
export { default as RussianFlag } from './flags/RussianFlag.vue';
|
|
11
12
|
export { default as SpanishFlag } from './flags/SpanishFlag.vue';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, defineAsyncComponent, ref, type Component } from 'vue';
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
|
-
import
|
|
4
|
+
import Tick02Icon from '@hugeicons/core-free-icons/Tick02Icon';
|
|
5
5
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
6
|
import { Button } from '../ui/button';
|
|
7
7
|
import { Command, CommandGroup, CommandItem, CommandList } from '../ui/command';
|
|
@@ -31,10 +31,10 @@ const isOpen = ref(false);
|
|
|
31
31
|
const supportedLanguages = [
|
|
32
32
|
{ code: 'en', label: 'English', loadFlag: () => import('../language-flag/flags/EnglishFlag.vue') },
|
|
33
33
|
{ code: 'it', label: 'Italiano', loadFlag: () => import('../language-flag/flags/ItalianFlag.vue') },
|
|
34
|
-
{ code: 'fr', label: '
|
|
35
|
-
{ code: 'es', label: '
|
|
34
|
+
{ code: 'fr', label: 'Français', loadFlag: () => import('../language-flag/flags/FrenchFlag.vue') },
|
|
35
|
+
{ code: 'es', label: 'Español', loadFlag: () => import('../language-flag/flags/SpanishFlag.vue') },
|
|
36
36
|
{ code: 'de', label: 'Deutsch', loadFlag: () => import('../language-flag/flags/GermanFlag.vue') },
|
|
37
|
-
{ code: 'pt', label: '
|
|
37
|
+
{ code: 'pt', label: 'Português', loadFlag: () => import('../language-flag/flags/PortugueseFlag.vue') },
|
|
38
38
|
{ code: 'zh', label: '中文', loadFlag: () => import('../language-flag/flags/ChineseFlag.vue') },
|
|
39
39
|
{ code: 'ja', label: '日本語', loadFlag: () => import('../language-flag/flags/JapaneseFlag.vue') },
|
|
40
40
|
{ code: 'ru', label: 'Русский', loadFlag: () => import('../language-flag/flags/RussianFlag.vue') }
|
|
@@ -73,10 +73,18 @@ const languageDisplayNames = computed(() => {
|
|
|
73
73
|
return typeof Intl.DisplayNames === 'function' ? new Intl.DisplayNames([locale.value], { type: 'language' }) : null;
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
// Base language of a code ('it-IT' and 'it_IT' become 'it')
|
|
77
|
+
const getBaseLanguageCode = (code: string) => code.toLowerCase().split(/[-_]/)[0] ?? '';
|
|
78
|
+
|
|
79
|
+
// Supported flag code for a language code, including regional codes
|
|
80
|
+
const getFlagCode = (code: string) => {
|
|
81
|
+
const languageCode = getBaseLanguageCode(code);
|
|
82
|
+
return isSupportedLanguageCode(languageCode) ? languageCode : null;
|
|
83
|
+
};
|
|
84
|
+
|
|
76
85
|
// Get the display label for a given language code
|
|
77
86
|
const getLanguageLabel = (code: string) => {
|
|
78
|
-
const
|
|
79
|
-
const languageCode = normalizedCode.split('-')[0];
|
|
87
|
+
const languageCode = getBaseLanguageCode(code);
|
|
80
88
|
const supportedLanguage = isSupportedLanguageCode(languageCode) ? getSupportedLanguage(languageCode) : null;
|
|
81
89
|
return supportedLanguage?.label || languageDisplayNames.value?.of(languageCode) || code.toUpperCase();
|
|
82
90
|
};
|
|
@@ -92,7 +100,7 @@ const normalizedLanguages = computed(() => {
|
|
|
92
100
|
// Compute the available flag codes based on the provided language list
|
|
93
101
|
const availableFlagCodes = computed<SupportedLanguageCode[]>(() => {
|
|
94
102
|
// Extract supported language codes from the normalized list
|
|
95
|
-
const supportedCodes = normalizedLanguages.value.map(language => language.code).filter(
|
|
103
|
+
const supportedCodes = normalizedLanguages.value.map(language => getFlagCode(language.code)).filter((code): code is SupportedLanguageCode => code !== null);
|
|
96
104
|
if (supportedCodes.length > 0) {
|
|
97
105
|
return [...new Set(supportedCodes)];
|
|
98
106
|
}
|
|
@@ -103,8 +111,8 @@ const availableFlagCodes = computed<SupportedLanguageCode[]>(() => {
|
|
|
103
111
|
|
|
104
112
|
// Resolve the flag component for a specific code, falling back to the first available code
|
|
105
113
|
const resolveFlagComponent = (code?: string) => {
|
|
106
|
-
const
|
|
107
|
-
const resolvedCode =
|
|
114
|
+
const flagCode = code ? getFlagCode(code) : null;
|
|
115
|
+
const resolvedCode = flagCode && availableFlagCodes.value.includes(flagCode) ? flagCode : availableFlagCodes.value[0];
|
|
108
116
|
return getAsyncFlagComponent(resolvedCode);
|
|
109
117
|
};
|
|
110
118
|
|
|
@@ -138,8 +146,8 @@ const handleSelectLanguage = (language: string) => {
|
|
|
138
146
|
<!-- Popover content with language options -->
|
|
139
147
|
<PopoverContent side="bottom" align="end" :side-offset="8" class="w-44 p-0!">
|
|
140
148
|
<Command :model-value="props.modelValue">
|
|
141
|
-
<CommandList>
|
|
142
|
-
<CommandGroup :heading="t('uiVintage.language.title')">
|
|
149
|
+
<CommandList :aria-label="t('uiVintage.language.title')">
|
|
150
|
+
<CommandGroup :heading="t('uiVintage.language.title')" stagger>
|
|
143
151
|
<CommandItem v-for="option in normalizedLanguages" :key="option.code" :value="option.code" @select="handleSelectLanguage(option.code)">
|
|
144
152
|
<component :is="resolveFlagComponent(option.code)" class="h-4 w-[1.35rem] rounded-[2px]" />
|
|
145
153
|
<span class="flex-1">{{ option.label }}</span>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue';
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
|
-
import
|
|
4
|
+
import ArrowDown01Icon from '@hugeicons/core-free-icons/ArrowDown01Icon';
|
|
5
|
+
import RefreshIcon from '@hugeicons/core-free-icons/RefreshIcon';
|
|
5
6
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
7
|
import { Button } from '../ui/button';
|
|
7
8
|
|
|
@@ -25,24 +26,19 @@ const { t } = useI18n();
|
|
|
25
26
|
const resolvedText = computed(() => props.text || t('uiVintage.buttons.loadMore'));
|
|
26
27
|
const resolvedLoadingText = computed(() => props.loadingText || t('uiVintage.buttons.loading'));
|
|
27
28
|
|
|
28
|
-
// Handle click event
|
|
29
|
+
// Handle click event (ignored while loading, so the button stays focusable without firing twice)
|
|
29
30
|
const handleClick = () => {
|
|
31
|
+
if (props.busy) return;
|
|
30
32
|
emits('load-more');
|
|
31
33
|
};
|
|
32
34
|
</script>
|
|
33
35
|
|
|
34
36
|
<template>
|
|
35
37
|
<div class="flex justify-center">
|
|
36
|
-
<!--
|
|
37
|
-
<Button
|
|
38
|
-
<HugeiconsIcon :icon="RefreshIcon" class="size-4 animate-spin" />
|
|
39
|
-
{{ resolvedLoadingText }}
|
|
40
|
-
</Button>
|
|
41
|
-
|
|
42
|
-
<!-- Load more button -->
|
|
43
|
-
<Button v-else variant="secondary" class="w-full md:w-auto" @click="handleClick">
|
|
44
|
-
<HugeiconsIcon :icon="ArrowDown01Icon" class="size-4" />
|
|
45
|
-
{{ resolvedText }}
|
|
38
|
+
<!-- A single button for both states keeps keyboard focus on it while the next page loads -->
|
|
39
|
+
<Button variant="secondary" :class="['w-full md:w-auto', props.busy && 'cursor-wait']" :aria-disabled="props.busy || undefined" :aria-busy="props.busy || undefined" @click="handleClick">
|
|
40
|
+
<HugeiconsIcon :icon="props.busy ? RefreshIcon : ArrowDown01Icon" :class="['size-4', props.busy && 'animate-spin']" />
|
|
41
|
+
{{ props.busy ? resolvedLoadingText : resolvedText }}
|
|
46
42
|
</Button>
|
|
47
43
|
</div>
|
|
48
|
-
</template>
|
|
44
|
+
</template>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue';
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
|
-
import
|
|
4
|
+
import Loading03Icon from '@hugeicons/core-free-icons/Loading03Icon';
|
|
5
5
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
6
|
import { Card, CardContent } from '../ui/card';
|
|
7
7
|
import type { HeadingLevel, HugeiconsIconDefinition } from '../../lib/common-types';
|
|
@@ -28,14 +28,14 @@ const resolvedDescription = computed(() => props.description || t('uiVintage.com
|
|
|
28
28
|
<Card class="flex flex-col items-center justify-center px-4 py-8 text-center md:py-12">
|
|
29
29
|
<CardContent class="flex flex-col items-center justify-center p-0! text-center">
|
|
30
30
|
<!-- Loading spinner icon -->
|
|
31
|
-
<div class="flex items-center justify-center text-4xl text-
|
|
31
|
+
<div aria-hidden="true" class="flex items-center justify-center text-4xl text-muted-foreground opacity-60">
|
|
32
32
|
<HugeiconsIcon :icon="props.icon" class="size-10 animate-spin" />
|
|
33
33
|
</div>
|
|
34
34
|
|
|
35
35
|
<!-- Title -->
|
|
36
36
|
<component :is="`h${props.level}`"
|
|
37
37
|
v-if="resolvedTitle"
|
|
38
|
-
class="text-sm font-
|
|
38
|
+
class="text-sm font-semibold text-foreground md:text-base">
|
|
39
39
|
{{ resolvedTitle }}
|
|
40
40
|
</component>
|
|
41
41
|
|