mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
add AI metadata checking to block image uploads with notice
This commit is contained in:
@@ -38,6 +38,7 @@
|
||||
"wrangler": "^4.115.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@contentauth/c2pa-web": "^0.13.1",
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
"@ltd/j-toml": "^1.38.0",
|
||||
"@modrinth/api-client": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
:closable="false"
|
||||
fade="warning"
|
||||
max-width="544px"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<p class="mb-4 mt-0 leading-normal">
|
||||
{{ formatMessage(messages.body) }}
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<Button type="colored" color="brand" native-type="button" @click="hide">
|
||||
<CheckCircleIcon class="size-4" />
|
||||
{{ formatMessage(commonMessages.iUnderstandButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon } from '@modrinth/assets'
|
||||
import { Button, commonMessages, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'project.ai-image-warning-modal.title',
|
||||
defaultMessage: 'AI-generated images not allowed',
|
||||
},
|
||||
body: {
|
||||
id: 'project.ai-image-warning-modal.body',
|
||||
defaultMessage:
|
||||
'Using AI-generated images to represent your project is not allowed. Attempting to work around detection may lead to your Modrinth account being suspended.',
|
||||
},
|
||||
})
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { C2paSdk } from '@contentauth/c2pa-web'
|
||||
import { createC2pa, isSupportedReaderFormat } from '@contentauth/c2pa-web/inline'
|
||||
|
||||
// https://cv.iptc.org/newscodes/digitalsourcetype/
|
||||
// only detecting generative AI types
|
||||
const AI_DIGITAL_SOURCE_TYPES = [
|
||||
'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia',
|
||||
'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicData',
|
||||
'http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia',
|
||||
'http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic',
|
||||
]
|
||||
|
||||
let c2paInstance: Promise<C2paSdk> | null = null
|
||||
|
||||
function getC2pa() {
|
||||
if (!c2paInstance) {
|
||||
c2paInstance = createC2pa()
|
||||
}
|
||||
return c2paInstance
|
||||
}
|
||||
|
||||
// reference: https://github.com/contentauth/c2pa-js/blob/main/packages/c2pa-web/README.md
|
||||
export async function fileDeclaresAi(file: File): Promise<boolean> {
|
||||
try {
|
||||
if (!isSupportedReaderFormat(file.type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const c2pa = await getC2pa()
|
||||
|
||||
const reader = await c2pa.reader.fromBlob(file.type, file)
|
||||
const manifestStore = await reader.manifestStore()
|
||||
|
||||
await reader.free()
|
||||
|
||||
const manifestJson = JSON.stringify(manifestStore)
|
||||
return AI_DIGITAL_SOURCE_TYPES.some((type) => manifestJson.includes(type))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -3485,6 +3485,12 @@
|
||||
"project.actions.servers-promo.title": {
|
||||
"message": "Create a server"
|
||||
},
|
||||
"project.ai-image-warning-modal.body": {
|
||||
"message": "Using AI-generated images to represent your project is not allowed. Attempting to work around detection may lead to your Modrinth account being suspended."
|
||||
},
|
||||
"project.ai-image-warning-modal.title": {
|
||||
"message": "AI-generated images not allowed"
|
||||
},
|
||||
"project.collections.create-new": {
|
||||
"message": "Create new collection"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<AiImageWarningModal ref="aiImageWarningModal" />
|
||||
<Modal
|
||||
v-if="currentMember"
|
||||
ref="modal_edit_item"
|
||||
@@ -18,12 +19,7 @@
|
||||
:accept="acceptFileTypes"
|
||||
:max-size="5242880"
|
||||
aria-label="Replace image"
|
||||
@change="
|
||||
(x) => {
|
||||
editFile = x[0]
|
||||
showPreviewImage()
|
||||
}
|
||||
"
|
||||
@change="replaceEditFile"
|
||||
>
|
||||
<TransferIcon aria-hidden="true" />
|
||||
</FileButton>
|
||||
@@ -315,6 +311,8 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||
import { fileDeclaresAi } from '~/helpers/c2pa'
|
||||
import { isPermission } from '~/utils/permissions.ts'
|
||||
|
||||
const formatDate = useFormatDateTime({
|
||||
@@ -333,6 +331,7 @@ const {
|
||||
} = injectProjectPageContext()
|
||||
|
||||
// Template refs
|
||||
const aiImageWarningModal = useTemplateRef('aiImageWarningModal')
|
||||
const modalEditItem = useTemplateRef('modal_edit_item')
|
||||
const modalConfirm = useTemplateRef('modal_confirm')
|
||||
|
||||
@@ -439,14 +438,35 @@ function resetEdit() {
|
||||
previewImage.value = null
|
||||
}
|
||||
|
||||
function handleFiles(files: File[]) {
|
||||
async function handleFiles(files: File[]) {
|
||||
const file = files[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
if (await fileDeclaresAi(file)) {
|
||||
aiImageWarningModal.value?.show()
|
||||
return
|
||||
}
|
||||
resetEdit()
|
||||
editFile.value = files[0]
|
||||
editFile.value = file
|
||||
|
||||
showPreviewImage()
|
||||
modalEditItem.value?.show()
|
||||
}
|
||||
|
||||
async function replaceEditFile(files: File[]) {
|
||||
const file = files[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
if (await fileDeclaresAi(file)) {
|
||||
aiImageWarningModal.value?.show()
|
||||
return
|
||||
}
|
||||
editFile.value = file
|
||||
showPreviewImage()
|
||||
}
|
||||
|
||||
function showPreviewImage() {
|
||||
const reader = new FileReader()
|
||||
if (editFile.value instanceof Blob) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<AiImageWarningModal ref="aiImageWarningModal" />
|
||||
<ConfirmLeaveModal ref="confirmLeaveModal" />
|
||||
<div class="universal-card">
|
||||
<div class="markdown-disclaimer">
|
||||
@@ -51,11 +52,14 @@ import {
|
||||
useSavable,
|
||||
} from '@modrinth/ui'
|
||||
import { TeamMemberPermission } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
import { fileDeclaresAi } from '~/helpers/c2pa'
|
||||
|
||||
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
|
||||
const aiImageWarningModal = useTemplateRef('aiImageWarningModal')
|
||||
|
||||
useProjectSettingsHeadTitle(commonProjectSettingsMessages.description)
|
||||
|
||||
@@ -80,6 +84,10 @@ const descriptionWarning = computed(() => {
|
||||
})
|
||||
|
||||
async function onUploadHandler(file: File) {
|
||||
if (await fileDeclaresAi(file)) {
|
||||
aiImageWarningModal.value?.show()
|
||||
return
|
||||
}
|
||||
const response = await useImageUpload(file, {
|
||||
context: 'project',
|
||||
projectID: project.value.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<AiImageWarningModal ref="aiImageWarningModal" />
|
||||
<Modal
|
||||
v-if="currentMember"
|
||||
ref="modal_edit_item"
|
||||
@@ -18,12 +19,7 @@
|
||||
:accept="acceptFileTypes"
|
||||
:max-size="5242880"
|
||||
aria-label="Replace image"
|
||||
@change="
|
||||
(x) => {
|
||||
editFile = x[0]
|
||||
showPreviewImage()
|
||||
}
|
||||
"
|
||||
@change="replaceEditFile"
|
||||
>
|
||||
<TransferIcon aria-hidden="true" />
|
||||
</FileButton>
|
||||
@@ -305,6 +301,8 @@ import {
|
||||
useFormatDateTime,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||
import { fileDeclaresAi } from '~/helpers/c2pa'
|
||||
import { isPermission } from '~/utils/permissions.ts'
|
||||
|
||||
const formatDate = useFormatDateTime({
|
||||
@@ -323,6 +321,7 @@ const {
|
||||
|
||||
useProjectSettingsHeadTitle(commonProjectSettingsMessages.gallery)
|
||||
|
||||
const aiImageWarningModal = ref(null)
|
||||
const modal_edit_item = ref(null)
|
||||
const modal_confirm = ref(null)
|
||||
|
||||
@@ -380,14 +379,35 @@ const resetEdit = () => {
|
||||
previewImage.value = null
|
||||
}
|
||||
|
||||
const handleFiles = (files) => {
|
||||
const handleFiles = async (files) => {
|
||||
const file = files[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
if (await fileDeclaresAi(file)) {
|
||||
aiImageWarningModal.value?.show()
|
||||
return
|
||||
}
|
||||
resetEdit()
|
||||
editFile.value = files[0]
|
||||
editFile.value = file
|
||||
|
||||
showPreviewImage()
|
||||
modal_edit_item.value.show()
|
||||
}
|
||||
|
||||
const replaceEditFile = async (files) => {
|
||||
const file = files[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
if (await fileDeclaresAi(file)) {
|
||||
aiImageWarningModal.value?.show()
|
||||
return
|
||||
}
|
||||
editFile.value = file
|
||||
showPreviewImage()
|
||||
}
|
||||
|
||||
const showPreviewImage = () => {
|
||||
const reader = new FileReader()
|
||||
if (editFile.value instanceof Blob) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<AiImageWarningModal ref="aiImageWarningModal" />
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
:title="formatMessage(messages.deleteConfirmationTitle)"
|
||||
@@ -322,7 +323,9 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { fileIsValid, formatProjectStatus } from '@modrinth/utils'
|
||||
|
||||
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
|
||||
import { useAuth } from '~/composables/auth.js'
|
||||
import { fileDeclaresAi } from '~/helpers/c2pa'
|
||||
import { getProjectTypeForUrl } from '~/helpers/projects.js'
|
||||
|
||||
const auth = await useAuth()
|
||||
@@ -337,6 +340,7 @@ const {
|
||||
invalidate,
|
||||
} = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const aiImageWarningModal = useTemplateRef('aiImageWarningModal')
|
||||
|
||||
useProjectSettingsHeadTitle(commonProjectSettingsMessages.general)
|
||||
|
||||
@@ -553,9 +557,17 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
const showPreviewImage = (files) => {
|
||||
const showPreviewImage = async (files) => {
|
||||
const file = files[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
if (await fileDeclaresAi(file)) {
|
||||
aiImageWarningModal.value?.show()
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
icon.value = files[0]
|
||||
icon.value = file
|
||||
deletedIcon.value = false
|
||||
reader.readAsDataURL(icon.value)
|
||||
reader.onload = (event) => {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<Checkbox v-model="dontShowAgain" :label="formatMessage(messages.dontShowAgain)" />
|
||||
<Button type="colored" color="brand" native-type="button" @click="acknowledge">
|
||||
<CheckCircleIcon class="size-4" />
|
||||
{{ formatMessage(messages.iUnderstand) }}
|
||||
{{ formatMessage(commonMessages.iUnderstandButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,6 +37,7 @@ import { ref, useTemplateRef } from 'vue'
|
||||
import { Button } from '#ui/components/base/buttons'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { commonMessages } from '../../utils/common-messages'
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
import NewModal from './NewModal.vue'
|
||||
|
||||
@@ -72,10 +73,6 @@ const messages = defineMessages({
|
||||
id: 'search.photosensitivity-warning-modal.dont-show-again',
|
||||
defaultMessage: "Don't show this again",
|
||||
},
|
||||
iUnderstand: {
|
||||
id: 'search.photosensitivity-warning-modal.i-understand',
|
||||
defaultMessage: 'I understand',
|
||||
},
|
||||
})
|
||||
|
||||
function show() {
|
||||
|
||||
@@ -206,6 +206,9 @@
|
||||
"button.hide-snapshots": {
|
||||
"defaultMessage": "Hide snapshots"
|
||||
},
|
||||
"button.i-understand": {
|
||||
"defaultMessage": "I understand"
|
||||
},
|
||||
"button.install": {
|
||||
"defaultMessage": "Install"
|
||||
},
|
||||
@@ -3989,9 +3992,6 @@
|
||||
"search.photosensitivity-warning-modal.dont-show-again": {
|
||||
"defaultMessage": "Don't show this again"
|
||||
},
|
||||
"search.photosensitivity-warning-modal.i-understand": {
|
||||
"defaultMessage": "I understand"
|
||||
},
|
||||
"search.photosensitivity-warning-modal.title": {
|
||||
"defaultMessage": "Results are not guaranteed to be safe"
|
||||
},
|
||||
|
||||
@@ -176,6 +176,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'input.view.grid',
|
||||
defaultMessage: 'Grid view',
|
||||
},
|
||||
iUnderstandButton: {
|
||||
id: 'button.i-understand',
|
||||
defaultMessage: 'I understand',
|
||||
},
|
||||
listInputView: {
|
||||
id: 'input.view.list',
|
||||
defaultMessage: 'Rows view',
|
||||
|
||||
Generated
+38
-4
@@ -263,6 +263,9 @@ importers:
|
||||
|
||||
apps/frontend:
|
||||
dependencies:
|
||||
'@contentauth/c2pa-web':
|
||||
specifier: ^0.13.1
|
||||
version: 0.13.1
|
||||
'@formatjs/intl-localematcher':
|
||||
specifier: ^0.5.4
|
||||
version: 0.5.10
|
||||
@@ -1230,6 +1233,15 @@ packages:
|
||||
'@codemirror/view@6.39.12':
|
||||
resolution: {integrity: sha512-f+/VsHVn/kOA9lltk/GFzuYwVVAKmOnNjxbrhkk3tPHntFqjWeI2TbIXx006YkBkqC10wZ4NsnWXCQiFPeAISQ==}
|
||||
|
||||
'@contentauth/c2pa-types@0.7.2':
|
||||
resolution: {integrity: sha512-SH+q4a6ZS2z2YhlqfTB2GzN1S4M9koLhJETGPxL5J4JVhJEFSyEQK52PnwOhee+ObiZdDZK18NI+s+1IrUSdTQ==}
|
||||
|
||||
'@contentauth/c2pa-wasm@0.11.0':
|
||||
resolution: {integrity: sha512-pL7tGZnM/qQz7JFCPB0hWSZM41L4aUEF/SV9B1fL+0L2sUZT1Zj7UGcUjSmiRWeXLagQ8Jds7RF+XQ9gxZiG7g==}
|
||||
|
||||
'@contentauth/c2pa-web@0.13.1':
|
||||
resolution: {integrity: sha512-K4GLdhqIdLE3DUdO0Ub2M6rnyopAnCtdOQhFx2ACayVXo6uLuYlMPRlBivqc4RlvuXMpvJcW2BMu6B1FZpbYhw==}
|
||||
|
||||
'@crowdin/crowdin-api-client@1.55.2':
|
||||
resolution: {integrity: sha512-kEMiz25j9tSfhsyj8dZWdhKPdtTeVu9lWZFBIc+adHXZ8vs6zd4/6xaiYr3PamYhV3rIOBEv8ElyzpUSSO5LGg==}
|
||||
engines: {node: '>=12.9.0'}
|
||||
@@ -6815,6 +6827,9 @@ packages:
|
||||
hey-listen@1.0.8:
|
||||
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
|
||||
|
||||
highgain@0.1.0:
|
||||
resolution: {integrity: sha512-Vw/g2upw4MYLZ9e95k79+idMoPFEKvTQVTce9LCDfI+T6AiG+SdiR4WDKszB1grQy0O+uhxd6cqbdi3VhECdbA==}
|
||||
|
||||
highlight.js@11.11.1:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -9470,6 +9485,10 @@ packages:
|
||||
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
|
||||
engines: {node: '>=6.10'}
|
||||
|
||||
ts-deepmerge@8.0.0:
|
||||
resolution: {integrity: sha512-133O+10nJmVI8w5xeVZPEv5PIrv7iaUae07wv1aH8XJH95Ur6YIhWAPhPyP1YPlbPS9fCVcNIZTu7m8urRVF0A==}
|
||||
engines: {node: '>=14.13.1'}
|
||||
|
||||
ts-interface-checker@0.1.13:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
@@ -10174,8 +10193,8 @@ packages:
|
||||
vue-component-type-helpers@3.2.4:
|
||||
resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==}
|
||||
|
||||
vue-component-type-helpers@3.3.8:
|
||||
resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==}
|
||||
vue-component-type-helpers@3.3.9:
|
||||
resolution: {integrity: sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==}
|
||||
|
||||
vue-confetti-explosion@1.0.2:
|
||||
resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==}
|
||||
@@ -11029,6 +11048,17 @@ snapshots:
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@contentauth/c2pa-types@0.7.2': {}
|
||||
|
||||
'@contentauth/c2pa-wasm@0.11.0': {}
|
||||
|
||||
'@contentauth/c2pa-web@0.13.1':
|
||||
dependencies:
|
||||
'@contentauth/c2pa-types': 0.7.2
|
||||
'@contentauth/c2pa-wasm': 0.11.0
|
||||
highgain: 0.1.0
|
||||
ts-deepmerge: 8.0.0
|
||||
|
||||
'@crowdin/crowdin-api-client@1.55.2':
|
||||
dependencies:
|
||||
axios: 1.16.1
|
||||
@@ -13822,7 +13852,7 @@ snapshots:
|
||||
storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.27(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.8
|
||||
vue-component-type-helpers: 3.3.9
|
||||
|
||||
'@stripe/stripe-js@7.9.0': {}
|
||||
|
||||
@@ -17067,6 +17097,8 @@ snapshots:
|
||||
|
||||
hey-listen@1.0.8: {}
|
||||
|
||||
highgain@0.1.0: {}
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
highlightjs-mcfunction@https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e: {}
|
||||
@@ -20384,6 +20416,8 @@ snapshots:
|
||||
|
||||
ts-dedent@2.2.0: {}
|
||||
|
||||
ts-deepmerge@8.0.0: {}
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
ts-map@1.0.3: {}
|
||||
@@ -21040,7 +21074,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.2.4: {}
|
||||
|
||||
vue-component-type-helpers@3.3.8: {}
|
||||
vue-component-type-helpers@3.3.9: {}
|
||||
|
||||
vue-confetti-explosion@1.0.2(vue@3.5.27(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user