feat: saved views disclaimers (#5265)

This commit is contained in:
Kristaps Fabians Geikins
2025-08-20 10:19:57 +03:00
committed by GitHub
parent 6801e106b8
commit 4650936bf0
22 changed files with 267 additions and 62 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

@@ -1,12 +1,12 @@
<template>
<ViewerLayoutSidePanel disable-scrollbar @close="$emit('close')">
<ViewerLayoutSidePanel disable-scrollbar class="relative" @close="$emit('close')">
<template #title>
<div class="flex justify-between items-center">
<div>Views</div>
</div>
</template>
<template #actions>
<div class="flex items-center gap-0.5">
<div v-if="!isLowerPlan" class="flex items-center gap-0.5">
<FormButton
v-if="false"
size="sm"
@@ -38,34 +38,53 @@
</div>
</div>
</template>
<div class="px-4 pt-2">
<ViewerButtonGroup>
<ViewerButtonGroupButton
v-for="viewsType in Object.values(ViewsType)"
:key="viewsType"
:is-active="selectedViewsType === viewsType"
class="grow"
@click="() => (selectedViewsType = viewsType)"
>
<span class="text-body-2xs text-foreground px-2 py-1">
{{ viewsTypeLabels[viewsType] }}
</span>
</ViewerButtonGroupButton>
</ViewerButtonGroup>
</div>
<div class="text-body-sm flex-1 min-h-0 overflow-y-auto simple-scrollbar">
<ViewerSavedViewsPanelGroups
v-model:selected-group-id="selectedGroupId"
:views-type="selectedViewsType"
/>
</div>
<template v-if="!isLowerPlan">
<div class="px-4 pt-2">
<ViewerButtonGroup>
<ViewerButtonGroupButton
v-for="viewsType in Object.values(ViewsType)"
:key="viewsType"
:is-active="selectedViewsType === viewsType"
class="grow"
@click="() => (selectedViewsType = viewsType)"
>
<span class="text-body-2xs text-foreground px-2 py-1">
{{ viewsTypeLabels[viewsType] }}
</span>
</ViewerButtonGroupButton>
</ViewerButtonGroup>
</div>
<div class="text-body-sm flex-1 min-h-0 overflow-y-auto simple-scrollbar">
<ViewerSavedViewsPanelGroups
v-model:selected-group-id="selectedGroupId"
:views-type="selectedViewsType"
/>
</div>
<div
v-if="isViewerSeat && !hideViewerSeatDisclaimer"
class="absolute bottom-0 left-0 right-0 p-2"
>
<CommonPromoAlert
title="Save your views"
text="With an editor seat, unlock the option to save your own views."
:button="{ title: 'Learn more' }"
show-closer
@close="hideViewerSeatDisclaimer = true"
/>
</div>
</template>
<ViewerSavedViewsPlanUpsell v-else />
</ViewerLayoutSidePanel>
</template>
<script setup lang="ts">
import { useMutationLoading } from '@vue/apollo-composable'
import { Search, FolderPlus, Plus } from 'lucide-vue-next'
import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie'
import { graphql } from '~/lib/common/generated/gql'
import { SavedViewVisibility } from '~/lib/common/generated/gql/graphql'
import {
SavedViewVisibility,
WorkspaceSeatType
} from '~/lib/common/generated/gql/graphql'
import {
useCreateSavedView,
useCreateSavedViewGroup
@@ -81,6 +100,11 @@ graphql(`
...FullPermissionCheckResult
}
}
workspace {
id
seatType
planSupportsSavedViews: hasAccessToFeature(featureName: savedViews)
}
}
`)
@@ -101,10 +125,20 @@ const isLoading = useMutationLoading()
const selectedViewsType = ref<ViewsType>(ViewsType.Personal)
const selectedGroupId = ref<string | null>(null)
const hideViewerSeatDisclaimer = useSynchronizedCookie<boolean>(
'hideViewerSeatSavedViewsDisclaimer',
{
default: () => false
}
)
const canCreateViewOrGroup = computed(
() => project.value?.permissions.canCreateSavedView
)
const isViewerSeat = computed(
() => project.value?.workspace?.seatType === WorkspaceSeatType.Viewer
)
const isLowerPlan = computed(() => !project.value?.workspace?.planSupportsSavedViews)
const onAddView = async () => {
if (isLoading.value) return
@@ -0,0 +1,20 @@
<template>
<div class="flex flex-col gap-4 p-4">
<img src="~/assets/images/viewer/saved-views/plan_upsell.webp" alt="Saved Views" />
<div>
<div class="text-foreground text-body font-semibold">Save custom views</div>
<div class="text-body-2xs font-medium text-foreground-2">
<p class="pb-3">Upgrade to a business plan to save, organise and present</p>
<ul class="flex flex-col gap-2 list-disc list-inside">
<li>It's cool</li>
<li>It's nice</li>
<li>It's got enough spice</li>
</ul>
</div>
</div>
<div class="flex gap-2">
<FormButton size="sm">Upgrade</FormButton>
<FormButton size="sm" color="outline">Learn more</FormButton>
</div>
</div>
</template>
@@ -206,7 +206,8 @@ watch(
groups,
(newGroups) => {
if (newGroups.length && !selectedGroupId.value) {
selectedGroupId.value = newGroups[0].id
// open default group, if any
selectedGroupId.value = newGroups.find((g) => g.isUngroupedViewsGroup)?.id || null
}
},
{ immediate: true }
+7
View File
@@ -1,3 +1,5 @@
import type { FeatureFlags } from '@speckle/shared/environment/featureFlags'
/**
* IMPORTANT: Don't use this directly in Vue templates that may render in SSR, cause this may cause the backend API origin to be rendered instead of the clientside one,
* at least until the app finishes hydrating. If people click on links based on this too early, they may end up in the wrong place.
@@ -17,3 +19,8 @@ export const useApiOrigin = (
return apiOrigin
}
export const useFeatureFlags = (): FeatureFlags => {
const { public: featureFlags } = useRuntimeConfig()
return featureFlags
}
@@ -166,7 +166,7 @@ type Documents = {
"\n fragment ViewerResourcesLimitAlert_Project on Project {\n id\n workspaceId\n workspace {\n id\n slug\n ...ViewerResourcesWorkspaceLimitAlert_Workspace\n }\n ...WorkspaceMoveProject_Project\n }\n": typeof types.ViewerResourcesLimitAlert_ProjectFragmentDoc,
"\n fragment ViewerResourcesPersonalLimitAlert_Project on Project {\n id\n ...WorkspaceMoveProject_Project\n }\n": typeof types.ViewerResourcesPersonalLimitAlert_ProjectFragmentDoc,
"\n fragment ViewerResourcesWorkspaceLimitAlert_Workspace on Workspace {\n id\n slug\n }\n": typeof types.ViewerResourcesWorkspaceLimitAlert_WorkspaceFragmentDoc,
"\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ViewerSavedViewsPanel_ProjectFragmentDoc,
"\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n workspace {\n id\n seatType\n planSupportsSavedViews: hasAccessToFeature(featureName: savedViews)\n }\n }\n": typeof types.ViewerSavedViewsPanel_ProjectFragmentDoc,
"\n fragment ViewerSavedViewsPanelGroups_Project on Project {\n id\n savedViewGroups(input: $savedViewGroupsInput) {\n totalCount\n cursor\n items {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\n }\n }\n ...ViewerSavedViewsPanelViewsGroup_Project\n }\n": typeof types.ViewerSavedViewsPanelGroups_ProjectFragmentDoc,
"\n query ViewerSavedViewsPanelGroups_SavedViewGroups(\n $projectId: String!\n $savedViewGroupsInput: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n ...ViewerSavedViewsPanelGroups_Project\n }\n }\n": typeof types.ViewerSavedViewsPanelGroups_SavedViewGroupsDocument,
"\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n visibility\n isHomeView\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_SavedView\n }\n": typeof types.ViewerSavedViewsPanelView_SavedViewFragmentDoc,
@@ -670,7 +670,7 @@ const documents: Documents = {
"\n fragment ViewerResourcesLimitAlert_Project on Project {\n id\n workspaceId\n workspace {\n id\n slug\n ...ViewerResourcesWorkspaceLimitAlert_Workspace\n }\n ...WorkspaceMoveProject_Project\n }\n": types.ViewerResourcesLimitAlert_ProjectFragmentDoc,
"\n fragment ViewerResourcesPersonalLimitAlert_Project on Project {\n id\n ...WorkspaceMoveProject_Project\n }\n": types.ViewerResourcesPersonalLimitAlert_ProjectFragmentDoc,
"\n fragment ViewerResourcesWorkspaceLimitAlert_Workspace on Workspace {\n id\n slug\n }\n": types.ViewerResourcesWorkspaceLimitAlert_WorkspaceFragmentDoc,
"\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ViewerSavedViewsPanel_ProjectFragmentDoc,
"\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n workspace {\n id\n seatType\n planSupportsSavedViews: hasAccessToFeature(featureName: savedViews)\n }\n }\n": types.ViewerSavedViewsPanel_ProjectFragmentDoc,
"\n fragment ViewerSavedViewsPanelGroups_Project on Project {\n id\n savedViewGroups(input: $savedViewGroupsInput) {\n totalCount\n cursor\n items {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\n }\n }\n ...ViewerSavedViewsPanelViewsGroup_Project\n }\n": types.ViewerSavedViewsPanelGroups_ProjectFragmentDoc,
"\n query ViewerSavedViewsPanelGroups_SavedViewGroups(\n $projectId: String!\n $savedViewGroupsInput: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n ...ViewerSavedViewsPanelGroups_Project\n }\n }\n": types.ViewerSavedViewsPanelGroups_SavedViewGroupsDocument,
"\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n visibility\n isHomeView\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_SavedView\n }\n": types.ViewerSavedViewsPanelView_SavedViewFragmentDoc,
@@ -1647,7 +1647,7 @@ export function graphql(source: "\n fragment ViewerResourcesWorkspaceLimitAlert
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
export function graphql(source: "\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n workspace {\n id\n seatType\n planSupportsSavedViews: hasAccessToFeature(featureName: savedViews)\n }\n }\n"): (typeof documents)["\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n workspace {\n id\n seatType\n planSupportsSavedViews: hasAccessToFeature(featureName: savedViews)\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -160,6 +160,7 @@ enum WorkspaceFeatureName {
hideSpeckleBranding
workspaceDataRegionSpecificity
exclusiveMembership
savedViews
}
type WorkspacePlanPrice {
@@ -5305,6 +5305,7 @@ export const WorkspaceFeatureName = {
ExclusiveMembership: 'exclusiveMembership',
HideSpeckleBranding: 'hideSpeckleBranding',
OidcSso: 'oidcSso',
SavedViews: 'savedViews',
WorkspaceDataRegionSpecificity: 'workspaceDataRegionSpecificity'
} as const;
@@ -598,7 +598,7 @@ export const updateSavedViewRecordFactory =
[SavedViews.col.id]: id,
[SavedViews.col.projectId]: projectId
})
.update(update, '*')
.update({ ...update, updatedAt: new Date() }, '*')
return updatedView || undefined
}
@@ -636,7 +636,13 @@ export const updateSavedViewGroupRecordFactory =
[SavedViewGroups.col.id]: groupId,
[SavedViewGroups.col.projectId]: projectId
})
.update(update, '*')
.update(
{
...update,
updatedAt: new Date()
},
'*'
)
return updatedGroup
}
+11
View File
@@ -87,6 +87,7 @@
"./package.json": "./package.json",
"./pinoPrettyTransport.cjs": "./pinoPrettyTransport.cjs",
"./environment": "./src/environment/index.ts",
"./environment/featureFlags": "./src/environment/featureFlags.ts",
"./environment/db": "./src/environment/db.ts",
"./environment/node": "./src/environment/node.ts",
"./environment/multiRegionConfig": "./src/environment/db.ts",
@@ -143,6 +144,16 @@
"default": "./dist/commonjs/environment/index.js"
}
},
"./environment/featureFlags": {
"import": {
"types": "./dist/esm/environment/featureFlags.d.ts",
"default": "./dist/esm/environment/featureFlags.js"
},
"require": {
"types": "./dist/commonjs/environment/featureFlags.d.ts",
"default": "./dist/commonjs/environment/featureFlags.js"
}
},
"./environment/db": {
"import": {
"types": "./dist/esm/environment/db.d.ts",
@@ -1,5 +1,5 @@
import { WorkspaceRoles, WorkspaceSeatType } from '../../../core/constants.js'
import { FeatureFlags } from '../../../environment/index.js'
import { FeatureFlags } from '../../../environment/featureFlags.js'
import { WorkspaceLimits } from '../../../workspaces/helpers/limits.js'
import { WorkspacePlan } from '../../../workspaces/index.js'
import { UserContext, WorkspaceContext } from '../context.js'
@@ -0,0 +1,27 @@
/**
* IMPORTANT: This should not have any node-only code (e.g. Zod)
*/
export type FeatureFlags = {
FF_AUTOMATE_MODULE_ENABLED: boolean
FF_GENDOAI_MODULE_ENABLED: boolean
FF_WORKSPACES_MODULE_ENABLED: boolean
FF_WORKSPACES_SSO_ENABLED: boolean
FF_GATEKEEPER_MODULE_ENABLED: boolean
FF_BILLING_INTEGRATION_ENABLED: boolean
FF_WORKSPACES_MULTI_REGION_ENABLED: boolean
FF_FORCE_ONBOARDING: boolean
FF_MOVE_PROJECT_REGION_ENABLED: boolean
FF_NO_PERSONAL_EMAILS_ENABLED: boolean
FF_RETRY_ERRORED_PREVIEWS_ENABLED: boolean
FF_PERSONAL_PROJECTS_LIMITS_ENABLED: boolean
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: boolean
FF_RHINO_FILE_IMPORTER_ENABLED: boolean
FF_BACKGROUND_JOBS_ENABLED: boolean
FF_LEGACY_FILE_IMPORTS_ENABLED: boolean
FF_LEGACY_IFC_IMPORTER_ENABLED: boolean
FF_EXPERIMENTAL_IFC_IMPORTER_ENABLED: boolean
FF_ACC_INTEGRATION_ENABLED: boolean
FF_SAVED_VIEWS_ENABLED: boolean
FF_USERS_INVITE_SCOPE_IS_PUBLIC: boolean
}
@@ -1,5 +1,6 @@
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
import { type FeatureFlags, parseFeatureFlags } from './index.js'
import { parseFeatureFlags } from './index.js'
import { FeatureFlags } from './featureFlags.js'
const originalDisableAllFfs = process.env.DISABLE_ALL_FFS || ''
const originalEnableAllFfs = process.env.ENABLE_ALL_FFS || ''
+1 -24
View File
@@ -1,6 +1,7 @@
import { has } from '#lodash'
import { parseEnv } from 'znv'
import { z } from 'zod'
import { FeatureFlags } from './featureFlags.js'
// Convenience variable to override below individual feature flags, which has the effect of setting all to 'false' (disabled)
// Takes precedence over ENABLE_ALL_FFS
@@ -180,30 +181,6 @@ export const parseFeatureFlags = (
let parsedFlags: FeatureFlags | undefined
export type FeatureFlags = {
FF_AUTOMATE_MODULE_ENABLED: boolean
FF_GENDOAI_MODULE_ENABLED: boolean
FF_WORKSPACES_MODULE_ENABLED: boolean
FF_WORKSPACES_SSO_ENABLED: boolean
FF_GATEKEEPER_MODULE_ENABLED: boolean
FF_BILLING_INTEGRATION_ENABLED: boolean
FF_WORKSPACES_MULTI_REGION_ENABLED: boolean
FF_FORCE_ONBOARDING: boolean
FF_MOVE_PROJECT_REGION_ENABLED: boolean
FF_NO_PERSONAL_EMAILS_ENABLED: boolean
FF_RETRY_ERRORED_PREVIEWS_ENABLED: boolean
FF_PERSONAL_PROJECTS_LIMITS_ENABLED: boolean
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: boolean
FF_RHINO_FILE_IMPORTER_ENABLED: boolean
FF_BACKGROUND_JOBS_ENABLED: boolean
FF_LEGACY_FILE_IMPORTS_ENABLED: boolean
FF_LEGACY_IFC_IMPORTER_ENABLED: boolean
FF_EXPERIMENTAL_IFC_IMPORTER_ENABLED: boolean
FF_ACC_INTEGRATION_ENABLED: boolean
FF_SAVED_VIEWS_ENABLED: boolean
FF_USERS_INVITE_SCOPE_IS_PUBLIC: boolean
}
export function getFeatureFlags(): FeatureFlags {
//@ts-expect-error this way, the parse function typing is a lot better
if (!parsedFlags) parsedFlags = parseFeatureFlags(process.env, { forceInputs: false })
+2 -1
View File
@@ -9,7 +9,7 @@ import {
WorkspaceSsoProvider,
WorkspaceSsoSession
} from '../authz/domain/workspaces/types.js'
import { FeatureFlags, parseFeatureFlags } from '../environment/index.js'
import { parseFeatureFlags } from '../environment/index.js'
import { mapValues } from 'lodash'
import { WorkspacePlan } from '../workspaces/index.js'
import { TIME_MS } from '../core/index.js'
@@ -18,6 +18,7 @@ import {
SavedViewGroup,
SavedViewVisibility
} from '../authz/domain/savedViews/types.js'
import { FeatureFlags } from '../environment/featureFlags.js'
export const fakeGetFactory =
<T extends Record<string, unknown>>(defaults: () => T) =>
@@ -7,7 +7,7 @@ import {
WorkspacePlans
} from './plans.js'
import type { MaybeNullOrUndefined } from '../../core/helpers/utilityTypes.js'
import { FeatureFlags } from '../../environment/index.js'
import { FeatureFlags } from '../../environment/featureFlags.js'
/**
* WORKSPACE FEATURES
+1
View File
@@ -54,6 +54,7 @@
"@vueuse/core": "^9.13.0",
"lodash": "^4.0.0",
"lodash-es": "^4.0.0",
"lucide-vue-next": "^0.537.0",
"nanoid": "^3.0.0",
"v3-infinite-loading": "^1.2.2",
"vue-advanced-cropper": "^2.8.8",
@@ -0,0 +1,60 @@
// AGENT WRITE HERE:
import type { Meta, StoryObj } from '@storybook/vue3'
import PromoAlert from '~~/src/components/common/PromoAlert.vue'
const meta: Meta<typeof PromoAlert> = {
component: PromoAlert,
args: {
title: 'Upgrade your workspace',
text: 'Unlock advanced features and collaboration tools by upgrading your plan.',
button: { title: 'Learn more', to: 'https://speckle.systems' }
},
argTypes: {
title: { control: 'text' },
text: { control: 'text' },
button: { control: 'object' },
showCloser: { control: 'boolean' }
}
}
export default meta
interface PromoAlertArgs {
title?: string
text?: string
button?: { to?: string; title: string }
showCloser?: boolean
}
const render = (args: PromoAlertArgs) => ({
components: { PromoAlert },
setup() {
return { args }
},
template: `
<div class="max-w-sm h-full">
<PromoAlert v-bind="args" @click="args.click" @close="args.close" />
</div>
`
})
type Story = StoryObj<typeof PromoAlert>
export const Default: Story = { render }
export const WithoutLink: Story = { render, args: { button: { title: 'Learn more' } } }
export const WithoutButton: Story = { render, args: { button: undefined } }
export const TitleOnly: Story = { render, args: { text: undefined, button: undefined } }
export const ButtonOnly: Story = {
render,
args: { title: undefined, text: undefined, button: { title: 'Upgrade' } }
}
export const CustomClick: Story = {
render,
args: { button: { title: 'Trigger action' } }
}
export const WithCloser: Story = { render, args: { showCloser: true } }
@@ -0,0 +1,53 @@
<template>
<div
class="bg-foundation-page shadow-sm flex flex-col gap-y-1 sm:gap-y-2 border border-outline-3 rounded-lg py-2 px-3 sm:p-4 select-none"
>
<div class="flex justify-between items-center">
<h6
v-if="title"
class="text-body-xs sm:text-heading-sm font-medium text-foreground"
>
{{ title }}
</h6>
<X
v-if="showCloser"
v-keyboard-clickable
class="h-4 w-4 cursor-pointer focus:outline-none"
@click="$emit('close', $event)"
/>
</div>
<p v-if="text" class="text-body-2xs sm:text-body-xs text-foreground-2 !leading-5">
{{ text }}
</p>
<div class="flex justify-end">
<FormButton
v-if="button"
size="sm"
class="mt-1"
:to="button.to"
:target="button.to ? '_blank' : undefined"
@click="$emit('click', $event)"
>
{{ button.title }}
</FormButton>
</div>
</div>
</template>
<script setup lang="ts">
import FormButton from '~~/src/components/form/Button.vue'
import { X } from 'lucide-vue-next'
import { vKeyboardClickable } from '~~/src/directives/accessibility'
defineEmits<{
click: [e: MouseEvent]
close: [e: MouseEvent]
}>()
defineProps<{
title?: string
text?: string
button?: { to?: string; title: string }
showCloser?: boolean
}>()
</script>
+2
View File
@@ -111,10 +111,12 @@ export { vKeyboardClickable } from '~~/src/directives/accessibility'
export { useAvatarSizeClasses } from '~~/src/composables/user/avatar'
export type { UserAvatarSize } from '~~/src/composables/user/avatar'
import CommonProgressBar from '~~/src/components/common/ProgressBar.vue'
import CommonPromoAlert from '~~/src/components/common/PromoAlert.vue'
import FormRange from '~~/src/components/form/Range.vue'
import type { FormRadioGroupItem } from '~~/src/helpers/common/components'
export {
CommonPromoAlert,
MissingFileExtensionError,
ForbiddenFileTypeError,
FileTooLargeError,
+1
View File
@@ -16608,6 +16608,7 @@ __metadata:
eslint-plugin-vuejs-accessibility: "npm:^2.3.0"
lodash: "npm:^4.0.0"
lodash-es: "npm:^4.0.0"
lucide-vue-next: "npm:^0.537.0"
nanoid: "npm:^3.0.0"
postcss: "npm:^8.4.31"
postcss-nesting: "npm:^10.2.0"