@brumaombra/ui-vintage 0.8.1 → 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 +91 -17
- package/README.md +296 -262
- package/module.mjs +5 -11
- package/package.json +31 -13
- package/src/components/blog/AllPostsSection.vue +1 -2
- package/src/components/blog/BlogContentRenderer.vue +144 -49
- package/src/components/blog/BlogFAQSection.vue +13 -15
- package/src/components/blog/BlogHeaderSection.vue +103 -11
- package/src/components/blog/BlogInfoSection.vue +46 -42
- package/src/components/blog/BlogPostHeader.vue +181 -0
- package/src/components/blog/BlogProgressBar.vue +3 -2
- package/src/components/blog/BlogSectionTitle.vue +27 -6
- package/src/components/blog/BlogTagsSection.vue +20 -8
- package/src/components/blog/CategoriesList.vue +2 -2
- package/src/components/blog/CategoryCard.vue +23 -19
- package/src/components/blog/HeaderCarousel.vue +117 -145
- package/src/components/blog/PostCard.vue +25 -33
- package/src/components/blog/PostsList.vue +3 -2
- package/src/components/blog/SocialShareSidebar.vue +62 -42
- package/src/components/blog/TableOfContents.vue +71 -34
- 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 +30 -42
- package/src/components/content/BlogTable.vue +26 -30
- package/src/components/content/CodeBlock.vue +96 -22
- package/src/components/content/Flow.vue +30 -36
- package/src/components/content/ProseHr.vue +6 -6
- package/src/components/content/ProsePre.vue +9 -4
- package/src/components/content/Terminal.vue +42 -14
- 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 -6
- package/src/components/landing/landing-navbar/LandingNavbar.vue +63 -51
- 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/breadcrumb/BreadcrumbLink.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbList.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbPage.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +2 -6
- package/src/components/ui/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/CommandItem.vue +2 -2
- 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 +19 -7
- package/src/i18n/en.json +19 -7
- package/src/i18n/es.json +19 -7
- package/src/i18n/fr.json +19 -7
- package/src/i18n/it.json +19 -7
- package/src/i18n/ja.json +19 -7
- package/src/i18n/pt.json +19 -7
- package/src/i18n/ru.json +19 -7
- package/src/i18n/zh.json +19 -7
- 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 +52 -16
- package/skills/ui-vintage/SKILL.md +0 -42
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
3
|
import { computed } from 'vue';
|
|
4
4
|
import { useI18n } from 'vue-i18n';
|
|
5
|
-
import {
|
|
5
|
+
import { ArrowUpRight01Icon, Calendar03Icon, RefreshIcon } from '@hugeicons/core-free-icons';
|
|
6
|
+
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
7
|
+
import { Card } from '../ui/card';
|
|
6
8
|
import { formatDateLongItalyTimezone } from '../../lib/utils';
|
|
7
9
|
|
|
8
10
|
// Props
|
|
@@ -10,9 +12,11 @@ const props = withDefaults(defineProps<{
|
|
|
10
12
|
author: string;
|
|
11
13
|
authorUrl: string;
|
|
12
14
|
authorImageUrl: string;
|
|
15
|
+
authorBio?: string;
|
|
13
16
|
datePublished: string;
|
|
14
17
|
dateModified?: string | null;
|
|
15
18
|
}>(), {
|
|
19
|
+
authorBio: '',
|
|
16
20
|
dateModified: null
|
|
17
21
|
});
|
|
18
22
|
|
|
@@ -21,52 +25,52 @@ const { t, locale } = useI18n();
|
|
|
21
25
|
// Formatted dates
|
|
22
26
|
const formattedPublishedDate = computed(() => formatDateLongItalyTimezone(props.datePublished, locale.value));
|
|
23
27
|
const formattedModifiedDate = computed(() => formatDateLongItalyTimezone(props.dateModified, locale.value));
|
|
28
|
+
|
|
29
|
+
// Only show the update date when it differs from the publication date
|
|
30
|
+
const showModifiedDate = computed(() => Boolean(props.dateModified) && formattedModifiedDate.value !== formattedPublishedDate.value);
|
|
24
31
|
</script>
|
|
25
32
|
|
|
26
33
|
<template>
|
|
27
34
|
<!-- Author and date information -->
|
|
28
|
-
<Card data-aos="
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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>
|
|
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" />
|
|
49
|
+
</NuxtLink>
|
|
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
|
+
</div>
|
|
54
|
+
</div>
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
</div>
|
|
48
|
-
</div>
|
|
49
|
-
</div>
|
|
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>
|
|
50
64
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<!-- Published date -->
|
|
54
|
-
<div v-if="props.datePublished" class="mb-1">
|
|
55
|
-
<span class="font-semibold">{{ t('uiVintage.blog.publishedDate') }}:</span>
|
|
56
|
-
<time :datetime="props.datePublished" class="ml-1">
|
|
57
|
-
{{ formattedPublishedDate }}
|
|
58
|
-
</time>
|
|
59
|
-
</div>
|
|
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" />
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
</div>
|
|
69
|
-
</div>
|
|
70
|
-
</CardContent>
|
|
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>
|
|
71
75
|
</Card>
|
|
72
|
-
</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,13 +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
|
-
|
|
52
|
+
<!-- Solid fill -->
|
|
53
|
+
<div class="h-full bg-primary transition-[width] duration-150 ease-out" :style="{ width: `${progress}%` }" />
|
|
53
54
|
</div>
|
|
54
55
|
</template>
|
|
55
56
|
|
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// Props
|
|
3
|
-
const props = defineProps<{
|
|
3
|
+
const props = withDefaults(defineProps<{
|
|
4
4
|
title: string;
|
|
5
|
-
|
|
5
|
+
description?: string;
|
|
6
|
+
}>(), {
|
|
7
|
+
description: ''
|
|
8
|
+
});
|
|
6
9
|
</script>
|
|
7
10
|
|
|
8
11
|
<template>
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
|
|
16
|
+
<div class="min-w-0">
|
|
17
|
+
<!-- Title -->
|
|
18
|
+
<h2 class="text-xl font-semibold tracking-tight text-foreground md:text-2xl">
|
|
19
|
+
{{ props.title }}
|
|
20
|
+
</h2>
|
|
21
|
+
|
|
22
|
+
<!-- Description -->
|
|
23
|
+
<p v-if="props.description" class="mt-1 max-w-2xl text-xs leading-6 text-muted-foreground md:text-sm">
|
|
24
|
+
{{ props.description }}
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<!-- Optional trailing action (for example a "View all" link) -->
|
|
29
|
+
<div v-if="$slots.action" class="shrink-0">
|
|
30
|
+
<slot name="action" />
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { Badge } from '../ui/badge';
|
|
3
|
-
|
|
4
2
|
// Props
|
|
5
3
|
const props = withDefaults(defineProps<{
|
|
6
4
|
tags?: Array<{
|
|
@@ -8,15 +6,29 @@ const props = withDefaults(defineProps<{
|
|
|
8
6
|
slug: string;
|
|
9
7
|
count: number;
|
|
10
8
|
}>;
|
|
9
|
+
blogPath?: string;
|
|
11
10
|
}>(), {
|
|
12
|
-
tags: () => []
|
|
11
|
+
tags: () => [],
|
|
12
|
+
blogPath: '/blog'
|
|
13
13
|
});
|
|
14
14
|
</script>
|
|
15
15
|
|
|
16
16
|
<template>
|
|
17
|
-
<div v-if="props.tags.length" class="flex flex-wrap gap-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
<div v-if="props.tags.length" class="flex flex-wrap gap-2">
|
|
18
|
+
<!-- Tag chip -->
|
|
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">
|
|
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>
|
|
31
|
+
</NuxtLinkLocale>
|
|
32
|
+
</span>
|
|
21
33
|
</div>
|
|
22
|
-
</template>
|
|
34
|
+
</template>
|
|
@@ -16,9 +16,9 @@ const props = withDefaults(defineProps<{
|
|
|
16
16
|
</script>
|
|
17
17
|
|
|
18
18
|
<template>
|
|
19
|
-
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4
|
|
19
|
+
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 md:gap-6 lg:grid-cols-3 xl:grid-cols-4">
|
|
20
20
|
<!-- Category card -->
|
|
21
|
-
<NuxtLink v-for="(category,
|
|
21
|
+
<NuxtLink v-for="(category, index) in props.categories" :key="category.path || category.slug" data-aos="blur-up" :data-aos-delay="(index % 4) * 80" :to="category.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
|
|
22
22
|
<CategoryCard :name="category.name" :image="category.image" :count="category.count" />
|
|
23
23
|
</NuxtLink>
|
|
24
24
|
</div>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { ArrowRight01Icon, Folder01Icon } from '@hugeicons/core-free-icons';
|
|
4
|
+
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
5
|
+
import { Card } from '../ui/card';
|
|
5
6
|
|
|
6
7
|
// Props
|
|
7
8
|
const props = withDefaults(defineProps<{
|
|
@@ -14,23 +15,26 @@ const props = withDefaults(defineProps<{
|
|
|
14
15
|
</script>
|
|
15
16
|
|
|
16
17
|
<template>
|
|
17
|
-
<Card class="
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
<
|
|
21
|
-
<NuxtImg :src="props.image" :alt="props.name" height="225" width="400" format="avif" quality="35" :sizes="{ 480: '480px', 1280: '400px' }" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
|
|
22
|
-
</div>
|
|
23
|
-
|
|
24
|
-
<!-- Content -->
|
|
25
|
-
<div class="p-4 flex items-center justify-between">
|
|
26
|
-
<!-- Name -->
|
|
27
|
-
<span class="text-sm font-bold text-foreground truncate mr-3">
|
|
28
|
-
{{ props.name }}
|
|
29
|
-
</span>
|
|
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]" />
|
|
30
22
|
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
<!-- Fallback when there is no image -->
|
|
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" />
|
|
33
26
|
</div>
|
|
34
|
-
</
|
|
27
|
+
</div>
|
|
28
|
+
|
|
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 }}
|
|
33
|
+
</span>
|
|
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" />
|
|
38
|
+
</div>
|
|
35
39
|
</Card>
|
|
36
|
-
</template>
|
|
40
|
+
</template>
|