@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.
- package/CHANGELOG.md +74 -0
- package/LICENSE +14 -0
- package/README.md +72 -2
- package/index.html +14 -0
- package/package.json +93 -4
- package/src/App.vue +56 -0
- package/src/blob-groups.ts +68 -0
- package/src/components/MyBlobs.vue +442 -0
- package/src/components/WalrusStorageView.vue +376 -0
- package/src/composables/useExtendQuotes.ts +69 -0
- package/src/composables/useOwnedBlobs.ts +91 -0
- package/src/config.ts +87 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +10 -0
- package/src/main.ts +16 -0
- package/src/wallet.ts +47 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +13 -0
|
@@ -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>
|