@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
package/module.mjs
CHANGED
|
@@ -28,24 +28,18 @@ const loadNuxtKit = async () => {
|
|
|
28
28
|
throw new Error('Unable to resolve @nuxt/kit for @brumaombra/ui-vintage. Install the package dependencies or add Nuxt to the consuming app.');
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
const { addPlugin, createResolver, defineNuxtModule, installModule } = await loadNuxtKit();
|
|
31
|
+
const { addPlugin, createResolver, defineNuxtModule, hasNuxtModule, installModule } = await loadNuxtKit();
|
|
32
|
+
const { version } = moduleRequire('./package.json');
|
|
32
33
|
const resolver = createResolver(import.meta.url);
|
|
33
34
|
const sourceRoot = resolver.resolve('src');
|
|
34
35
|
const stylesPath = resolver.resolve('src/styles.css');
|
|
35
36
|
|
|
36
|
-
// Check whether the consuming app already has a specific module installed
|
|
37
|
-
const hasModuleInstalled = (modules, moduleName) => {
|
|
38
|
-
return modules.some((entry) => {
|
|
39
|
-
if (typeof entry === 'string') return entry === moduleName;
|
|
40
|
-
if (Array.isArray(entry)) return entry[0] === moduleName;
|
|
41
|
-
return false;
|
|
42
|
-
});
|
|
43
|
-
};
|
|
44
|
-
|
|
45
37
|
export default defineNuxtModule({
|
|
46
38
|
// Module meta information
|
|
47
39
|
meta: {
|
|
48
40
|
name: '@brumaombra/ui-vintage',
|
|
41
|
+
configKey: 'uiVintage',
|
|
42
|
+
version,
|
|
49
43
|
compatibility: {
|
|
50
44
|
nuxt: '>=4.0.0'
|
|
51
45
|
}
|
|
@@ -63,7 +57,7 @@ export default defineNuxtModule({
|
|
|
63
57
|
nuxt.options.build.transpile.push(new RegExp(`^${cleanSourceRoot}`));
|
|
64
58
|
|
|
65
59
|
// Ensure Nuxt Image is available for components that render NuxtImg
|
|
66
|
-
if (!
|
|
60
|
+
if (!hasNuxtModule('@nuxt/image', nuxt)) {
|
|
67
61
|
await installModule('@nuxt/image');
|
|
68
62
|
}
|
|
69
63
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brumaombra/ui-vintage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Source-published Nuxt UI runtime with shared theme tokens and built-in Nuxt integrations.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"author": "Mauro Brambilla",
|
|
@@ -30,12 +30,14 @@
|
|
|
30
30
|
"src",
|
|
31
31
|
"module.mjs",
|
|
32
32
|
"COMPONENTS.md",
|
|
33
|
-
"
|
|
33
|
+
"AGENTS.md"
|
|
34
34
|
],
|
|
35
35
|
"exports": {
|
|
36
36
|
".": {
|
|
37
|
-
"import": "./module.mjs"
|
|
37
|
+
"import": "./module.mjs",
|
|
38
|
+
"default": "./module.mjs"
|
|
38
39
|
},
|
|
40
|
+
"./package.json": "./package.json",
|
|
39
41
|
"./style.css": "./src/styles.css",
|
|
40
42
|
"./accordion": "./src/components/ui/accordion/index.ts",
|
|
41
43
|
"./alert": "./src/components/ui/alert/index.ts",
|
|
@@ -107,6 +109,7 @@
|
|
|
107
109
|
"./slider": "./src/components/ui/slider/index.ts",
|
|
108
110
|
"./slider-form-component": "./src/components/slider-form-component/index.ts",
|
|
109
111
|
"./spinner": "./src/components/ui/spinner/index.ts",
|
|
112
|
+
"./stat-strip": "./src/components/stat-strip/index.ts",
|
|
110
113
|
"./stepper": "./src/components/ui/stepper/index.ts",
|
|
111
114
|
"./switch": "./src/components/ui/switch/index.ts",
|
|
112
115
|
"./switch-form-component": "./src/components/switch-form-component/index.ts",
|
|
@@ -127,32 +130,47 @@
|
|
|
127
130
|
"scripts": {
|
|
128
131
|
"typecheck": "vue-tsc --noEmit",
|
|
129
132
|
"docs:components": "node scripts/generate-components-doc.mjs",
|
|
130
|
-
"docs:components:check": "node scripts/generate-components-doc.mjs --check"
|
|
133
|
+
"docs:components:check": "node scripts/generate-components-doc.mjs --check",
|
|
134
|
+
"test": "npm --prefix demo-app run test"
|
|
131
135
|
},
|
|
132
136
|
"peerDependencies": {
|
|
137
|
+
"@hugeicons/core-free-icons": "^4.1.0",
|
|
138
|
+
"@hugeicons/vue": "^1.0.0",
|
|
139
|
+
"@nuxt/content": "^3.0.0",
|
|
133
140
|
"@nuxt/image": "^2.0.0",
|
|
134
141
|
"nuxt": "^4.0.0",
|
|
142
|
+
"tailwindcss": "^4.0.0",
|
|
135
143
|
"vue": "^3.5.0",
|
|
136
144
|
"vue-i18n": "^9.0.0 || ^10.0.0 || ^11.0.0"
|
|
137
145
|
},
|
|
146
|
+
"peerDependenciesMeta": {
|
|
147
|
+
"@nuxt/content": {
|
|
148
|
+
"optional": true
|
|
149
|
+
}
|
|
150
|
+
},
|
|
138
151
|
"dependencies": {
|
|
139
|
-
"@hugeicons/core-free-icons": "4.1.1",
|
|
140
|
-
"@hugeicons/vue": "1.0.5",
|
|
141
152
|
"@internationalized/date": "3.10.0",
|
|
142
|
-
"@nuxt/kit": "4.
|
|
153
|
+
"@nuxt/kit": "4.5.2",
|
|
143
154
|
"@vueuse/core": "14.2.1",
|
|
144
155
|
"class-variance-authority": "0.7.1",
|
|
145
156
|
"clsx": "2.1.1",
|
|
146
157
|
"reka-ui": "2.9.2",
|
|
147
158
|
"shiki": "4.4.3",
|
|
148
|
-
"tailwind-merge": "3.5.0"
|
|
149
|
-
"tailwindcss": "4.2.4"
|
|
159
|
+
"tailwind-merge": "3.5.0"
|
|
150
160
|
},
|
|
151
161
|
"devDependencies": {
|
|
152
|
-
"@
|
|
153
|
-
"
|
|
162
|
+
"@hugeicons/core-free-icons": "4.1.1",
|
|
163
|
+
"@hugeicons/vue": "1.0.5",
|
|
164
|
+
"nuxt": "4.5.2",
|
|
165
|
+
"tailwindcss": "4.2.4",
|
|
154
166
|
"typescript": "5.9.3",
|
|
155
|
-
"vue": "3.5.
|
|
167
|
+
"vue": "3.5.42",
|
|
168
|
+
"vue-i18n": "11.4.13",
|
|
156
169
|
"vue-tsc": "2.2.10"
|
|
170
|
+
},
|
|
171
|
+
"overrides": {
|
|
172
|
+
"archiver-utils": {
|
|
173
|
+
"glob": "13.0.6"
|
|
174
|
+
}
|
|
157
175
|
}
|
|
158
176
|
}
|
|
@@ -187,7 +187,7 @@ const props = defineProps<{
|
|
|
187
187
|
.blog-content :deep(img:not(.not-prose):not(.not-prose *)) {
|
|
188
188
|
border: 1px solid var(--border);
|
|
189
189
|
border-radius: var(--radius-sm);
|
|
190
|
-
box-shadow: var(--uv-shadow-
|
|
190
|
+
box-shadow: var(--uv-shadow-sm);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
/* Plain markdown tables */
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue';
|
|
3
|
+
import { useI18n } from 'vue-i18n';
|
|
4
|
+
import { ArrowRight01Icon } from '@hugeicons/core-free-icons';
|
|
5
|
+
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
|
+
import type { HugeiconsIconDefinition } from '../../lib/common-types';
|
|
3
7
|
import type { ToneColor } from '../../lib/color-tokens';
|
|
8
|
+
import { cn } from '../../lib/utils';
|
|
4
9
|
import { Badge } from '../ui/badge';
|
|
10
|
+
import { StatStrip } from '../stat-strip';
|
|
5
11
|
|
|
6
12
|
// Props
|
|
7
13
|
const props = withDefaults(defineProps<{
|
|
@@ -9,48 +15,116 @@ const props = withDefaults(defineProps<{
|
|
|
9
15
|
color: ToneColor;
|
|
10
16
|
text: string;
|
|
11
17
|
}>;
|
|
18
|
+
announcement?: {
|
|
19
|
+
text: string;
|
|
20
|
+
to: string;
|
|
21
|
+
label?: string;
|
|
22
|
+
} | null;
|
|
12
23
|
title: string;
|
|
24
|
+
highlight?: string;
|
|
13
25
|
description: string;
|
|
26
|
+
stats?: Array<{
|
|
27
|
+
label: string;
|
|
28
|
+
value: string | number;
|
|
29
|
+
icon?: HugeiconsIconDefinition;
|
|
30
|
+
}>;
|
|
31
|
+
align?: 'left' | 'center' | 'right';
|
|
14
32
|
}>(), {
|
|
15
|
-
badges: () => []
|
|
33
|
+
badges: () => [],
|
|
34
|
+
announcement: null,
|
|
35
|
+
highlight: '',
|
|
36
|
+
stats: () => [],
|
|
37
|
+
align: 'center'
|
|
16
38
|
});
|
|
17
39
|
|
|
18
|
-
|
|
19
|
-
|
|
40
|
+
const { t } = useI18n();
|
|
41
|
+
|
|
42
|
+
// Alignment of the whole header: the column, its text, and the rows of badges and stats
|
|
43
|
+
const alignClasses = {
|
|
44
|
+
left: { header: 'items-start text-left', row: 'justify-start' },
|
|
45
|
+
center: { header: 'items-center text-center', row: 'justify-center' },
|
|
46
|
+
right: { header: 'items-end text-right', row: 'justify-end' }
|
|
47
|
+
} as const;
|
|
48
|
+
const alignClass = computed(() => alignClasses[props.align] ?? alignClasses.center);
|
|
49
|
+
|
|
50
|
+
// Split the title around the highlighted part (when it appears in the title)
|
|
51
|
+
const titleParts = computed(() => {
|
|
52
|
+
const index = props.highlight ? props.title.indexOf(props.highlight) : -1;
|
|
53
|
+
if (index === -1) return { before: props.title, highlight: '', after: '' };
|
|
54
|
+
return {
|
|
55
|
+
before: props.title.slice(0, index),
|
|
56
|
+
highlight: props.highlight,
|
|
57
|
+
after: props.title.slice(index + props.highlight.length)
|
|
58
|
+
};
|
|
59
|
+
});
|
|
20
60
|
|
|
21
|
-
//
|
|
22
|
-
const
|
|
61
|
+
// Words that rise in one by one (the highlight rises in as a single block between them)
|
|
62
|
+
const splitWords = (text: string) => text.split(/\s+/).filter(Boolean);
|
|
63
|
+
const beforeWords = computed(() => splitWords(titleParts.value.before));
|
|
64
|
+
const afterWords = computed(() => splitWords(titleParts.value.after));
|
|
65
|
+
// Space after a word before the highlight: always between words, and before the highlight only when the title has one there
|
|
66
|
+
const wordGap = (index: number) => {
|
|
67
|
+
if (index < beforeWords.value.length - 1) return ' ';
|
|
68
|
+
return titleParts.value.highlight && /\s$/.test(titleParts.value.before) ? ' ' : '';
|
|
69
|
+
};
|
|
70
|
+
const wordDelay = (index: number) => `${120 + Math.min(index, 12) * 55}ms`;
|
|
71
|
+
const highlightDelay = computed(() => wordDelay(beforeWords.value.length));
|
|
72
|
+
const afterDelay = (index: number) => wordDelay(beforeWords.value.length + 1 + index);
|
|
73
|
+
|
|
74
|
+
// The rest of the header follows the last word of the title
|
|
75
|
+
const titleLength = computed(() => beforeWords.value.length + afterWords.value.length + (titleParts.value.highlight ? 1 : 0));
|
|
76
|
+
const descriptionDelay = computed(() => `${Math.min(titleLength.value, 12) * 55 + 200}ms`);
|
|
77
|
+
const statsDelay = computed(() => `${Math.min(titleLength.value, 12) * 55 + 300}ms`);
|
|
78
|
+
const underlineDelay = computed(() => `${beforeWords.value.length * 55 + 600}ms`);
|
|
23
79
|
</script>
|
|
24
80
|
|
|
25
81
|
<template>
|
|
26
|
-
<header class="relative
|
|
27
|
-
<!--
|
|
28
|
-
<
|
|
82
|
+
<header :class="cn('relative flex flex-col gap-6 pt-6 md:pt-12', alignClass.header)">
|
|
83
|
+
<!-- Announcement (links to something new, such as the latest post) -->
|
|
84
|
+
<NuxtLink v-if="props.announcement" :to="props.announcement.to" class="group/announce max-w-full animate-uv-fade-up rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
|
|
85
|
+
<span class="inline-flex max-w-full items-center gap-2 rounded border border-warning/35 bg-warning/10 px-3 py-1 text-xs text-foreground transition-colors duration-150 group-hover/announce:border-warning/70">
|
|
86
|
+
<span aria-hidden="true" class="relative flex size-1.5 shrink-0 text-warning">
|
|
87
|
+
<span class="absolute inline-flex size-full animate-uv-ping-soft rounded-full bg-current opacity-60" />
|
|
88
|
+
<span class="relative inline-flex size-1.5 rounded-full bg-current" />
|
|
89
|
+
</span>
|
|
90
|
+
<span class="shrink-0 font-semibold">{{ props.announcement.label || t('uiVintage.blog.new') }}</span>
|
|
91
|
+
<span class="truncate opacity-80">{{ props.announcement.text }}</span>
|
|
92
|
+
<HugeiconsIcon :icon="ArrowRight01Icon" class="size-3.5 shrink-0 transition-transform duration-300 ease-spring group-hover/announce:translate-x-0.5" />
|
|
93
|
+
</span>
|
|
94
|
+
</NuxtLink>
|
|
29
95
|
|
|
30
96
|
<!-- Badges -->
|
|
31
|
-
<div v-if="props.badges.length > 0" class="flex flex-wrap gap-
|
|
97
|
+
<div v-if="props.badges.length > 0" :class="cn('flex flex-wrap gap-2', alignClass.row)">
|
|
32
98
|
<Badge v-for="(badge, index) in props.badges"
|
|
33
99
|
:key="`${badge.color}-${badge.text}`"
|
|
34
100
|
:color="badge.color"
|
|
35
101
|
:text="badge.text"
|
|
36
|
-
:pulse="index === 0"
|
|
102
|
+
:pulse="index === 0 && !props.announcement"
|
|
37
103
|
class="animate-uv-fade-up"
|
|
38
104
|
:style="{ animationDelay: `${index * 70}ms` }" />
|
|
39
105
|
</div>
|
|
40
106
|
|
|
41
|
-
<!-- Title (each word rises in with a spring) -->
|
|
42
|
-
<h1 class="text-
|
|
43
|
-
<template v-for="(word, index) in
|
|
44
|
-
<span class="inline-block animate-uv-word-in" :style="{ animationDelay:
|
|
107
|
+
<!-- Title (each word rises in with a spring, the highlight gets a hand-drawn underline) -->
|
|
108
|
+
<h1 class="max-w-4xl text-4xl leading-[1.05] font-semibold tracking-tight text-foreground sm:text-5xl md:text-6xl">
|
|
109
|
+
<template v-for="(word, index) in beforeWords" :key="`before-${word}-${index}`">
|
|
110
|
+
<span class="inline-block animate-uv-word-in" :style="{ animationDelay: wordDelay(index) }">{{ word }}</span>{{ wordGap(index) }}
|
|
111
|
+
</template>
|
|
112
|
+
<!-- The text and the underline sit on one line so no stray space ends up inside the highlight -->
|
|
113
|
+
<span v-if="titleParts.highlight" class="relative inline-block animate-uv-word-in text-primary" :style="{ animationDelay: highlightDelay }">{{ titleParts.highlight }}<svg class="absolute -bottom-2 left-0 h-3 w-full text-primary/60" viewBox="0 0 200 12" fill="none" preserveAspectRatio="none" aria-hidden="true">
|
|
114
|
+
<path d="M2 9C40 3 80 2 198 7" stroke="currentColor" stroke-width="4" stroke-linecap="round" pathLength="1" class="animate-uv-draw [stroke-dasharray:1]" :style="{ animationDelay: underlineDelay }" />
|
|
115
|
+
</svg>
|
|
116
|
+
</span>
|
|
117
|
+
<template v-for="(word, index) in afterWords" :key="`after-${word}-${index}`">
|
|
118
|
+
{{ index === 0 && !/^\s/.test(titleParts.after) ? '' : ' ' }}<span class="inline-block animate-uv-word-in" :style="{ animationDelay: afterDelay(index) }">{{ word }}</span>
|
|
45
119
|
</template>
|
|
46
120
|
</h1>
|
|
47
121
|
|
|
48
|
-
<!-- Accent rule -->
|
|
49
|
-
<div aria-hidden="true" class="h-1 w-20 origin-left animate-uv-grow-x rounded-full bg-primary shadow-glow" :style="{ animationDelay: descriptionDelay }" />
|
|
50
|
-
|
|
51
122
|
<!-- Description -->
|
|
52
|
-
<p class="max-w-
|
|
123
|
+
<p class="max-w-2xl animate-uv-fade-up text-sm leading-7 text-muted-foreground sm:text-base" :style="{ animationDelay: descriptionDelay }">
|
|
53
124
|
{{ props.description }}
|
|
54
125
|
</p>
|
|
126
|
+
|
|
127
|
+
<!-- Stats strip -->
|
|
128
|
+
<StatStrip :items="props.stats" :class="cn('animate-uv-fade-up', alignClass.row)" :style="{ animationDelay: statsDelay }" />
|
|
55
129
|
</header>
|
|
56
|
-
</template>
|
|
130
|
+
</template>
|
|
@@ -4,7 +4,7 @@ import { computed } from 'vue';
|
|
|
4
4
|
import { useI18n } from 'vue-i18n';
|
|
5
5
|
import { ArrowUpRight01Icon, Calendar03Icon, RefreshIcon } from '@hugeicons/core-free-icons';
|
|
6
6
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
7
|
-
import { Card
|
|
7
|
+
import { Card } from '../ui/card';
|
|
8
8
|
import { formatDateLongItalyTimezone } from '../../lib/utils';
|
|
9
9
|
|
|
10
10
|
// Props
|
|
@@ -12,9 +12,11 @@ const props = withDefaults(defineProps<{
|
|
|
12
12
|
author: string;
|
|
13
13
|
authorUrl: string;
|
|
14
14
|
authorImageUrl: string;
|
|
15
|
+
authorBio?: string;
|
|
15
16
|
datePublished: string;
|
|
16
17
|
dateModified?: string | null;
|
|
17
18
|
}>(), {
|
|
19
|
+
authorBio: '',
|
|
18
20
|
dateModified: null
|
|
19
21
|
});
|
|
20
22
|
|
|
@@ -30,44 +32,45 @@ const showModifiedDate = computed(() => Boolean(props.dateModified) && formatted
|
|
|
30
32
|
|
|
31
33
|
<template>
|
|
32
34
|
<!-- Author and date information -->
|
|
33
|
-
<Card data-aos="blur-up" class="
|
|
34
|
-
<!--
|
|
35
|
-
<div
|
|
35
|
+
<Card data-aos="blur-up" class="gap-0! overflow-hidden p-0!">
|
|
36
|
+
<!-- Author -->
|
|
37
|
+
<div :class="['flex gap-3 px-5 py-4', props.authorBio ? 'items-start' : 'items-center']">
|
|
38
|
+
<!-- Author image -->
|
|
39
|
+
<NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="shrink-0 rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
|
|
40
|
+
<NuxtImg :src="props.authorImageUrl" :alt="props.author" height="44" width="44" format="avif" quality="40" :sizes="{ 480: '44px', 1280: '44px' }" loading="lazy" decoding="async" class="size-11 rounded border border-border object-cover" />
|
|
41
|
+
</NuxtLink>
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<
|
|
43
|
+
<!-- Author name and bio -->
|
|
44
|
+
<div class="min-w-0 flex-1">
|
|
45
|
+
<p class="text-[11px] font-semibold tracking-wider text-muted-foreground uppercase">{{ t('uiVintage.blog.author') }}</p>
|
|
46
|
+
<NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="group/link inline-flex items-center gap-1 text-sm font-semibold text-foreground outline-none transition-colors hover:text-primary focus-visible:underline md:text-base">
|
|
47
|
+
{{ props.author }}
|
|
48
|
+
<HugeiconsIcon :icon="ArrowUpRight01Icon" class="size-4 text-muted-foreground transition-[translate,color] duration-300 ease-spring group-hover/link:translate-x-0.5 group-hover/link:-translate-y-0.5 group-hover/link:text-primary" />
|
|
43
49
|
</NuxtLink>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
<p class="text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase">{{ t('uiVintage.blog.author') }}</p>
|
|
48
|
-
<NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="group/link mt-0.5 inline-flex items-center gap-1 text-base font-bold text-foreground outline-none hover:text-primary focus-visible:underline">
|
|
49
|
-
{{ props.author }}
|
|
50
|
-
<HugeiconsIcon :icon="ArrowUpRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/link:translate-x-0.5 group-hover/link:-translate-y-0.5" />
|
|
51
|
-
</NuxtLink>
|
|
52
|
-
</div>
|
|
50
|
+
<p v-if="props.authorBio" class="mt-1.5 text-xs leading-relaxed text-muted-foreground md:text-sm">
|
|
51
|
+
{{ props.authorBio }}
|
|
52
|
+
</p>
|
|
53
53
|
</div>
|
|
54
|
+
</div>
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
<!-- Date information -->
|
|
57
|
+
<div v-if="props.datePublished || showModifiedDate" class="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-border bg-surface px-5 py-2.5 text-xs text-muted-foreground">
|
|
58
|
+
<!-- Published date -->
|
|
59
|
+
<span v-if="props.datePublished" class="inline-flex items-center gap-2">
|
|
60
|
+
<HugeiconsIcon :icon="Calendar03Icon" class="size-3.5 shrink-0 text-primary" />
|
|
61
|
+
{{ t('uiVintage.blog.publishedDate') }}
|
|
62
|
+
<time :datetime="props.datePublished" class="text-foreground">{{ formattedPublishedDate }}</time>
|
|
63
|
+
</span>
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
65
|
+
<!-- Divider between the dates (hidden when they wrap on small screens) -->
|
|
66
|
+
<span v-if="props.datePublished && showModifiedDate" aria-hidden="true" class="hidden h-3.5 w-px bg-border sm:block" />
|
|
67
|
+
|
|
68
|
+
<!-- Last updated date -->
|
|
69
|
+
<span v-if="showModifiedDate" class="inline-flex items-center gap-2">
|
|
70
|
+
<HugeiconsIcon :icon="RefreshIcon" class="size-3.5 shrink-0 text-primary" />
|
|
71
|
+
{{ t('uiVintage.blog.lastUpdatedDate') }}
|
|
72
|
+
<time :datetime="props.dateModified ?? undefined" class="text-foreground">{{ formattedModifiedDate }}</time>
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
72
75
|
</Card>
|
|
73
|
-
</template>
|
|
76
|
+
</template>
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { NuxtImg } from '#components';
|
|
3
|
+
import { computed } from 'vue';
|
|
4
|
+
import { useI18n } from 'vue-i18n';
|
|
5
|
+
import { Badge } from '../ui/badge';
|
|
6
|
+
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '../ui/breadcrumb';
|
|
7
|
+
import { formatDateLongItalyTimezone, getReadingMinutes } from '../../lib/utils';
|
|
8
|
+
|
|
9
|
+
// Props
|
|
10
|
+
const props = withDefaults(defineProps<{
|
|
11
|
+
title: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
image?: string;
|
|
14
|
+
author?: string;
|
|
15
|
+
authorImageUrl?: string;
|
|
16
|
+
datePublished?: string;
|
|
17
|
+
categoryText?: string;
|
|
18
|
+
categorySlug?: string;
|
|
19
|
+
tags?: Array<{
|
|
20
|
+
name: string;
|
|
21
|
+
slug: string;
|
|
22
|
+
}>;
|
|
23
|
+
body?: unknown;
|
|
24
|
+
readingMinutes?: number | null;
|
|
25
|
+
blogPath?: string;
|
|
26
|
+
blogLabel?: string;
|
|
27
|
+
animated?: boolean;
|
|
28
|
+
}>(), {
|
|
29
|
+
description: '',
|
|
30
|
+
image: '',
|
|
31
|
+
author: '',
|
|
32
|
+
authorImageUrl: '',
|
|
33
|
+
datePublished: '',
|
|
34
|
+
categoryText: '',
|
|
35
|
+
categorySlug: '',
|
|
36
|
+
tags: () => [],
|
|
37
|
+
readingMinutes: null,
|
|
38
|
+
blogPath: '/blog',
|
|
39
|
+
blogLabel: '',
|
|
40
|
+
animated: true
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const { t, locale } = useI18n();
|
|
44
|
+
|
|
45
|
+
// Split the title so each word can rise into place on its own
|
|
46
|
+
const titleWords = computed(() => props.title.split(/\s+/).filter(Boolean));
|
|
47
|
+
|
|
48
|
+
// Long publication date in the current locale
|
|
49
|
+
const formattedDate = computed(() => formatDateLongItalyTimezone(props.datePublished, locale.value));
|
|
50
|
+
|
|
51
|
+
// Explicit reading time wins, otherwise estimate it from the body
|
|
52
|
+
const resolvedReadingMinutes = computed(() => {
|
|
53
|
+
if (props.readingMinutes) return props.readingMinutes;
|
|
54
|
+
return props.body ? getReadingMinutes(props.body) : null;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Links built from the blog base path
|
|
58
|
+
const categoryPath = computed(() => `${props.blogPath}/categories/${props.categorySlug}`);
|
|
59
|
+
const tagPath = (slug: string) => `${props.blogPath}/tags/${slug}`;
|
|
60
|
+
|
|
61
|
+
// Keep only the entrance classes when animations are enabled
|
|
62
|
+
const enter = (classes: string) => props.animated ? classes : '';
|
|
63
|
+
|
|
64
|
+
// Whether the category link can be built
|
|
65
|
+
const hasCategory = computed(() => Boolean(props.categoryText && props.categorySlug));
|
|
66
|
+
|
|
67
|
+
// Whether the details row has any column to show
|
|
68
|
+
const hasDetails = computed(() => Boolean(props.author || formattedDate.value || resolvedReadingMinutes.value || hasCategory.value));
|
|
69
|
+
|
|
70
|
+
const badgeLinkClasses = 'rounded outline-none transition-opacity duration-150 hover:opacity-80 focus-visible:ring-[3px] focus-visible:ring-ring/45';
|
|
71
|
+
|
|
72
|
+
// Shared classes for the details cells (exposed to the meta slot)
|
|
73
|
+
const detailCellClasses = 'min-w-0 border-t border-l border-border py-4 pl-4 pr-4';
|
|
74
|
+
const detailLabelClasses = 'text-[11px] font-semibold tracking-wider text-muted-foreground uppercase';
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<header class="relative isolate mb-10 md:mb-14">
|
|
79
|
+
<!-- Breadcrumbs -->
|
|
80
|
+
<Breadcrumb :class="enter('animate-uv-fade-up')">
|
|
81
|
+
<BreadcrumbList>
|
|
82
|
+
<BreadcrumbItem>
|
|
83
|
+
<BreadcrumbLink as-child>
|
|
84
|
+
<NuxtLinkLocale :to="props.blogPath">
|
|
85
|
+
{{ props.blogLabel || t('uiVintage.blog.breadcrumbBlog') }}
|
|
86
|
+
</NuxtLinkLocale>
|
|
87
|
+
</BreadcrumbLink>
|
|
88
|
+
</BreadcrumbItem>
|
|
89
|
+
<BreadcrumbSeparator />
|
|
90
|
+
<template v-if="hasCategory">
|
|
91
|
+
<BreadcrumbItem>
|
|
92
|
+
<BreadcrumbLink as-child>
|
|
93
|
+
<NuxtLinkLocale :to="categoryPath">
|
|
94
|
+
{{ props.categoryText }}
|
|
95
|
+
</NuxtLinkLocale>
|
|
96
|
+
</BreadcrumbLink>
|
|
97
|
+
</BreadcrumbItem>
|
|
98
|
+
<BreadcrumbSeparator />
|
|
99
|
+
</template>
|
|
100
|
+
<BreadcrumbItem class="min-w-0">
|
|
101
|
+
<BreadcrumbPage class="truncate">
|
|
102
|
+
{{ props.title }}
|
|
103
|
+
</BreadcrumbPage>
|
|
104
|
+
</BreadcrumbItem>
|
|
105
|
+
</BreadcrumbList>
|
|
106
|
+
</Breadcrumb>
|
|
107
|
+
|
|
108
|
+
<!-- Title and lede, closed by a rule with a primary segment -->
|
|
109
|
+
<div class="relative mt-8 border-b border-border pb-8">
|
|
110
|
+
<span aria-hidden="true" :class="['absolute -bottom-px left-0 h-0.5 w-20 origin-left bg-primary', enter('animate-uv-grow-x')]" :style="props.animated ? { animationDelay: '500ms' } : undefined" />
|
|
111
|
+
|
|
112
|
+
<!-- Title (each word rises in) -->
|
|
113
|
+
<h1 class="text-3xl leading-[1.08] font-semibold tracking-tight text-foreground sm:text-4xl md:text-5xl">
|
|
114
|
+
<template v-for="(word, index) in titleWords" :key="`${word}-${index}`">
|
|
115
|
+
<span :class="['inline-block', enter('animate-uv-word-in')]" :style="props.animated ? { animationDelay: `${120 + Math.min(index, 12) * 50}ms` } : undefined">{{ word }}</span>{{ index < titleWords.length - 1 ? ' ' : '' }}
|
|
116
|
+
</template>
|
|
117
|
+
</h1>
|
|
118
|
+
|
|
119
|
+
<!-- Lede -->
|
|
120
|
+
<p v-if="props.description" :class="['mt-5 max-w-3xl text-sm leading-relaxed text-muted-foreground md:text-lg', enter('animate-uv-fade-up [animation-delay:450ms]')]">
|
|
121
|
+
{{ props.description }}
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<!-- Labelled details (columns without data are left out; the meta slot adds more) -->
|
|
126
|
+
<div v-if="hasDetails || $slots.meta" :class="['overflow-hidden border-b border-border', enter('animate-uv-fade-up [animation-delay:550ms]')]">
|
|
127
|
+
<dl class="-mt-px -ml-[calc(1rem+1px)] grid grid-cols-2 md:grid-flow-col md:auto-cols-fr md:grid-cols-none">
|
|
128
|
+
<!-- Author -->
|
|
129
|
+
<div v-if="props.author" :class="detailCellClasses">
|
|
130
|
+
<dt :class="detailLabelClasses">{{ t('uiVintage.blog.author') }}</dt>
|
|
131
|
+
<dd class="mt-2 flex min-w-0 items-center gap-2 text-sm font-semibold text-foreground">
|
|
132
|
+
<NuxtImg v-if="props.authorImageUrl" :src="props.authorImageUrl" :alt="props.author" width="24" height="24" format="avif" quality="40" class="size-6 shrink-0 rounded border border-border object-cover" />
|
|
133
|
+
<span class="truncate">{{ props.author }}</span>
|
|
134
|
+
</dd>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<!-- Publication date -->
|
|
138
|
+
<div v-if="formattedDate" :class="detailCellClasses">
|
|
139
|
+
<dt :class="detailLabelClasses">{{ t('uiVintage.blog.publishedDate') }}</dt>
|
|
140
|
+
<dd class="mt-2 text-sm text-foreground">
|
|
141
|
+
<time :datetime="props.datePublished">{{ formattedDate }}</time>
|
|
142
|
+
</dd>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<!-- Reading time -->
|
|
146
|
+
<div v-if="resolvedReadingMinutes" :class="detailCellClasses">
|
|
147
|
+
<dt :class="detailLabelClasses">{{ t('uiVintage.blog.readingTime') }}</dt>
|
|
148
|
+
<dd class="mt-2 text-sm text-foreground">{{ t('uiVintage.blog.readingTimeValue', { minutes: resolvedReadingMinutes }) }}</dd>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<!-- Category -->
|
|
152
|
+
<div v-if="hasCategory" :class="detailCellClasses">
|
|
153
|
+
<dt :class="detailLabelClasses">{{ t('uiVintage.blog.category') }}</dt>
|
|
154
|
+
<dd class="mt-1.5">
|
|
155
|
+
<NuxtLinkLocale :to="categoryPath" :class="['inline-flex', badgeLinkClasses]">
|
|
156
|
+
<Badge color="yellow" :text="props.categoryText" pulse />
|
|
157
|
+
</NuxtLinkLocale>
|
|
158
|
+
</dd>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<!-- Extra columns from the app (use the same cell structure: a div with a dt and a dd) -->
|
|
162
|
+
<slot name="meta" :cell-classes="detailCellClasses" :label-classes="detailLabelClasses" />
|
|
163
|
+
</dl>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<!-- Tags -->
|
|
167
|
+
<div v-if="props.tags.length > 0" :class="['mt-4 flex flex-wrap items-center gap-2', enter('animate-uv-fade-up [animation-delay:600ms]')]">
|
|
168
|
+
<NuxtLinkLocale v-for="tag in props.tags" :key="tag.slug" :to="tagPath(tag.slug)" :class="badgeLinkClasses">
|
|
169
|
+
<Badge :text="`#${tag.name}`" />
|
|
170
|
+
</NuxtLinkLocale>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<!-- Featured image (replaceable through the media slot) -->
|
|
174
|
+
<slot name="media">
|
|
175
|
+
<div v-if="props.image" :class="['relative mt-8 aspect-video w-full overflow-hidden rounded border border-border shadow-elevated-xl', enter('animate-uv-fade-up [animation-delay:650ms]')]">
|
|
176
|
+
<NuxtImg :src="props.image" :alt="props.title" format="avif" quality="50" :sizes="{ 480: '480px', 1536: '896px' }" loading="eager" fetchpriority="high" preload :class="['size-full object-cover', enter('animate-uv-ken-burns')]" />
|
|
177
|
+
<div aria-hidden="true" class="pointer-events-none absolute inset-0 bg-linear-to-t from-black/20 to-transparent" />
|
|
178
|
+
</div>
|
|
179
|
+
</slot>
|
|
180
|
+
</header>
|
|
181
|
+
</template>
|
|
@@ -43,16 +43,14 @@ onUnmounted(() => {
|
|
|
43
43
|
</script>
|
|
44
44
|
|
|
45
45
|
<template>
|
|
46
|
-
<div :class="cn('
|
|
46
|
+
<div :class="cn('h-0.5 w-full bg-border/60 landing-navbar-progress', props.class)"
|
|
47
47
|
role="progressbar"
|
|
48
48
|
:aria-label="resolvedAriaLabel"
|
|
49
49
|
aria-valuemin="0"
|
|
50
50
|
aria-valuemax="100"
|
|
51
51
|
:aria-valuenow="Math.round(progress)">
|
|
52
|
-
<!--
|
|
53
|
-
<div class="
|
|
54
|
-
<span v-if="progress > 0" aria-hidden="true" class="absolute top-1/2 right-0 size-2 -translate-y-1/2 rounded-full bg-primary shadow-[0_0_10px_2px_color-mix(in_oklab,var(--primary)_70%,transparent)]" />
|
|
55
|
-
</div>
|
|
52
|
+
<!-- Solid fill -->
|
|
53
|
+
<div class="h-full bg-primary transition-[width] duration-150 ease-out" :style="{ width: `${progress}%` }" />
|
|
56
54
|
</div>
|
|
57
55
|
</template>
|
|
58
56
|
|
|
@@ -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>
|