@meddleware/walrus-storage-ui 0.1.64 → 0.1.66
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/CHANGELOG.md +8 -0
- package/package.json +22 -12
- package/src/App.vue +20 -41
- package/src/components/BlobGroupRows.vue +133 -0
- package/src/components/ExtendControl.vue +102 -0
- package/src/components/MyBlobs.vue +108 -345
- package/src/components/UploadResultCard.vue +35 -0
- package/src/components/WalrusStorageView.vue +94 -310
- package/src/composables/useExtendQuotes.ts +3 -3
- package/src/composables/useOwnedBlobs.ts +8 -9
- package/src/composables/useStorageUpload.ts +111 -0
- package/src/config.ts +3 -16
- package/src/e2e-data.ts +11 -0
- package/src/e2e.ts +46 -0
- package/src/main.ts +3 -1
- package/src/wallet.ts +5 -44
- package/src/walrus.ts +29 -0
- package/vite.config.ts +13 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to `@meddleware/walrus-ui` are documented here.
|
|
|
4
4
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
|
|
5
5
|
[Semantic Versioning](https://semver.org/). Before 0.2.0, breaking changes ship as patch releases.
|
|
6
6
|
|
|
7
|
+
## [0.1.66] - 2026-10-31
|
|
8
|
+
|
|
9
|
+
- Gated upload composable, blob table split, accessibility gates
|
|
10
|
+
|
|
11
|
+
## [0.1.65] - 2026-10-30
|
|
12
|
+
|
|
13
|
+
- Dependency updates (minor and patch groups) and workflow action bumps. No source change.
|
|
14
|
+
|
|
7
15
|
## [0.1.64] - 2026-10-30
|
|
8
16
|
|
|
9
17
|
- Point the developer-docs and indexer links at the machine zone (ADR-0005); current @meddleware packages.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meddleware/walrus-storage-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.66",
|
|
4
4
|
"homepage": "https://docs.meddleware.co.uk/blockchain/sui/walrus-storage/",
|
|
5
5
|
"description": "Standalone Vue 3 SPA for uploading blobs to Walrus decentralised storage and managing owned blobs — wallet-connected, NFT-gated relay support.",
|
|
6
6
|
"author": "Meddleware <dev@meddleware.co.uk>",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"dev": "vite",
|
|
33
33
|
"build": "vue-tsc --noEmit && vite build",
|
|
34
|
+
"build:e2e": "VITE_E2E=1 vite build --mode e2e",
|
|
34
35
|
"preview": "vite preview",
|
|
35
36
|
"type-check": "vue-tsc --noEmit",
|
|
36
37
|
"test": "vitest run",
|
|
@@ -41,16 +42,19 @@
|
|
|
41
42
|
"lint": "npm run lint:css && npm run lint:js && npm run lint:html",
|
|
42
43
|
"lint:html": "html-validate \"src/**/*.vue\"",
|
|
43
44
|
"licenses": "node scripts/third-party-licenses.mjs",
|
|
44
|
-
"check:licenses": "node scripts/third-party-licenses.mjs --check"
|
|
45
|
+
"check:licenses": "node scripts/third-party-licenses.mjs --check",
|
|
46
|
+
"shots": "SHOTS=1 playwright test",
|
|
47
|
+
"lhci": "npm run build && lhci autorun"
|
|
45
48
|
},
|
|
46
49
|
"publishConfig": {
|
|
47
50
|
"access": "public"
|
|
48
51
|
},
|
|
49
52
|
"dependencies": {
|
|
50
|
-
"@meddleware/design-tokens": "^0.1.
|
|
51
|
-
"@meddleware/ui": "^0.1.
|
|
52
|
-
"@meddleware/walrus-storage-client": "^0.0.
|
|
53
|
-
"@meddleware/walrus-storage-kit": "^0.1.
|
|
53
|
+
"@meddleware/design-tokens": "^0.1.12",
|
|
54
|
+
"@meddleware/ui": "^0.1.37",
|
|
55
|
+
"@meddleware/walrus-storage-client": "^0.0.34",
|
|
56
|
+
"@meddleware/walrus-storage-kit": "^0.1.36",
|
|
57
|
+
"@meddleware/web-utils": "^0.0.1",
|
|
54
58
|
"@mysten/sui": "^2.33.2",
|
|
55
59
|
"@mysten/walrus": "~1.2.32",
|
|
56
60
|
"@mysten/walrus-wasm": "~0.3.1",
|
|
@@ -60,13 +64,15 @@
|
|
|
60
64
|
"@meddleware/wallet-adapter": ">=0.0.12 <0.2.0"
|
|
61
65
|
},
|
|
62
66
|
"devDependencies": {
|
|
67
|
+
"@lhci/cli": "^0.15.1",
|
|
63
68
|
"@meddleware/access-gate-client": "^0.0.13",
|
|
64
|
-
"@meddleware/
|
|
65
|
-
"@meddleware/wallet-adapter": "^0.0.
|
|
69
|
+
"@meddleware/frontend-config": "^0.0.1",
|
|
70
|
+
"@meddleware/wallet-adapter": "^0.0.20",
|
|
66
71
|
"@playwright/test": "~1.64.1",
|
|
67
72
|
"@types/node": "~26.6.5",
|
|
68
73
|
"@vitejs/plugin-vue": "^6.0.8",
|
|
69
74
|
"@vue/eslint-config-typescript": "^14.9.0",
|
|
75
|
+
"@vue/test-utils": "^2.5.1",
|
|
70
76
|
"autoprefixer": "^10.4.20",
|
|
71
77
|
"eslint": "^10.11.0",
|
|
72
78
|
"eslint-plugin-vue": "^10.11.1",
|
|
@@ -74,6 +80,7 @@
|
|
|
74
80
|
"html-validate": "^11.16.1",
|
|
75
81
|
"html-validate-vue": "^9.3.1",
|
|
76
82
|
"jiti": "^2.7.0",
|
|
83
|
+
"jsdom": "^30.1.0",
|
|
77
84
|
"postcss": "^8.4.49",
|
|
78
85
|
"postcss-html": "^2.0.0",
|
|
79
86
|
"stylelint": "^17.15.0",
|
|
@@ -88,8 +95,11 @@
|
|
|
88
95
|
"node": "^22.18.0 || >=24.12.0"
|
|
89
96
|
},
|
|
90
97
|
"browserslist": [
|
|
91
|
-
"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
"extends @meddleware/frontend-config/browserslist-config"
|
|
99
|
+
],
|
|
100
|
+
"overrides": {
|
|
101
|
+
"basic-ftp": "^6.2.4",
|
|
102
|
+
"tmp": "^0.2.7",
|
|
103
|
+
"zod": "^4.6.7"
|
|
104
|
+
}
|
|
95
105
|
}
|
package/src/App.vue
CHANGED
|
@@ -1,56 +1,35 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// Standalone shell for the Walrus SPA: app header + footer wrapping the core tool view.
|
|
3
3
|
// The core UI lives in WalrusStorageView.vue (also exported for inline embedding in the dashboard).
|
|
4
|
-
import {
|
|
4
|
+
import { AppFooter, AppHeader, ColorModeControl, CopyrightLine, UiAppShell, useColorMode } from '@meddleware/ui'
|
|
5
5
|
import { TipConfigBadge } from '@meddleware/walrus-storage-kit'
|
|
6
6
|
import { walrusNetwork, OPERATOR_RELAY_HOSTS } from './config.js'
|
|
7
7
|
import WalrusStorageView from './components/WalrusStorageView.vue'
|
|
8
8
|
|
|
9
9
|
const { mode, set } = useColorMode('dark')
|
|
10
10
|
const DOCS_URL = import.meta.env.VITE_DOCS_URL || 'https://docs.meddleware.co.uk/blockchain/sui/walrus-storage/'
|
|
11
|
-
const DEV_URL
|
|
11
|
+
const DEV_URL = import.meta.env.VITE_DEV_URL || 'https://meddleware.dev/sui/walrus-storage/'
|
|
12
12
|
</script>
|
|
13
13
|
|
|
14
14
|
<template>
|
|
15
|
-
<
|
|
16
|
-
<
|
|
17
|
-
<
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
</
|
|
24
|
-
</
|
|
15
|
+
<UiAppShell contained>
|
|
16
|
+
<template #header>
|
|
17
|
+
<AppHeader variant="dark">
|
|
18
|
+
<template #brand>Walrus Assets</template>
|
|
19
|
+
<template #actions>
|
|
20
|
+
<TipConfigBadge v-if="walrusNetwork" :host="OPERATOR_RELAY_HOSTS[walrusNetwork]" />
|
|
21
|
+
<ColorModeControl :model-value="mode" @update:model-value="set" />
|
|
22
|
+
</template>
|
|
23
|
+
</AppHeader>
|
|
24
|
+
</template>
|
|
25
25
|
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
</main>
|
|
26
|
+
<h1 class="mw-visually-hidden">Walrus Assets</h1>
|
|
27
|
+
<WalrusStorageView />
|
|
29
28
|
|
|
30
|
-
<
|
|
31
|
-
|
|
29
|
+
<template #footer>
|
|
30
|
+
<AppFooter :docs-url="DOCS_URL" :dev-url="DEV_URL">
|
|
31
|
+
<CopyrightLine symbol-variant="kopimi" organisation-name="Meddleware" rights-statement="jam" />
|
|
32
|
+
</AppFooter>
|
|
33
|
+
</template>
|
|
34
|
+
</UiAppShell>
|
|
32
35
|
</template>
|
|
33
|
-
|
|
34
|
-
<style scoped>
|
|
35
|
-
.app {
|
|
36
|
-
min-height: 100vh;
|
|
37
|
-
display: flex;
|
|
38
|
-
flex-direction: column;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/* Centre the tool at the shared tool-content width when running standalone. The dashboard
|
|
42
|
-
supplies its own width container, so this lives in the shell, not WalrusStorageView. */
|
|
43
|
-
.app__content {
|
|
44
|
-
flex: 1;
|
|
45
|
-
width: 100%;
|
|
46
|
-
max-width: var(--mw-tool-content-max);
|
|
47
|
-
margin: 0 auto;
|
|
48
|
-
box-sizing: border-box;
|
|
49
|
-
padding: 1.5rem 1.25rem 4rem;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
.brand-title {
|
|
53
|
-
font: inherit;
|
|
54
|
-
margin: 0;
|
|
55
|
-
}
|
|
56
|
-
</style>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// One blob's rows in the My Blobs table: the group row (re-uploads of the same content are collapsed into it), and, when expanded, one
|
|
3
|
+
// row per underlying copy so a specific pending copy can be certified. Presentational: the parent owns the data and runs the actions.
|
|
4
|
+
import { computed } from 'vue'
|
|
5
|
+
import { CopyableAddress, ExplorerLink, UiBadge, UiButton, UiLiveStatus } from '@meddleware/ui'
|
|
6
|
+
import { formatBytes, truncateMiddle, walruscanBlobUrl } from '@meddleware/web-utils'
|
|
7
|
+
import type { WalrusNetwork } from '@meddleware/walrus-storage-kit'
|
|
8
|
+
import type { OwnedBlob } from '@meddleware/walrus-storage-client'
|
|
9
|
+
import type { PendingCertify } from '@meddleware/walrus-storage-client/flow'
|
|
10
|
+
import { expiryLabel, maxExtendableEpochs, type BlobGroup } from '../blob-groups.js'
|
|
11
|
+
import type { EstimateExtendCost } from '../composables/useExtendQuotes.js'
|
|
12
|
+
import ExtendControl from './ExtendControl.vue'
|
|
13
|
+
|
|
14
|
+
/** Expiring within this many epochs is flagged. */
|
|
15
|
+
const SOON_EPOCHS = 10
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
group: BlobGroup
|
|
19
|
+
network: WalrusNetwork
|
|
20
|
+
currentEpoch: number
|
|
21
|
+
/** The longest reservation the chain allows (epochs ahead). */
|
|
22
|
+
maxReservation: number
|
|
23
|
+
/** Routed here from the duplicate-upload dialog. */
|
|
24
|
+
highlighted: boolean
|
|
25
|
+
expanded: boolean
|
|
26
|
+
/** Stored certificates of uploaded-but-uncertified copies, by Blob object id. */
|
|
27
|
+
pending: Record<string, PendingCertify>
|
|
28
|
+
/** The Blob object id whose action is running, or null. */
|
|
29
|
+
busy: string | null
|
|
30
|
+
/** The last action's outcome, by Blob object id. */
|
|
31
|
+
statuses: Record<string, string>
|
|
32
|
+
estimate: EstimateExtendCost
|
|
33
|
+
}>()
|
|
34
|
+
const emit = defineEmits<{ toggle: []; extend: [blob: OwnedBlob, epochs: number]; certify: [blob: OwnedBlob] }>()
|
|
35
|
+
|
|
36
|
+
const representative = computed(() => props.group.representative)
|
|
37
|
+
const certifiable = computed(() => props.group.copies.find((c) => !c.certified && props.pending[c.objectId]) ?? null)
|
|
38
|
+
const certification = computed<'certified' | 'pending' | 'none'>(() => (props.group.anyCertified ? 'certified' : certifiable.value ? 'pending' : 'none'))
|
|
39
|
+
const maxAddable = computed(() => maxExtendableEpochs(representative.value.endEpoch, props.currentEpoch, props.maxReservation))
|
|
40
|
+
const expiresSoon = computed(() => props.group.maxEndEpoch - props.currentEpoch < SOON_EPOCHS)
|
|
41
|
+
const shownCopies = computed(() => (props.expanded ? props.group.copies : []))
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<tr :id="`blob-row-${group.blobId}`" :class="{ highlight: highlighted }" :aria-current="highlighted || undefined">
|
|
46
|
+
<th scope="row">
|
|
47
|
+
<CopyableAddress :address="group.blobId" label="Copy blob ID">
|
|
48
|
+
<ExplorerLink :href="walruscanBlobUrl(network, group.blobId)" :value="group.blobId" :chars="[8, 6]" />
|
|
49
|
+
</CopyableAddress>
|
|
50
|
+
<UiButton v-if="group.copies.length > 1" class="toggle" variant="link" size="sm" :aria-expanded="expanded" @click="emit('toggle')">
|
|
51
|
+
<span aria-hidden="true">{{ expanded ? '▾' : '▸' }}</span> {{ group.copies.length }} copies
|
|
52
|
+
</UiButton>
|
|
53
|
+
</th>
|
|
54
|
+
<td class="size">{{ formatBytes(group.size) }}</td>
|
|
55
|
+
<td>
|
|
56
|
+
{{ expiryLabel(group.maxEndEpoch, currentEpoch) }}
|
|
57
|
+
<UiBadge v-if="expiresSoon" tone="warn">Expiring soon</UiBadge>
|
|
58
|
+
</td>
|
|
59
|
+
<td>
|
|
60
|
+
<UiBadge v-if="certification === 'certified'" tone="ok">Certified</UiBadge>
|
|
61
|
+
<UiBadge v-else-if="certification === 'pending'" tone="warn">Uploaded, not certified</UiBadge>
|
|
62
|
+
<UiBadge v-else tone="neutral" :symbol="false">Not certified</UiBadge>
|
|
63
|
+
</td>
|
|
64
|
+
<td class="actions">
|
|
65
|
+
<UiButton v-if="certifiable" size="sm" variant="secondary" :disabled="busy !== null" :busy="busy === certifiable.objectId" @click="emit('certify', certifiable)">
|
|
66
|
+
Certify
|
|
67
|
+
</UiButton>
|
|
68
|
+
<p v-if="maxAddable === 0" class="at-max">At max lifetime</p>
|
|
69
|
+
<ExtendControl
|
|
70
|
+
v-else
|
|
71
|
+
:blob="representative"
|
|
72
|
+
:max="maxAddable"
|
|
73
|
+
:disabled="busy !== null"
|
|
74
|
+
:busy="busy === representative.objectId"
|
|
75
|
+
:estimate="estimate"
|
|
76
|
+
@extend="(epochs) => emit('extend', representative, epochs)"
|
|
77
|
+
/>
|
|
78
|
+
<UiLiveStatus label="Result" :message="statuses[representative.objectId] ?? ''" />
|
|
79
|
+
</td>
|
|
80
|
+
</tr>
|
|
81
|
+
|
|
82
|
+
<!-- Per-copy detail for grouped duplicates: certify a specific pending copy. -->
|
|
83
|
+
<tr v-for="c in shownCopies" :key="c.objectId" class="copy-row">
|
|
84
|
+
<th scope="row">
|
|
85
|
+
<CopyableAddress :address="c.objectId" label="Copy object ID">{{ truncateMiddle(c.objectId, [10, 0]) }}</CopyableAddress>
|
|
86
|
+
</th>
|
|
87
|
+
<td></td>
|
|
88
|
+
<td>{{ expiryLabel(c.endEpoch, currentEpoch) }}</td>
|
|
89
|
+
<td>
|
|
90
|
+
<UiBadge v-if="c.certified" tone="ok">Certified</UiBadge>
|
|
91
|
+
<UiBadge v-else-if="pending[c.objectId]" tone="warn">Uploaded, not certified</UiBadge>
|
|
92
|
+
<UiBadge v-else tone="neutral" :symbol="false">Not certified</UiBadge>
|
|
93
|
+
</td>
|
|
94
|
+
<td class="actions">
|
|
95
|
+
<UiButton v-if="!c.certified && pending[c.objectId]" size="sm" variant="secondary" :disabled="busy !== null" :busy="busy === c.objectId" @click="emit('certify', c)">
|
|
96
|
+
Certify
|
|
97
|
+
</UiButton>
|
|
98
|
+
<UiLiveStatus label="Result" :message="statuses[c.objectId] ?? ''" />
|
|
99
|
+
</td>
|
|
100
|
+
</tr>
|
|
101
|
+
</template>
|
|
102
|
+
|
|
103
|
+
<style scoped>
|
|
104
|
+
/* A truncated id and a size stay on one line each; the table scrolls inside its own box when the screen is narrower. */
|
|
105
|
+
th {
|
|
106
|
+
white-space: nowrap;
|
|
107
|
+
}
|
|
108
|
+
.size {
|
|
109
|
+
white-space: nowrap;
|
|
110
|
+
}
|
|
111
|
+
.highlight > * {
|
|
112
|
+
background: color-mix(in srgb, var(--accent) 14%, var(--surface));
|
|
113
|
+
}
|
|
114
|
+
.copy-row > * {
|
|
115
|
+
background: var(--lift);
|
|
116
|
+
font-size: var(--font-size-xs);
|
|
117
|
+
}
|
|
118
|
+
.actions {
|
|
119
|
+
min-width: 14em;
|
|
120
|
+
}
|
|
121
|
+
/* The toggle sits on its own line under the blob link (the cell does not wrap, so left inline it would spill into the next column), and
|
|
122
|
+
* the two targets stay 24px apart (WCAG 2.5.8). */
|
|
123
|
+
.toggle {
|
|
124
|
+
display: flex;
|
|
125
|
+
inline-size: fit-content;
|
|
126
|
+
margin-block-start: var(--space-2xs);
|
|
127
|
+
}
|
|
128
|
+
.at-max {
|
|
129
|
+
margin: 0;
|
|
130
|
+
color: var(--muted);
|
|
131
|
+
font-size: var(--font-size-sm);
|
|
132
|
+
}
|
|
133
|
+
</style>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Extend one blob's storage: choose how many epochs to add (clamped to what the chain still allows), see what that costs in WAL, then
|
|
3
|
+
// extend. The wallet opens only for an amount whose cost has been shown (audit F12): the first press prices the amount, the second
|
|
4
|
+
// confirms. The quote is an estimate (≈); the wallet and the chain decide what is actually paid.
|
|
5
|
+
import { computed, ref, watch } from 'vue'
|
|
6
|
+
import { UiButton, UiInput, UiLiveStatus } from '@meddleware/ui'
|
|
7
|
+
import { truncateMiddle } from '@meddleware/web-utils'
|
|
8
|
+
import { formatCoinAmount } from '@meddleware/walrus-storage-kit'
|
|
9
|
+
import type { OwnedBlob } from '@meddleware/walrus-storage-client'
|
|
10
|
+
import { useExtendQuotes, type EstimateExtendCost } from '../composables/useExtendQuotes.js'
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
/** The blob (the group's furthest-expiry copy) to extend. */
|
|
14
|
+
blob: OwnedBlob
|
|
15
|
+
/** Epochs that can still be added; the control is not shown at 0. */
|
|
16
|
+
max: number
|
|
17
|
+
/** Another action is running. */
|
|
18
|
+
disabled: boolean
|
|
19
|
+
/** This blob's extension is running. */
|
|
20
|
+
busy: boolean
|
|
21
|
+
/** Prices storing `size` bytes for `epochs` (WAL, FROST). */
|
|
22
|
+
estimate: EstimateExtendCost
|
|
23
|
+
}>()
|
|
24
|
+
const emit = defineEmits<{ extend: [epochs: number] }>()
|
|
25
|
+
|
|
26
|
+
const quotes = useExtendQuotes(props.estimate)
|
|
27
|
+
// Seeded with a sensible default (+10, clamped) without pricing it: the estimate is fetched on the first interaction, not for every row.
|
|
28
|
+
const amount = ref(Math.min(10, props.max))
|
|
29
|
+
|
|
30
|
+
function clamp(value: number | string): number {
|
|
31
|
+
return Math.min(props.max, Math.max(1, Math.floor(Number(value) || 1)))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The person picked an amount: clamp it and (re)price it. */
|
|
35
|
+
function pick(value: number | string): void {
|
|
36
|
+
amount.value = clamp(value)
|
|
37
|
+
void quotes.price(props.blob.objectId, props.blob.size, amount.value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const quote = computed(() => quotes.quoteFor(props.blob.objectId, amount.value))
|
|
41
|
+
const quoteText = computed(() => {
|
|
42
|
+
const q = quote.value
|
|
43
|
+
if (q?.state === 'ready') return `≈${formatCoinAmount(q.costFrost, 'WAL')} for +${q.epochs} epochs`
|
|
44
|
+
if (q?.state === 'unavailable') return 'price unavailable'
|
|
45
|
+
return quotes.pricing.value[props.blob.objectId] === amount.value ? 'pricing…' : ''
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
async function extend(): Promise<void> {
|
|
49
|
+
if (props.disabled || props.busy) return
|
|
50
|
+
if (await quotes.shownBeforeSigning(props.blob.objectId, props.blob.size, amount.value)) emit('extend', amount.value)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// An extension changes the blob's end epoch (and what it may still add): the old quote no longer applies.
|
|
54
|
+
watch(
|
|
55
|
+
() => props.blob.endEpoch,
|
|
56
|
+
() => {
|
|
57
|
+
quotes.clear(props.blob.objectId)
|
|
58
|
+
amount.value = clamp(amount.value)
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
watch(
|
|
62
|
+
() => props.max,
|
|
63
|
+
(max) => {
|
|
64
|
+
if (amount.value > max) amount.value = Math.max(1, max)
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<fieldset class="extend">
|
|
71
|
+
<legend class="mw-visually-hidden">Extend {{ truncateMiddle(blob.blobId, [8, 6]) }}</legend>
|
|
72
|
+
<UiInput
|
|
73
|
+
type="number"
|
|
74
|
+
min="1"
|
|
75
|
+
:max="max"
|
|
76
|
+
:model-value="amount"
|
|
77
|
+
:aria-label="`Epochs to add to ${truncateMiddle(blob.blobId, [8, 6])}`"
|
|
78
|
+
@update:model-value="pick($event ?? 1)"
|
|
79
|
+
/>
|
|
80
|
+
<UiButton variant="ghost" size="sm" @click="pick(10)">+10</UiButton>
|
|
81
|
+
<UiButton variant="ghost" size="sm" @click="pick(25)">+25</UiButton>
|
|
82
|
+
<UiButton variant="ghost" size="sm" @click="pick(max)">Max</UiButton>
|
|
83
|
+
<UiButton size="sm" :disabled="disabled" :busy="busy" @click="extend">{{ quote ? 'Extend' : 'Show cost' }}</UiButton>
|
|
84
|
+
<UiLiveStatus label="Extension cost" :message="quoteText" />
|
|
85
|
+
</fieldset>
|
|
86
|
+
</template>
|
|
87
|
+
|
|
88
|
+
<style scoped>
|
|
89
|
+
.extend {
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-wrap: wrap;
|
|
92
|
+
align-items: center;
|
|
93
|
+
gap: var(--space-3xs);
|
|
94
|
+
min-width: 0;
|
|
95
|
+
margin: 0;
|
|
96
|
+
padding: 0;
|
|
97
|
+
border: 0;
|
|
98
|
+
}
|
|
99
|
+
.extend :deep(.mw-input) {
|
|
100
|
+
width: 5em;
|
|
101
|
+
}
|
|
102
|
+
</style>
|