Add all the bells and whistles

Add tooltip, relative time, requestedAt
However, fetching data is too slow now and there is a lag after render. Can try removing the fetching of slots when getting past StorageRequested events, and only do that fetch on the details page
This commit is contained in:
Eric 2024-06-14 18:13:35 +10:00
parent 28bdd3f3e7
commit 77cdbbe6e8
No known key found for this signature in database
14 changed files with 274 additions and 73 deletions

View File

@ -9,7 +9,7 @@ ${CODEX_PATH}/build/codex \
--bootstrap-node=spr:CiUIAhIhAgybmRwboqDdUJjeZrzh43sn5mp8jt6ENIb08tLn4x01EgIDARo8CicAJQgCEiECDJuZHBuioN1QmN5mvOHjeyfmanyO3oQ0hvTy0ufjHTUQh4ifsAYaCwoJBI_0zSiRAnVsKkcwRQIhAJCb_z0E3RsnQrEePdJzMSQrmn_ooHv6mbw1DOh5IbVNAiBbBJrWR8eBV6ftzMd6ofa5khNA2h88OBhMqHCIzSjCeA \
--bootstrap-node=spr:CiUIAhIhAntGLadpfuBCD9XXfiN_43-V3L5VWgFCXxg4a8uhDdnYEgIDARo8CicAJQgCEiECe0Ytp2l-4EIP1dd-I3_jf5XcvlVaAUJfGDhry6EN2dgQsIufsAYaCwoJBNEmoCiRAnV2KkYwRAIgXO3bzd5VF8jLZG8r7dcLJ_FnQBYp1BcxrOvovEa40acCIDhQ14eJRoPwJ6GKgqOkXdaFAsoszl-HIRzYcXKeb7D9 \
--data-dir=./codex-data \
--log-level='INFO;TRACE:marketplace,node,statemachine,erasure' \
--log-level='INFO;TRACE:marketplace,node,statemachine,erasure,storestream' \
--api-port=8080 \
--api-bindaddr=0.0.0.0 \
--api-cors-origin='*' \

View File

@ -11,11 +11,13 @@
"format": "prettier --write src/"
},
"dependencies": {
"@feelinglovelynow/get-relative-time": "^1.1.2",
"ethers": "^6.12.1",
"flowbite": "^2.3.0",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
"vue-router": "^4.3.0",
"vue-unique-id": "^3.2.1"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",

View File

@ -0,0 +1,26 @@
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { getRelativeTime } from '@feelinglovelynow/get-relative-time'
import Tooltip from '@/components/Tooltip.vue'
const props = defineProps({
timestamp: {
type: Date,
required: true
}
})
const date = ref(Date.now())
const relativeTime = ref(getRelativeTime(props.timestamp))
watch(date, () => (relativeTime.value = getRelativeTime(props.timestamp)))
let intervalId
// onMounted(() => {
// intervalId = setInterval(() => (date.value = Date.now()), 10000)
// })
// onUnmounted(() => clearInterval(intervalId))
</script>
<template>
<Tooltip>
<template #text>{{ relativeTime }}</template>
<template #tooltip-content>{{ props.timestamp.toString() }}</template>
</Tooltip>
</template>

View File

@ -0,0 +1,24 @@
<script setup>
import Tooltip from '@/components/Tooltip.vue'
import { shorten } from '@/utils/ids'
defineProps({
value: {
type: String,
required: true
},
ellipses: {
type: String,
default: '..'
},
chars: {
type: Number,
default: 4
}
})
</script>
<template>
<Tooltip>
<template #text>{{ shorten(value, ellipses, chars) }}</template>
<template #tooltip-content>{{ value }}</template>
</Tooltip>
</template>

View File

@ -1,7 +1,7 @@
<script setup>
import { onMounted } from 'vue'
import { getStateColour } from '@/utils/slots'
import { shortHex } from '@/utils/ids'
import { shorten } from '@/utils/ids'
import StateIndicator from '@/components/StateIndicator.vue'
defineProps({
slots: {
@ -35,11 +35,11 @@ defineProps({
scope="row"
class="flex items-center px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
<div class="text-base font-semibold">{{ shortHex(slotId) }}</div>
<div class="text-base font-semibold">{{ shorten(slotId) }}</div>
</th>
<td class="px-6 py-4">{{ slotIdx }}</td>
<td class="px-6 py-4">{{ proofsMissed }}</td>
<td class="px-6 py-4">{{ shortHex(provider) }}</td>
<td class="px-6 py-4">{{ shorten(provider) }}</td>
<td class="px-6 py-4">
<div class="flex items-center">
<StateIndicator :text="state" :color="getStateColour(state)"></StateIndicator>

View File

@ -5,7 +5,8 @@ import { autoPluralize } from '@/utils/strings'
import CodexImage from '@/components/CodexImage.vue'
import StateIndicator from '@/components/StateIndicator.vue'
import { shortHex } from '@/utils/ids'
import RelativeTime from '@/components/RelativeTime.vue'
import ShortenValue from '@/components/ShortenValue.vue'
import Slots from './Slots.vue'
const props = defineProps({
@ -27,25 +28,29 @@ const stateColour = computed(() => getStateColour(props.request.state))
<template>
<div class="flex flex-wrap">
<CodexImage class="flex-initial mx-auto my-8 lg:my-16 min-w-sm max-w-md w-full
rounded" :cid="request.content.cid" :local-only="!['New',
'Fulfilled'].includes(request.state)"></CodexImage>
<CodexImage
class="flex-initial mx-auto my-8 lg:my-16 min-w-sm max-w-md w-full rounded"
:cid="request.content.cid"
:local-only="!['New', 'Fulfilled'].includes(request.state)"
></CodexImage>
<div class="py-8 px-4 ml-4 max-w-2xl lg:py-16 flex-1">
<div class="flex justify-between items-center mb-2">
<h2 class="text-xl font-semibold leading-none text-gray-900 md:text-2xl dark:text-white">
Request {{ shortHex(requestId, 8) }}
Request <ShortenValue :value="requestId" :chars="8"></ShortenValue>
</h2>
<StateIndicator
:text="request.state"
:color="stateColour"
size="lg"
></StateIndicator>
<StateIndicator :text="request.state" :color="stateColour" size="lg"></StateIndicator>
</div>
<p class="mb-4 text-xl font-extrabold leading-none text-gray-900
md:text-2xl dark:text-white flex justify-between">
<div>{{ totalPrice }} CDX</div>
<p
class="mb-4 text-xl font-extrabold leading-none text-gray-900 md:text-2xl dark:text-white flex justify-between"
>
{{ totalPrice }} CDX
</p>
<dl>
<!-- <dt class="mb-2 font-semibold leading-none text-gray-900 dark:text-white">Dataset CID</dt> -->
<dd class="mb-4 font-light text-gray-500 sm:mb-5 dark:text-gray-400">
<RelativeTime :timestamp="new Date(request.requestedAt * 1000)"></RelativeTime>
</dd>
</dl>
<dl>
<dt class="mb-2 font-semibold leading-none text-gray-900 dark:text-white">Dataset CID</dt>
<dd class="mb-4 font-light text-gray-500 sm:mb-5 dark:text-gray-400">
@ -69,7 +74,9 @@ const stateColour = computed(() => getStateColour(props.request.state))
class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400 mx-auto"
>
<tbody>
<tr class="bg-white dark:bg-transparent hover:bg-gray-50 dark:hover:bg-gray-600 text-base">
<tr
class="bg-white dark:bg-transparent hover:bg-gray-50 dark:hover:bg-gray-600 text-base"
>
<td
class="flex items-center pr-1 py-2 font-medium text-gray-900 whitespace-nowrap dark:text-white border-r"
>
@ -77,7 +84,9 @@ const stateColour = computed(() => getStateColour(props.request.state))
</td>
<td class="px-6 py-2 font-light">{{ request.expiry }} seconds</td>
</tr>
<tr class="bg-white dark:bg-transparent hover:bg-gray-50 dark:hover:bg-gray-600 text-base">
<tr
class="bg-white dark:bg-transparent hover:bg-gray-50 dark:hover:bg-gray-600 text-base"
>
<td
class="flex items-center pr-1 py-2 font-medium text-gray-900 whitespace-nowrap dark:text-white border-r"
>
@ -85,7 +94,9 @@ const stateColour = computed(() => getStateColour(props.request.state))
</td>
<td class="px-6 py-2 font-light">{{ request.ask.duration }} seconds</td>
</tr>
<tr class="bg-white dark:bg-transparent hover:bg-gray-50 dark:hover:bg-gray-600 text-base">
<tr
class="bg-white dark:bg-transparent hover:bg-gray-50 dark:hover:bg-gray-600 text-base"
>
<td
class="flex items-center pr-1 py-2 font-medium text-gray-900 whitespace-nowrap dark:text-white border-r"
>
@ -93,7 +104,9 @@ const stateColour = computed(() => getStateColour(props.request.state))
</td>
<td class="px-6 py-2 font-light">{{ request.ask.slotSize }} bytes</td>
</tr>
<tr class="bg-white dark:bg-transparent hover:bg-gray-50 dark:hover:bg-gray-600 text-base">
<tr
class="bg-white dark:bg-transparent hover:bg-gray-50 dark:hover:bg-gray-600 text-base"
>
<td
class="flex items-center pr-1 py-2 font-medium text-gray-900 whitespace-nowrap dark:text-white border-r"
>
@ -101,7 +114,9 @@ const stateColour = computed(() => getStateColour(props.request.state))
</td>
<td class="px-6 py-2 font-light">{{ request.ask.proofProbability }}</td>
</tr>
<tr class="bg-white dark:bg-transparent hover:bg-gray-50 dark:hover:bg-gray-600 text-base">
<tr
class="bg-white dark:bg-transparent hover:bg-gray-50 dark:hover:bg-gray-600 text-base"
>
<td
class="flex items-center pr-1 py-2 font-medium text-gray-900 whitespace-nowrap dark:text-white border-r"
>
@ -129,7 +144,9 @@ const stateColour = computed(() => getStateColour(props.request.state))
</td>
<td class="px-6 py-2 font-light">{{ slots }}</td>
</tr>
<tr class="bg-white dark:bg-transparent hover:bg-gray-50 dark:hover:bg-gray-600 text-base">
<tr
class="bg-white dark:bg-transparent hover:bg-gray-50 dark:hover:bg-gray-600 text-base"
>
<td
class="flex items-center pr-1 py-2 font-medium text-gray-900 whitespace-nowrap dark:text-white border-r"
>

View File

@ -6,12 +6,20 @@ import { useRequestsStore } from '@/stores/requests'
import { storeToRefs } from 'pinia'
import CodexImage from '@/components/CodexImage.vue'
import StateIndicator from '@/components/StateIndicator.vue'
import { shortHex } from '@/utils/ids'
import RelativeTime from '@/components/RelativeTime.vue'
import ShortenValue from '@/components/ShortenValue.vue'
import { shorten } from '@/utils/ids'
import { getStateColour } from '@/utils/requests'
const requestsStore = useRequestsStore()
const { requests } = storeToRefs(requestsStore)
const router = useRouter()
const requestsOrdered = computed(() => {
const sorted = [...requests.value.entries()].sort(
([reqIdA, reqA], [reqIdB, reqB]) => reqB.requestedAt - reqA.requestedAt
)
return sorted
})
</script>
<template>
@ -131,7 +139,7 @@ const router = useRouter()
</thead>
<tbody>
<tr
v-for="([requestId, { content, state }], idx) in requests"
v-for="([requestId, { requestedAt, content, state }], idx) in requestsOrdered"
:key="{ requestId }"
class="cursor-pointer bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-600"
@click="router.push(`/request/${requestId}`)"
@ -146,11 +154,17 @@ const router = useRouter()
class="w-10 h-10 rounded-full mt-1"
/>
<div class="ps-3">
<div class="text-base font-semibold">{{ shortHex(requestId) }}</div>
<div class="font-normal text-gray-500">leslie@flowbite.com</div>
<div class="text-base font-semibold">
<ShortenValue :value="requestId"></ShortenValue>
</div>
<div class="font-normal text-gray-500">
<RelativeTime :timestamp="new Date(requestedAt * 1000)"></RelativeTime>
</div>
</div>
</th>
<td class="px-6 py-4">{{ shortHex(content.cid) }}</td>
<td class="px-6 py-4">
<ShortenValue :value="content.cid"></ShortenValue>
</td>
<td class="px-6 py-4">
<div class="flex items-center">
<StateIndicator :text="state" :color="getStateColour(state)"></StateIndicator>

View File

@ -0,0 +1,28 @@
<script setup>
import { onMounted, ref } from 'vue'
import { initTooltips } from 'flowbite'
// defineProps({
// id: {
// type: String,
// required: true
// }
// })
onMounted(() => {
initTooltips()
})
</script>
<template>
<!-- <slot :id="$idRef('tooltip')" name="text"></slot> -->
<p :data-tooltip-target="$id('tooltip')" class="cursor-help">
<slot name="text"></slot>
</p>
<div
:id="$id('tooltip')"
role="tooltip"
class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700"
>
<slot name="tooltip-content"></slot>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</template>

View File

@ -2,13 +2,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import uniqueId from '@/plugins/UniqueIdPlugin'
import App from './App.vue'
import router from './router'
import ethersPlugin from './plugins/EthersPlugin'
import codexPlugin from './plugins/CodexPlugin'
import marketplacePlugin from './plugins/MarketplacePlugin'
import testTokenPlugin from './plugins/TestTokenPlugin'
import App from '@/App.vue'
import router from '@/router'
import ethersPlugin from '@/plugins/EthersPlugin'
import codexPlugin from '@/plugins/CodexPlugin'
import marketplacePlugin from '@/plugins/MarketplacePlugin'
import testTokenPlugin from '@/plugins/TestTokenPlugin'
import './index.css'
@ -1084,6 +1085,7 @@ app.use(marketplacePlugin, {
}
],
address: '0x4cBDfab37baB0AA3AC69A7b12C4396907dfF5227'
// address: '0x9C88D67c7C745D2F0A4E411c18A6a22c15b37EaA' // new address!
})
app.use(testTokenPlugin, {
abi: [
@ -1387,5 +1389,5 @@ app.use(codexPlugin, {
codexRestUrl: 'http://localhost:8080/api/codex/v1',
myAddress: '0xE3b2588a05260caC3EEAbfBFd7937BbC14eB0aC7'
})
app.use(uniqueId)
app.mount('#app')

View File

@ -0,0 +1,70 @@
// Copyright (c) 2019, Bertrand Guay-Paquet <bernie@step.polymtl.ca>
//
// Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
const methods = {
/**
* Generate a component-scoped unique HTML identifier.
*
* Example: $id('my-id') => 'uid-42-my-id'
*
* @param {string} id id to scope
*/
$idFactory(uidProperty) {
return function $id(id = '') {
return `${this[uidProperty]}-${id}`;
};
},
/**
* Generate a component-scoped unique HTML identifier reference. Prepends '#' to the id generated
* by the call $id(id).
*
* Example: $idRef('my-id') => '#uid-42-my-id'
*
* @param {string} id id to scope
*/
$idRef(id) {
return `#${this.$id(id)}`;
},
};
const DEFAULTS = {
// {string} Property name of the component's unique identifier. Change this if 'vm.uid' conflicts
// with another plugin or your own props.
uidProperty: 'uid',
// {string} Prefix to use when generating HTML ids. Change this to make your ids more unique on a
// page that already uses or could use a similar naming scheme.
uidPrefix: 'uid-',
};
function installVueGlobal(Vue, globalName, globalValue) {
const globalPrototype = Vue.version.slice(0, 2) === '3.' ? Vue.config.globalProperties : Vue.prototype;
// Don't use Object.assign() to match the Vue.js supported browsers (ECMAScript 5)
globalPrototype[globalName] = globalValue;
}
export default function install(Vue, options = {}) {
// Don't use object spread to merge the defaults because bublé transforms that to Object.assign
const uidProperty = options.uidProperty || DEFAULTS.uidProperty;
const uidPrefix = options.uidPrefix || DEFAULTS.uidPrefix;
// Assign a unique id to each component
let uidCounter = 0;
Vue.mixin({
beforeCreate() {
uidCounter += 1;
const uid = uidPrefix + uidCounter;
Object.defineProperties(this, {
[uidProperty]: { get() { return uid; } },
});
},
});
installVueGlobal(Vue, '$id', methods.$idFactory(uidProperty));
installVueGlobal(Vue, '$idRef', methods.$idRef);
}

View File

@ -27,7 +27,7 @@ export const useRequestsStore = defineStore('request', () => {
// const requestFailedEvents = ref([]) // {blockNumber, requestId}
// const requestFinishedEvents = ref([]) // {blockNumber, requestId}
const loading = ref(false)
const fetched = ref(false)
const blocks = ref(new Map())
// const request = computed(() => count.value * 2)
// onStorageRequested => add request to requests ref, along with slots
@ -75,30 +75,58 @@ export const useRequestsStore = defineStore('request', () => {
// blockNumbers.value.add(blockNumber)
}
const getBlock = async (blockHash) => {
if (blocks.value.has(blockHash)) {
return blocks.value.get(blockHash)
} else {
let block = await ethProvider.getBlock(blockHash)
blocks.value.set(blockHash, block)
return block
}
}
async function fetch() {
// query past events
loading.value = true
try {
let pastRequests = await marketplace.queryFilter(StorageRequested)
pastRequests.forEach(async (event) => {
let events = await marketplace.queryFilter(StorageRequested)
console.log('got ', events.length, ' StorageRequested events')
let reqs = new Map()
events.forEach(async (event, i) => {
console.log('getting details for StorageRequested event ', i)
let start = Date.now()
// let event = events[i]
// await events.forEach(async (event) => {
let { requestId, ask, expiry } = event.args
// let { blockNumber } = event
let { blockHash, blockNumber } = event
let arrRequest = await marketplace.getRequest(requestId)
let request = arrayToObject(arrRequest)
let state = await getRequestState(requestId)
let slots = await getSlots(requestId, request.ask.slots)
let block = await getBlock(blockHash)
// populate temp map to constrain state update volume
// reqs.set(requestId, {
// ...request,
// state,
// slots: [],
// requestedAt: block.timestamp,
// requestFinishedId: null
// })
requests.value.set(requestId, {
...request,
state,
slots,
requestedAt: block.timestamp,
requestFinishedId: null
})
console.log(`got details for ${i} in ${(Date.now() - start) / 1000} seconds`)
if (i === events.length - 1) {
loading.value = false
}
})
// reqs.forEach((request, requestId) => requests.value.set(requestId, request))
} catch (error) {
console.error(`failed to load past contract events: ${error.message}`)
} finally {
loading.value = false
fetched.value = true
}
}
@ -152,15 +180,17 @@ export const useRequestsStore = defineStore('request', () => {
onSlotFilled
) {
marketplace.on(StorageRequested, async (requestId, ask, expiry, event) => {
let { blockNumber } = event.log
let { blockNumber, blockHash } = event.log
let arrRequest = await marketplace.getRequest(requestId)
let request = arrayToObject(arrRequest)
let state = await getRequestState(requestId)
let slots = await getSlots(requestId, request.ask.slots)
let block = await getBlock(blockHash)
requests.value.set(requestId, {
...request,
state,
slots,
requestedAt: block.timestamp,
requestFinishedId: null
})
@ -239,7 +269,6 @@ export const useRequestsStore = defineStore('request', () => {
// requestFinishedEvents,
fetch,
listenForNewEvents,
loading,
fetched
loading
}
})

View File

@ -1,28 +1,7 @@
import { keccak256 } from 'ethers/crypto'
import { AbiCoder } from 'ethers/abi'
// func shortLog*(long: string, ellipses = "*", start = 3, stop = 6): string =
// ## Returns compact string representation of ``long``.
// var short = long
// let minLen = start + ellipses.len + stop
// if len(short) > minLen:
// short.insert(ellipses, start)
// when (NimMajor, NimMinor) > (1, 4):
// short.delete(start + ellipses.len .. short.high - stop)
// else:
// short.delete(start + ellipses.len, short.high - stop)
// short
// func shortHexLog*(long: string): string =
// if long[0..1] == "0x": result &= "0x"
// result &= long[2..long.high].shortLog("..", 4, 4)
// func short0xHexLog*[N: static[int], T: array[N, byte]](v: T): string =
// v.to0xHex.shortHexLog
export function short(long, ellipses = '*', start = 3, stop = 6) {
function short(long, ellipses = '*', start = 3, stop = 6) {
var short = long
const minLen = start + ellipses.length + stop
if (short.length > minLen) {
@ -33,14 +12,14 @@ export function short(long, ellipses = '*', start = 3, stop = 6) {
return short
}
export function shortHex(long, chars = 4) {
export function shorten(long, ellipses = '..', chars = 4) {
let shortened = ''
let rest = long
if (long.substring(0, 2) === '0x') {
shortened = '0x'
rest = long.substring(2)
}
shortened += short(rest, '..', chars, chars)
shortened += short(rest, ellipses, chars, chars)
return shortened
}

View File

@ -6,9 +6,9 @@ import SkeletonLoading from '@/components/SkeletonLoading.vue'
import { computed } from 'vue'
const requestsStore = useRequestsStore()
const { loading, fetched, requests } = storeToRefs(requestsStore)
const { loading, requests } = storeToRefs(requestsStore)
const isLoading = computed(
() => loading.value || !fetched.value || !requests.value || requests.value?.size === 0
() => loading.value || !requests.value
)
</script>

View File

@ -470,6 +470,11 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f"
integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==
"@feelinglovelynow/get-relative-time@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@feelinglovelynow/get-relative-time/-/get-relative-time-1.1.2.tgz#7c11a0345926918094c9745085f72fd591916338"
integrity sha512-3nnyH71EkAVH5sdxJpWWBjRTm5yXNtOQSPp1WtkKMd1//noO//tzdJShsDfCy9D1XQV0BbAPJ2btUstqtMT2mw==
"@humanwhocodes/config-array@^0.11.14":
version "0.11.14"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b"
@ -2603,6 +2608,11 @@ vue-router@^4.3.0:
dependencies:
"@vue/devtools-api" "^6.5.1"
vue-unique-id@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/vue-unique-id/-/vue-unique-id-3.2.1.tgz#e5db2e8620409a4b718578ca7f00eac78b15e818"
integrity sha512-Ih4vw3nx5O0M0q16Omx23T6VQW5U+RD16SEEbGa+aJWLJxFco8slQN6z6GUuzwgREgofH3ns9n7a97qr6DI73g==
vue@^3.4.21:
version "3.4.27"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.4.27.tgz#40b7d929d3e53f427f7f5945386234d2854cc2a1"