@meddleware/walrus-storage-ui 0.0.0-stage → 0.1.62

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.
@@ -0,0 +1,442 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onMounted, ref, watch } from 'vue'
3
+ import { CopyableAddress, ExplorerLink } from '@meddleware/ui'
4
+ import type { OwnedBlob } from '@meddleware/walrus-storage-client'
5
+ import { MAX_SINGLE_RESERVATION_EPOCHS, formatCoinAmount } from '@meddleware/walrus-storage-kit'
6
+ import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
7
+ import type { Executor } from '../wallet.js'
8
+ import { walrusNetwork, walruscanBlobUrl } from '../config.js'
9
+ import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
10
+ import { useExtendQuotes } from '../composables/useExtendQuotes.js'
11
+ import { groupBlobs, expiryLabel, maxExtendableEpochs, type BlobGroup } from '../blob-groups.js'
12
+ import {
13
+ pendingCertifyKey,
14
+ loadPendingCertifies,
15
+ clearPendingCertify,
16
+ type PendingCertify,
17
+ browserStorage,
18
+ } from '@meddleware/walrus-storage-client/flow'
19
+
20
+ // Browser storage that never throws (blocked site data falls back to memory for this page).
21
+ const storage = browserStorage()
22
+
23
+ const props = defineProps<{
24
+ /** Connected wallet address whose owned blobs to list; `null` when no wallet is connected. */
25
+ address: string | null
26
+ /** Factory that builds a transaction {@link Executor} bound to the connected wallet. */
27
+ buildExecutor: () => Promise<Executor>
28
+ /** When set, the matching group is expanded + scrolled into view (from the duplicate-upload flow). */
29
+ highlightBlobId?: string | null
30
+ }>()
31
+
32
+ /** The active Walrus network; the view is only rendered on one (WalrusStorageView gates it). */
33
+ function currentNetwork(): 'testnet' | 'mainnet' {
34
+ const net = walrusNetwork.value
35
+ if (!net) throw new Error('Walrus storage is not available on this network.')
36
+ return net
37
+ }
38
+
39
+ // Shared, session-persistent cache (survives tab switches and inline re-mounts).
40
+ const { blobs, currentEpoch, loading, error, load } = useOwnedBlobs()
41
+
42
+ // One row per blobId; re-uploads of the same content are collapsed into a group's `copies`.
43
+ const groups = computed(() => groupBlobs(blobs.value))
44
+ const expanded = ref<Set<string>>(new Set())
45
+
46
+ // Pending certifications (uploaded-but-not-certified copies we still hold a certificate for).
47
+ const pending = ref<Record<string, PendingCertify>>({})
48
+
49
+ // Per-object action state (keyed by Blob objectId), shared by Extend and Certify.
50
+ const busy = ref<string | null>(null)
51
+ const actionStatus = ref<Record<string, string>>({})
52
+ const extendAmount = ref<Record<string, number>>({})
53
+
54
+ const MAX = MAX_SINGLE_RESERVATION_EPOCHS
55
+
56
+ function msg(e: unknown): string {
57
+ return e instanceof Error ? e.message : String(e)
58
+ }
59
+
60
+ function maxAddable(endEpoch: number): number {
61
+ return maxExtendableEpochs(endEpoch, currentEpoch.value, MAX)
62
+ }
63
+
64
+ /** The stored certificate for an uncertified copy, if we can certify it here. */
65
+ function pendingFor(objectId: string): PendingCertify | null {
66
+ return pending.value[objectId] ?? null
67
+ }
68
+ /** The first pending (certifiable) copy in a group, if any. */
69
+ function groupPendingCopy(g: BlobGroup): OwnedBlob | null {
70
+ return g.copies.find((c) => !c.certified && pending.value[c.objectId]) ?? null
71
+ }
72
+ /** Status shown in the Certified column for a group. */
73
+ function groupCertStatus(g: BlobGroup): 'certified' | 'pending' | 'none' {
74
+ if (g.anyCertified) return 'certified'
75
+ return groupPendingCopy(g) ? 'pending' : 'none'
76
+ }
77
+
78
+ function toggleExpand(blobId: string): void {
79
+ const next = new Set(expanded.value)
80
+ if (next.has(blobId)) next.delete(blobId)
81
+ else next.add(blobId)
82
+ expanded.value = next
83
+ }
84
+
85
+ function refresh(): Promise<void> {
86
+ return load(props.address, { force: true })
87
+ }
88
+
89
+ /** Reload the pending map from storage, dropping entries whose blob is already certified. */
90
+ function refreshPending(): void {
91
+ if (!props.address || !walrusNetwork.value) {
92
+ pending.value = {}
93
+ return
94
+ }
95
+ const key = pendingCertifyKey(walrusNetwork.value, props.address)
96
+ const map = loadPendingCertifies(storage, key)
97
+ for (const blob of blobs.value) {
98
+ if (blob.certified && blob.objectId in map) {
99
+ clearPendingCertify(storage, key, blob.objectId)
100
+ delete map[blob.objectId]
101
+ }
102
+ }
103
+ pending.value = map
104
+ }
105
+
106
+ // ── Extend ──────────────────────────────────────────────────────────────────
107
+ // Extend adds storage only (no one-time write cost), so the quote prices the `storageCost` component.
108
+ async function estimateExtendCost(size: number, epochs: number): Promise<bigint | null> {
109
+ const { createWalrusClient, estimateStorageCost } = await import('@meddleware/walrus-storage-client')
110
+ const walrusClient = createWalrusClient({ network: currentNetwork(), wasmUrl: walrusWasmUrl })
111
+ return (await estimateStorageCost(walrusClient, size, epochs)).storageCost
112
+ }
113
+ const extendQuotes = useExtendQuotes(estimateExtendCost)
114
+
115
+ // User picked an amount → clamp and (re)price it. Only the picked amount is priced here, not every
116
+ // row on load, to avoid spinning up a Walrus client per blob; a row that was never touched is priced
117
+ // when Extend is first pressed (see `extendBlob`), before the wallet opens.
118
+ function setExtendAmount(blob: OwnedBlob, value: number | string): void {
119
+ const max = maxAddable(blob.endEpoch)
120
+ const v = Math.min(max, Math.max(1, Math.floor(Number(value) || 1)))
121
+ extendAmount.value = { ...extendAmount.value, [blob.objectId]: v }
122
+ void extendQuotes.price(blob.objectId, blob.size, v)
123
+ }
124
+
125
+ /** What the row shows beside the Extend button for its current amount. */
126
+ function extendQuoteLabel(blob: OwnedBlob): string {
127
+ const epochs = extendAmount.value[blob.objectId]
128
+ const q = extendQuotes.quoteFor(blob.objectId, epochs)
129
+ if (q?.state === 'ready') return `≈${formatCoinAmount(q.costFrost, 'WAL')} for +${q.epochs} epochs`
130
+ if (q?.state === 'unavailable') return 'price unavailable'
131
+ return extendQuotes.pricing.value[blob.objectId] === epochs ? 'pricing…' : ''
132
+ }
133
+
134
+ async function extendBlob(blob: OwnedBlob): Promise<void> {
135
+ const epochs = extendAmount.value[blob.objectId]
136
+ if (!epochs || busy.value) return
137
+ // The wallet opens only for an amount whose cost the user has been shown. The seeded default has
138
+ // no quote yet: price it now, show it, and let the user press Extend again to confirm.
139
+ if (!(await extendQuotes.shownBeforeSigning(blob.objectId, blob.size, epochs))) return
140
+ busy.value = blob.objectId
141
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Building transaction…' }
142
+ try {
143
+ const { createWalrusClient, extendBlobLifetimeTransaction } = await import('@meddleware/walrus-storage-client')
144
+ const walrusClient = createWalrusClient({ network: currentNetwork(), wasmUrl: walrusWasmUrl })
145
+ const tx = await extendBlobLifetimeTransaction(walrusClient, blob.objectId, { epochs })
146
+ const executor = await props.buildExecutor()
147
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Approve in wallet…' }
148
+ const { digest } = await executor.signAndExecute(tx)
149
+ await executor.waitForTransaction(digest)
150
+ extendQuotes.clear(blob.objectId)
151
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Extended ✓ (${digest.slice(0, 8)}…)` }
152
+ await refresh()
153
+ } catch (e) {
154
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Failed: ${msg(e)}` }
155
+ } finally {
156
+ busy.value = null
157
+ }
158
+ }
159
+
160
+ // ── Certify (resume a pending upload from its stored certificate) ─────────────
161
+ async function certifyBlob(blob: OwnedBlob): Promise<void> {
162
+ const entry = pendingFor(blob.objectId)
163
+ if (!entry || !props.address || busy.value) return
164
+ busy.value = blob.objectId
165
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Building transaction…' }
166
+ try {
167
+ const { createWalrusClient, certifyBlobTransaction } = await import('@meddleware/walrus-storage-client')
168
+ const walrusClient = createWalrusClient({ network: currentNetwork(), wasmUrl: walrusWasmUrl })
169
+ const tx = certifyBlobTransaction(walrusClient, {
170
+ blobId: entry.blobId,
171
+ blobObjectId: entry.blobObjectId,
172
+ certificate: entry.certificate,
173
+ deletable: entry.deletable,
174
+ })
175
+ const executor = await props.buildExecutor()
176
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Approve in wallet…' }
177
+ const { digest } = await executor.signAndExecute(tx)
178
+ await executor.waitForTransaction(digest)
179
+ clearPendingCertify(storage, pendingCertifyKey(currentNetwork(), props.address), blob.objectId)
180
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Certified ✓ (${digest.slice(0, 8)}…)` }
181
+ await refresh()
182
+ } catch (e) {
183
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Failed: ${msg(e)}` }
184
+ } finally {
185
+ busy.value = null
186
+ }
187
+ }
188
+
189
+ // Load on first open and whenever the address changes; re-derive pending on list/address changes.
190
+ onMounted(() => void load(props.address))
191
+ watch([() => props.address, walrusNetwork], ([addr]) => void load(addr))
192
+ watch(blobs, () => refreshPending())
193
+ watch([() => props.address, walrusNetwork], () => refreshPending())
194
+
195
+ // Seed each group's extend amount with a sensible default (+10, clamped) without pricing it — the
196
+ // estimate is fetched on the first user interaction.
197
+ watch(
198
+ [groups, currentEpoch],
199
+ () => {
200
+ for (const g of groups.value) {
201
+ const b = g.representative
202
+ const max = maxAddable(b.endEpoch)
203
+ if (max > 0 && extendAmount.value[b.objectId] === undefined) {
204
+ extendAmount.value = { ...extendAmount.value, [b.objectId]: Math.min(10, max) }
205
+ }
206
+ }
207
+ },
208
+ { immediate: true },
209
+ )
210
+
211
+ // Highlight + expand the group routed from the duplicate-upload dialog.
212
+ watch(
213
+ () => props.highlightBlobId,
214
+ async (blobId) => {
215
+ if (!blobId) return
216
+ expanded.value = new Set(expanded.value).add(blobId)
217
+ await nextTick()
218
+ document.getElementById(`blob-row-${blobId}`)?.scrollIntoView({ block: 'center', behavior: 'smooth' })
219
+ },
220
+ { immediate: true },
221
+ )
222
+ </script>
223
+
224
+ <template>
225
+ <section class="my-blobs">
226
+ <div class="toolbar">
227
+ <h2>My Blobs</h2>
228
+ <button type="button" :disabled="!address || loading" @click="refresh">
229
+ {{ loading ? 'Loading…' : 'Refresh' }}
230
+ </button>
231
+ </div>
232
+
233
+ <p v-if="!address" class="hint">Connect your wallet to list your Walrus blobs.</p>
234
+ <p v-else-if="error" class="err">{{ error }}</p>
235
+ <p v-else-if="!loading && groups.length === 0" class="hint">No Walrus blobs found for this address.</p>
236
+
237
+ <table v-if="groups.length" class="blob-table">
238
+ <thead>
239
+ <tr>
240
+ <th>Blob ID</th>
241
+ <th>Size</th>
242
+ <th>Expires</th>
243
+ <th>Certified</th>
244
+ <th>Actions</th>
245
+ </tr>
246
+ </thead>
247
+ <tbody>
248
+ <template v-for="g in groups" :key="g.blobId">
249
+ <tr
250
+ :id="`blob-row-${g.blobId}`"
251
+ :class="{ warn: g.maxEndEpoch - currentEpoch < 10, highlight: g.blobId === highlightBlobId }"
252
+ >
253
+ <td>
254
+ <CopyableAddress :address="g.blobId" label="Copy blob ID">
255
+ <ExplorerLink :href="walruscanBlobUrl(walrusNetwork ?? 'testnet', g.blobId)" :value="g.blobId" :chars="[8, 6]" />
256
+ </CopyableAddress>
257
+ <button
258
+ v-if="g.copies.length > 1"
259
+ type="button"
260
+ class="copies-toggle"
261
+ @click="toggleExpand(g.blobId)"
262
+ >
263
+ {{ expanded.has(g.blobId) ? '▾' : '▸' }} {{ g.copies.length }} copies
264
+ </button>
265
+ </td>
266
+ <td>{{ (g.size / 1024).toFixed(1) }} KB</td>
267
+ <td>{{ expiryLabel(g.maxEndEpoch, currentEpoch) }}</td>
268
+ <td>
269
+ <span v-if="groupCertStatus(g) === 'certified'">✓</span>
270
+ <span v-else-if="groupCertStatus(g) === 'pending'" class="pending-badge" title="Uploaded but not certified">pending</span>
271
+ <span v-else>—</span>
272
+ </td>
273
+ <td class="actions">
274
+ <span v-if="actionStatus[g.representative.objectId]" class="act-status">
275
+ {{ actionStatus[g.representative.objectId] }}
276
+ </span>
277
+ <template v-else>
278
+ <button
279
+ v-if="groupPendingCopy(g)"
280
+ type="button"
281
+ class="certify-btn"
282
+ :disabled="!!busy"
283
+ @click="certifyBlob(groupPendingCopy(g)!)"
284
+ >
285
+ Certify
286
+ </button>
287
+ <span v-if="maxAddable(g.representative.endEpoch) === 0" class="at-max">at max lifetime</span>
288
+ <span v-else class="extend">
289
+ <input
290
+ type="number"
291
+ min="1"
292
+ :max="maxAddable(g.representative.endEpoch)"
293
+ :value="extendAmount[g.representative.objectId]"
294
+ aria-label="Epochs to add"
295
+ @input="setExtendAmount(g.representative, ($event.target as HTMLInputElement).value)"
296
+ />
297
+ <button type="button" class="preset" @click="setExtendAmount(g.representative, 10)">+10</button>
298
+ <button type="button" class="preset" @click="setExtendAmount(g.representative, 25)">+25</button>
299
+ <button
300
+ type="button"
301
+ class="preset"
302
+ @click="setExtendAmount(g.representative, maxAddable(g.representative.endEpoch))"
303
+ >
304
+ Max
305
+ </button>
306
+ <button type="button" :disabled="!!busy" @click="extendBlob(g.representative)">
307
+ {{ extendQuotes.quoteFor(g.representative.objectId, extendAmount[g.representative.objectId]) ? 'Extend' : 'Show cost' }}
308
+ </button>
309
+ <span class="est" aria-live="polite">{{ extendQuoteLabel(g.representative) }}</span>
310
+ </span>
311
+ </template>
312
+ </td>
313
+ </tr>
314
+
315
+ <!-- Per-copy detail for grouped duplicates: certify a specific pending copy. -->
316
+ <tr v-for="c in (expanded.has(g.blobId) ? g.copies : [])" :key="c.objectId" class="copy-row">
317
+ <td class="copy-obj">
318
+ <CopyableAddress :address="c.objectId" label="Copy object ID">
319
+ <span class="mono">{{ c.objectId.slice(0, 10) }}…</span>
320
+ </CopyableAddress>
321
+ </td>
322
+ <td></td>
323
+ <td>{{ expiryLabel(c.endEpoch, currentEpoch) }}</td>
324
+ <td>
325
+ <span v-if="c.certified">✓</span>
326
+ <span v-else-if="pendingFor(c.objectId)" class="pending-badge">pending</span>
327
+ <span v-else>—</span>
328
+ </td>
329
+ <td class="actions">
330
+ <span v-if="actionStatus[c.objectId]" class="act-status">{{ actionStatus[c.objectId] }}</span>
331
+ <button
332
+ v-else-if="pendingFor(c.objectId)"
333
+ type="button"
334
+ class="certify-btn"
335
+ :disabled="!!busy"
336
+ @click="certifyBlob(c)"
337
+ >
338
+ Certify
339
+ </button>
340
+ </td>
341
+ </tr>
342
+ </template>
343
+ </tbody>
344
+ </table>
345
+ </section>
346
+ </template>
347
+
348
+ <style scoped>
349
+ .my-blobs {
350
+ margin-top: 1.5rem;
351
+ }
352
+ .toolbar {
353
+ display: flex;
354
+ align-items: center;
355
+ gap: 1rem;
356
+ margin-bottom: 0.75rem;
357
+ }
358
+ .toolbar h2 {
359
+ margin: 0;
360
+ font-size: 1.2rem;
361
+ }
362
+ .hint {
363
+ color: var(--muted);
364
+ font-size: 0.9rem;
365
+ }
366
+ .err {
367
+ color: var(--danger);
368
+ font-size: 0.9rem;
369
+ }
370
+ .blob-table {
371
+ width: 100%;
372
+ border-collapse: collapse;
373
+ font-size: 0.88rem;
374
+ }
375
+ .blob-table th,
376
+ .blob-table td {
377
+ text-align: left;
378
+ padding: 0.4rem 0.6rem;
379
+ border-bottom: 1px solid var(--border);
380
+ vertical-align: top;
381
+ }
382
+ .blob-table th {
383
+ font-weight: 600;
384
+ color: var(--muted);
385
+ }
386
+ .blob-table tr.warn td {
387
+ background: color-mix(in srgb, var(--warning) 8%, transparent);
388
+ }
389
+ .blob-table tr.highlight td {
390
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
391
+ }
392
+ .copy-row td {
393
+ background: color-mix(in srgb, var(--muted) 6%, transparent);
394
+ font-size: 0.82rem;
395
+ }
396
+ .copies-toggle {
397
+ display: inline-block;
398
+ margin-top: 0.25rem;
399
+ font-size: 0.78rem;
400
+ padding: 0.05rem 0.4rem;
401
+ }
402
+ .mono {
403
+ font-family: monospace;
404
+ }
405
+ .actions {
406
+ white-space: nowrap;
407
+ }
408
+ .extend {
409
+ display: inline-flex;
410
+ align-items: center;
411
+ gap: 0.3rem;
412
+ flex-wrap: wrap;
413
+ }
414
+ .extend input {
415
+ width: 4rem;
416
+ }
417
+ .preset {
418
+ font-size: 0.75rem;
419
+ padding: 0.1rem 0.4rem;
420
+ }
421
+ .est {
422
+ font-size: 0.78rem;
423
+ color: var(--muted);
424
+ }
425
+ .at-max {
426
+ font-size: 0.8rem;
427
+ color: var(--muted);
428
+ }
429
+ .act-status {
430
+ font-size: 0.85rem;
431
+ color: var(--muted);
432
+ }
433
+ .certify-btn {
434
+ border-color: var(--accent);
435
+ color: var(--accent);
436
+ }
437
+ .pending-badge {
438
+ font-size: 0.78rem;
439
+ color: var(--accent);
440
+ font-weight: 600;
441
+ }
442
+ </style>