refactor: ♻️ More work on rewriting notes

This commit is contained in:
Jesse Wierzbinski 2024-11-30 16:21:16 +01:00
parent d29f181000
commit 8cc4ff1348
No known key found for this signature in database
20 changed files with 514 additions and 63 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -1,12 +1,12 @@
<template> <template>
<svg class="absolute inset-0 h-full w-full stroke-white/10 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]" <svg class="absolute inset-0 h-full w-full stroke-primary/[0.07] [mask-image:radial-gradient(100%_100%_at_top_right,hsl(var(--primary-foreground)),transparent)] pointer-events-none"
aria-hidden="true"> aria-hidden="true">
<defs> <defs>
<pattern id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc" width="200" height="200" x="50%" y="-1" <pattern id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc" width="200" height="200" x="50%" y="-1"
patternUnits="userSpaceOnUse"> patternUnits="userSpaceOnUse">
<path d="M.5 200V.5H200" fill="none"></path> <path d="M.5 200V.5H200" fill="none"></path>
</pattern> </pattern>
</defs><svg x="50%" y="-1" class="overflow-visible fill-gray-800/20"> </defs><svg x="50%" y="-1" class="overflow-visible fill-primary/[0.03]">
<path d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z" <path d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
stroke-width="0"></path> stroke-width="0"></path>
</svg> </svg>

View file

@ -0,0 +1,45 @@
<template>
<div class="flex flex-row w-full items-stretch justify-around text-sm *:max-w-28 *:w-full *:text-muted-foreground">
<Button variant="ghost">
<Reply class="size-5 text-primary" />
{{ numberFormat(replyCount) }}
</Button>
<Button variant="ghost">
<Heart class="size-5 text-primary" />
{{ numberFormat(likeCount) }}
</Button>
<Button variant="ghost">
<Repeat class="size-5 text-primary" />
{{ numberFormat(reblogCount) }}
</Button>
<Button variant="ghost">
<Quote class="size-5 text-primary" />
</Button>
<Menu>
<Button variant="ghost">
<Ellipsis class="size-5 text-primary" />
</Button>
</Menu>
</div>
</template>
<script lang="ts" setup>
import { Ellipsis, Heart, Quote, Repeat, Reply } from "lucide-vue-next";
import { Button } from "~/components/ui/button";
import Menu from "./menu.vue";
defineProps<{
replyCount: number;
likeCount: number;
reblogCount: number;
}>();
const numberFormat = (number = 0) =>
number !== 0
? new Intl.NumberFormat(undefined, {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 1,
}).format(number)
: undefined;
</script>

View file

@ -1,5 +1,5 @@
<template> <template>
<div :class="['prose block relative dark:prose-invert duration-200 !max-w-full break-words', $style.content]" v-html="content"> <div :class="['prose block relative dark:prose-invert duration-200 !max-w-full break-words prose-a:no-underline prose-a:hover:underline', $style.content]" v-html="content">
</div> </div>
</template> </template>

View file

@ -1,23 +1,29 @@
<template> <template>
<div class="rounded flex flex-row gap-4"> <div class="rounded flex flex-row gap-3">
<Avatar class="size-14 rounded border border-card"> <div class="relative">
<AvatarImage :src="avatar" alt="" /> <Avatar class="size-14 rounded-md border border-card">
<AvatarFallback class="rounded-lg"> AA </AvatarFallback> <AvatarImage :src="avatar" alt="" />
</Avatar> <AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar>
<Avatar v-if="cornerAvatar" class="size-6 rounded border absolute -bottom-1 -right-1">
<AvatarImage :src="cornerAvatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar>
</div>
<div class="flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight"> <div class="flex flex-col gap-0.5 justify-center flex-1 text-left leading-tight">
<span class="truncate font-semibold">{{ <span class="truncate font-semibold">{{
displayName displayName
}}</span> }}</span>
<span class="truncate text-sm"> <span class="truncate text-sm tracking-tight">
<CopyableText :text="acct"> <CopyableText :text="acct">
<span <span
class="font-semibold bg-gradient-to-tr from-pink-300 via-purple-300 to-indigo-400 text-transparent bg-clip-text"> class="font-semibold bg-gradient-to-tr from-pink-700 dark:from-indigo-400 via-purple-700 dark:via-purple-400 to-indigo-700 dark:to-indigo-400 text-transparent bg-clip-text">
@{{ username }} @{{ username }}
</span> </span>
<span class="text-muted-foreground">{{ instance && "@" }}{{ instance }}</span> <span class="text-muted-foreground">{{ instance && "@" }}{{ instance }}</span>
</CopyableText> </CopyableText>
&middot; &middot;
<span class="text-muted-foreground ml-auto" :title="fullTime">{{ timeAgo }}</span> <span class="text-muted-foreground ml-auto tracking-normal" :title="fullTime">{{ timeAgo }}</span>
</span> </span>
</div> </div>
<div class="flex flex-col gap-1 justify-center items-end"> <div class="flex flex-col gap-1 justify-center items-end">
@ -35,6 +41,7 @@ import CopyableText from "./copyable-text.vue";
const { acct, createdAt } = defineProps<{ const { acct, createdAt } = defineProps<{
avatar: string; avatar: string;
cornerAvatar?: string;
acct: string; acct: string;
displayName: string; displayName: string;
visibility: StatusVisibility; visibility: StatusVisibility;

84
components/notes/menu.vue Normal file
View file

@ -0,0 +1,84 @@
<script setup lang="ts">
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Ban,
Code,
Delete,
ExternalLink,
Link,
MessageSquare,
Pencil,
Trash,
} from "lucide-vue-next";
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<slot />
</DropdownMenuTrigger>
<DropdownMenuContent class="w-56">
<DropdownMenuLabel>Note Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Pencil class="mr-2 size-4" />
<span>Edit</span>
<DropdownMenuShortcut>E</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<Code class="mr-2 size-4" />
<span>Copy API data</span>
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<Link class="mr-2 size-4" />
<span>Copy link</span>
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<Link class="mr-2 size-4" />
<span>Copy link (origin)</span>
<DropdownMenuShortcut>K</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<ExternalLink class="mr-2 size-4" />
<span>Open on remote</span>
<DropdownMenuShortcut>F</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Delete class="mr-2 size-4" />
<span>Delete and redraft</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Trash class="mr-2 size-4" />
<span>Delete</span>
<DropdownMenuShortcut>D</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<MessageSquare class="mr-2 size-4" />
<span>Report</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Ban class="mr-2 size-4" />
<span>Block user</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</template>

View file

@ -1,26 +1,38 @@
<template> <template>
<Card as="article" class="rounded-none border-0 hover:bg-muted/50 duration-200"> <Card as="article" class="rounded-none border-0 duration-200 shadow-none">
<CardHeader class="pb-4"> <CardHeader class="pb-4">
<Header :avatar="note.account.avatar" :acct="note.account.acct" :display-name="note.account.display_name" <ReblogHeader v-if="!!note.reblog" :avatar="note.account.avatar"
:visibility="note.visibility" :url="accountUrl" :created-at="new Date(note.created_at)" /> :display-name="note.account.display_name" />
<Header :avatar="noteToUse.account.avatar" :corner-avatar="note.reblog ? note.account.avatar : undefined"
:acct="noteToUse.account.acct" :display-name="noteToUse.account.display_name"
:visibility="noteToUse.visibility" :url="accountUrl" :created-at="new Date(noteToUse.created_at)" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Content :content="note.content" /> <Content :content="noteToUse.content" />
</CardContent> </CardContent>
<CardFooter>
<Actions :reply-count="noteToUse.replies_count" :like-count="noteToUse.favourites_count"
:reblog-count="noteToUse.reblogs_count" />
</CardFooter>
</Card> </Card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Status } from "@versia/client/types"; import type { Status } from "@versia/client/types";
import { Card, CardHeader } from "../ui/card"; import { Card, CardFooter, CardHeader } from "../ui/card";
import { Separator } from "../ui/separator"; import Actions from "./actions.vue";
import Content from "./content.vue"; import Content from "./content.vue";
import Header from "./header.vue"; import Header from "./header.vue";
import ReblogHeader from "./reblog-header.vue";
const { note } = defineProps<{ const { note } = defineProps<{
note: Status; note: Status;
}>(); }>();
const url = `/@${note.account.acct}/${note.id}`; // Notes can be reblogs or quotes, in which case
const accountUrl = `/@${note.account.acct}`; // the actual thing to render is inside the reblog or quote
const noteToUse = note.reblog ? note.reblog : note.quote ? note.quote : note;
const url = `/@${noteToUse.account.acct}/${noteToUse.id}`;
const accountUrl = `/@${noteToUse.account.acct}`;
</script> </script>

View file

@ -0,0 +1,21 @@
<template>
<div class="rounded border hover:bg-muted duration-100 text-sm flex flex-row items-center gap-2 px-2 py-1 mb-2">
<Repeat class="size-4 text-primary" />
<Avatar class="size-6 rounded border">
<AvatarImage :src="avatar" alt="" />
<AvatarFallback class="rounded-lg"> AA </AvatarFallback>
</Avatar>
<span class="font-semibold">{{ displayName }}</span>
reblogged
</div>
</template>
<script lang="ts" setup>
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Repeat } from "lucide-vue-next";
defineProps<{
avatar: string;
displayName: string;
}>();
</script>

View file

@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
BadgeCheck, BadgeCheck,
BedSingle,
Bell, Bell,
ChevronRight, ChevronRight,
ChevronsUpDown, ChevronsUpDown,
Globe,
House, House,
LogOut, LogOut,
MoreHorizontal, MapIcon,
Settings2, Settings2,
} from "lucide-vue-next"; } from "lucide-vue-next";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
@ -55,30 +57,6 @@ import ThemeSwitcher from "./theme-switcher.vue";
const data = { const data = {
navMain: [ navMain: [
{
title: "Timelines",
url: "#",
icon: House,
isActive: true,
items: [
{
title: "Home",
url: "/home",
},
{
title: "Public",
url: "/public",
},
{
title: "Local",
url: "/local",
},
{
title: "Global",
url: "/global",
},
],
},
{ {
title: "Settings", title: "Settings",
url: "#", url: "#",
@ -104,6 +82,26 @@ const data = {
}, },
], ],
other: [ other: [
{
name: "Home",
url: "/home",
icon: House,
},
{
name: "Public",
url: "/public",
icon: MapIcon,
},
{
name: "Local",
url: "/local",
icon: BedSingle,
},
{
name: "Global",
url: "/global",
icon: Globe,
},
{ {
name: "Notifications", name: "Notifications",
url: "/notifications", url: "/notifications",
@ -140,8 +138,21 @@ const instance = useInstance();
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup class="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Navigation</SidebarGroupLabel> <SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in data.other" :key="item.name">
<SidebarMenuButton as-child>
<NuxtLink :href="item.url">
<component :is="item.icon" />
<span>{{ item.name }}</span>
</NuxtLink>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
<SidebarGroup class="mt-auto">
<SidebarGroupLabel>More</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
<Collapsible v-for="item in data.navMain" :key="item.title" as-child <Collapsible v-for="item in data.navMain" :key="item.title" as-child
:default-open="item.isActive" class="group/collapsible"> :default-open="item.isActive" class="group/collapsible">
@ -169,19 +180,6 @@ const instance = useInstance();
</Collapsible> </Collapsible>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
<SidebarGroup class="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Other</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in data.other" :key="item.name">
<SidebarMenuButton as-child>
<NuxtLink :href="item.url">
<component :is="item.icon" />
<span>{{ item.name }}</span>
</NuxtLink>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<SidebarMenu> <SidebarMenu>
@ -264,7 +262,7 @@ const instance = useInstance();
</Breadcrumb> </Breadcrumb>
</div> </div>
</header> </header>
<div class="flex flex-1 flex-col gap-4 md:p-1 overflow-auto"> <div class="flex flex-1 flex-col gap-4 md:p-1 overflow-auto *:z-10">
<slot /> <slot />
</div> </div>
</SidebarInset> </SidebarInset>

View file

@ -1,7 +1,7 @@
<!-- Timeline.vue --> <!-- Timeline.vue -->
<template> <template>
<div class="timeline rounded overflow-hidden ring-1 ring-ring/15"> <div class="timeline rounded overflow-hidden ring-1 ring-ring/10">
<TransitionGroup name="timeline-item" tag="div" class="timeline-items *:!border-b *:last:border-0"> <TransitionGroup name="timeline-item" tag="div" class="timeline-items *:!border-b-[0.5px] *:last:border-0">
<TimelineItem :type="type" v-for="item in items" :key="item.id" :item="item" @update="updateItem" <TimelineItem :type="type" v-for="item in items" :key="item.id" :item="item" @update="updateItem"
@delete="removeItem" /> @delete="removeItem" />
</TransitionGroup> </TransitionGroup>

View file

@ -0,0 +1,65 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type {
CarouselEmits,
CarouselProps,
WithClassAsProps,
} from "./interface";
import { useProvideCarousel } from "./useCarousel";
const props = withDefaults(defineProps<CarouselProps & WithClassAsProps>(), {
orientation: "horizontal",
});
const emits = defineEmits<CarouselEmits>();
const {
canScrollNext,
canScrollPrev,
carouselApi,
carouselRef,
orientation,
scrollNext,
scrollPrev,
} = useProvideCarousel(props, emits);
defineExpose({
canScrollNext,
canScrollPrev,
carouselApi,
carouselRef,
orientation,
scrollNext,
scrollPrev,
});
function onKeyDown(event: KeyboardEvent) {
const prevKey = props.orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
const nextKey =
props.orientation === "vertical" ? "ArrowDown" : "ArrowRight";
if (event.key === prevKey) {
event.preventDefault();
scrollPrev();
return;
}
if (event.key === nextKey) {
event.preventDefault();
scrollNext();
}
}
</script>
<template>
<div
:class="cn('relative', props.class)"
role="region"
aria-roledescription="carousel"
tabindex="0"
@keydown="onKeyDown"
>
<slot :can-scroll-next :can-scroll-prev :carousel-api :carousel-ref :orientation :scroll-next :scroll-prev />
</div>
</template>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { WithClassAsProps } from "./interface";
import { useCarousel } from "./useCarousel";
defineOptions({
inheritAttrs: false,
});
const props = defineProps<WithClassAsProps>();
const { carouselRef, orientation } = useCarousel();
</script>
<template>
<div ref="carouselRef" class="overflow-hidden">
<div
:class="
cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
props.class,
)"
v-bind="$attrs"
>
<slot />
</div>
</div>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { WithClassAsProps } from "./interface";
import { useCarousel } from "./useCarousel";
const props = defineProps<WithClassAsProps>();
const { orientation } = useCarousel();
</script>
<template>
<div
role="group"
aria-roledescription="slide"
:class="cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
props.class,
)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { ArrowRight } from "lucide-vue-next";
import { Button } from "~/components/ui/button";
import type { WithClassAsProps } from "./interface";
import { useCarousel } from "./useCarousel";
const props = defineProps<WithClassAsProps>();
const { orientation, canScrollNext, scrollNext } = useCarousel();
</script>
<template>
<Button
:disabled="!canScrollNext"
:class="cn(
'touch-manipulation absolute h-8 w-8 rounded-full p-0',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
props.class,
)"
variant="outline"
@click="scrollNext"
>
<slot>
<ArrowRight class="h-4 w-4 text-current" />
<span class="sr-only">Next Slide</span>
</slot>
</Button>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { ArrowLeft } from "lucide-vue-next";
import { Button } from "~/components/ui/button";
import type { WithClassAsProps } from "./interface";
import { useCarousel } from "./useCarousel";
const props = defineProps<WithClassAsProps>();
const { orientation, canScrollPrev, scrollPrev } = useCarousel();
</script>
<template>
<Button
:disabled="!canScrollPrev"
:class="cn(
'touch-manipulation absolute h-8 w-8 rounded-full p-0',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
props.class,
)"
variant="outline"
@click="scrollPrev"
>
<slot>
<ArrowLeft class="h-4 w-4 text-current" />
<span class="sr-only">Previous Slide</span>
</slot>
</Button>
</template>

View file

@ -0,0 +1,8 @@
export { default as Carousel } from "./Carousel.vue";
export { default as CarouselContent } from "./CarouselContent.vue";
export { default as CarouselItem } from "./CarouselItem.vue";
export { default as CarouselNext } from "./CarouselNext.vue";
export { default as CarouselPrevious } from "./CarouselPrevious.vue";
export type { UnwrapRefCarouselApi as CarouselApi } from "./interface";
export { useCarousel } from "./useCarousel";

View file

@ -0,0 +1,25 @@
import type useEmblaCarousel from "embla-carousel-vue";
import type { EmblaCarouselVueType } from "embla-carousel-vue";
import type { HTMLAttributes, UnwrapRef } from "vue";
type CarouselApi = EmblaCarouselVueType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
export type UnwrapRefCarouselApi = UnwrapRef<CarouselApi>;
export interface CarouselProps {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
}
export type CarouselEmits = (
e: "init-api",
payload: UnwrapRefCarouselApi,
) => void;
export interface WithClassAsProps {
class?: HTMLAttributes["class"];
}

View file

@ -0,0 +1,69 @@
import { createInjectionState } from "@vueuse/core";
import emblaCarouselVue from "embla-carousel-vue";
import { onMounted, ref } from "vue";
import type {
UnwrapRefCarouselApi as CarouselApi,
CarouselEmits,
CarouselProps,
} from "./interface";
const [useProvideCarousel, useInjectCarousel] = createInjectionState(
({ opts, orientation, plugins }: CarouselProps, emits: CarouselEmits) => {
const [emblaNode, emblaApi] = emblaCarouselVue(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
function scrollPrev() {
emblaApi.value?.scrollPrev();
}
function scrollNext() {
emblaApi.value?.scrollNext();
}
const canScrollNext = ref(false);
const canScrollPrev = ref(false);
function onSelect(api: CarouselApi) {
canScrollNext.value = !!api?.canScrollNext();
canScrollPrev.value = !!api?.canScrollPrev();
}
onMounted(() => {
if (!emblaApi.value) {
return;
}
emblaApi.value?.on("init", onSelect);
emblaApi.value?.on("reInit", onSelect);
emblaApi.value?.on("select", onSelect);
emits("init-api", emblaApi.value);
});
return {
carouselRef: emblaNode,
carouselApi: emblaApi,
canScrollPrev,
canScrollNext,
scrollPrev,
scrollNext,
orientation,
};
},
);
function useCarousel() {
const carouselState = useInjectCarousel();
if (!carouselState) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return carouselState;
}
export { useCarousel, useProvideCarousel };

View file

@ -1,5 +1,6 @@
<template> <template>
<Sidebar> <Sidebar>
<SquarePattern />
<slot /> <slot />
</Sidebar> </Sidebar>
<ComposerModal /> <ComposerModal />
@ -8,6 +9,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ComposerModal from "~/components/composer/modal.client.vue"; import ComposerModal from "~/components/composer/modal.client.vue";
import SquarePattern from "~/components/graphics/square-pattern.vue";
import Sidebar from "~/components/sidebars/sidebar.vue"; import Sidebar from "~/components/sidebars/sidebar.vue";
import AttachmentDialog from "~/components/social-elements/notes/attachment-dialog.vue"; import AttachmentDialog from "~/components/social-elements/notes/attachment-dialog.vue";

View file

@ -42,6 +42,7 @@
"c12": "^2.0.1", "c12": "^2.0.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"embla-carousel-vue": "^8.5.1",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"iconify-icon": "^2.1.0", "iconify-icon": "^2.1.0",