@meddleware/token-deployer-ui 0.0.1
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/.claude/move-language-reference/SKILL.md +445 -0
- package/.claude/skills/move-language-reference/SKILL.md +445 -0
- package/.claude/skills/sui-move-bootcamp/SKILL.md +128 -0
- package/.claude/sui-move-bootcamp/SKILL.md +128 -0
- package/.editorconfig +8 -0
- package/.env.example +75 -0
- package/.env.production +7 -0
- package/.gitattributes +1 -0
- package/.github/workflows/ci.yml +34 -0
- package/.github/workflows/token-deployer-e2e.yml +64 -0
- package/.oxfmtrc.json +5 -0
- package/.oxlintrc.json +10 -0
- package/.vscode/extensions.json +9 -0
- package/AGENTS.md +39 -0
- package/CLAUDE.md +159 -0
- package/LICENSE +121 -0
- package/README.md +234 -0
- package/env.d.ts +13 -0
- package/index.html +13 -0
- package/package.json +67 -0
- package/playwright.config.ts +30 -0
- package/public/CNAME +1 -0
- package/public/_headers +12 -0
- package/public/_redirects +1 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +5 -0
- package/public/og-image.png +0 -0
- package/public/robots.txt +3 -0
- package/public/sitemap.xml +8 -0
- package/scripts/e2e-browser.mjs +15 -0
- package/scripts/e2e-deploy.mjs +222 -0
- package/scripts/e2e-localnet.mjs +65 -0
- package/scripts/e2e-walrus-browser.mjs +115 -0
- package/scripts/gen-og-image.mjs +31 -0
- package/scripts/publish-localnet.mjs +55 -0
- package/scripts/regen-template.sh +45 -0
- package/scripts/sync-template-src.sh +36 -0
- package/scripts/verify-bytecode.mjs +49 -0
- package/src/App.vue +41 -0
- package/src/__tests__/App.spec.js +11 -0
- package/src/components/AppNotice.vue +15 -0
- package/src/components/ColorModeControl.vue +81 -0
- package/src/components/ConfigForm.vue +192 -0
- package/src/components/DeployProgress.vue +46 -0
- package/src/components/FieldHint.vue +79 -0
- package/src/components/FormField.vue +28 -0
- package/src/components/FormSelect.vue +43 -0
- package/src/components/GithubPush.vue +100 -0
- package/src/components/IconPicker.vue +410 -0
- package/src/components/LicensePicker.vue +182 -0
- package/src/components/ResultPanel.vue +88 -0
- package/src/components/ReviewPanel.vue +69 -0
- package/src/components/TermsDialog.vue +81 -0
- package/src/components/TokenDeployerView.vue +115 -0
- package/src/components/WalletBar.vue +108 -0
- package/src/components/WalrusBlobBrowser.vue +188 -0
- package/src/components/WalrusIconUpload.vue +205 -0
- package/src/composables/useAccessGate.ts +122 -0
- package/src/composables/useColorMode.ts +76 -0
- package/src/composables/useWalrusRelay.ts +21 -0
- package/src/config.ts +185 -0
- package/src/index.ts +1 -0
- package/src/lib/accessGate.ts +141 -0
- package/src/lib/buildPublishTx.ts +192 -0
- package/src/lib/deploy.ts +168 -0
- package/src/lib/deployExecutor.ts +61 -0
- package/src/lib/errors.ts +21 -0
- package/src/lib/form.ts +75 -0
- package/src/lib/generatePackage.ts +312 -0
- package/src/lib/github.ts +146 -0
- package/src/lib/licenses.ts +122 -0
- package/src/lib/readClient.ts +17 -0
- package/src/lib/template.ts +131 -0
- package/src/lib/types.ts +69 -0
- package/src/lib/validation.ts +172 -0
- package/src/lib/walrus-constants.ts +15 -0
- package/src/lib/walrus.ts +144 -0
- package/src/main.ts +154 -0
- package/src/move-template/sui_token_template.mv +0 -0
- package/src/move-template/template.ts +31 -0
- package/src/styles.css +384 -0
- package/src/template-src/files.json +10 -0
- package/test-results/.last-run.json +4 -0
- package/tests/accessGate.test.ts +108 -0
- package/tests/buildPublishTx.test.ts +243 -0
- package/tests/deploy.test.ts +81 -0
- package/tests/errors.test.ts +32 -0
- package/tests/generatePackage.test.ts +182 -0
- package/tests/github.test.ts +61 -0
- package/tests/licenses.test.ts +78 -0
- package/tests/template.test.ts +93 -0
- package/tests/templateArtifact.test.ts +61 -0
- package/tests/templateParity.test.ts +55 -0
- package/tests/useAccessGate.test.ts +101 -0
- package/tests/useWallet.test.ts +74 -0
- package/tests/useWalrusRelayGating.test.ts +30 -0
- package/tests/validation.test.ts +117 -0
- package/tests/walrusUpload.test.ts +93 -0
- package/tests/walrus_url_patch.test.ts +26 -0
- package/tsconfig.json +19 -0
- package/typedoc.json +34 -0
- package/vite.config.ts +65 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted, watch } from 'vue'
|
|
3
|
+
import { useWallet } from '@meddleware/wallet-adapter'
|
|
4
|
+
import { buildDeployExecutor } from '../lib/deployExecutor.js'
|
|
5
|
+
import { getReadClient } from '../lib/readClient.js'
|
|
6
|
+
import { useWalrusRelay } from '../composables/useWalrusRelay.js'
|
|
7
|
+
import { useAccessGate } from '../composables/useAccessGate.js'
|
|
8
|
+
import { ICON_EPOCHS } from '../lib/walrus-constants.js'
|
|
9
|
+
import { ICON_MAX_BYTES, validateIconFile } from '../config.js'
|
|
10
|
+
import {
|
|
11
|
+
fetchRelayChallenge,
|
|
12
|
+
personalMessageForNonce,
|
|
13
|
+
buildAccessProofToken,
|
|
14
|
+
} from '../lib/accessGate.js'
|
|
15
|
+
import type { WalrusNetwork } from '../lib/walrus.js'
|
|
16
|
+
import type { Network } from '../lib/types.js'
|
|
17
|
+
import AppNotice from './AppNotice.vue'
|
|
18
|
+
import FieldHint from './FieldHint.vue'
|
|
19
|
+
import WalrusBlobBrowser from './WalrusBlobBrowser.vue'
|
|
20
|
+
import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
modelValue: string
|
|
24
|
+
network: Network
|
|
25
|
+
connected: boolean
|
|
26
|
+
error?: string
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{
|
|
30
|
+
(e: 'update:modelValue', url: string): void
|
|
31
|
+
(e: 'blur'): void
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const { account, signPersonalMessage } = useWallet()
|
|
35
|
+
|
|
36
|
+
const isWalrusNetwork = computed(() => props.network !== 'localnet')
|
|
37
|
+
const walrusNet: WalrusNetwork = props.network === 'mainnet' ? 'mainnet' : 'testnet'
|
|
38
|
+
|
|
39
|
+
// NFT-gated relay access (inert when no gate is configured for this network).
|
|
40
|
+
const accessGate = useAccessGate(walrusNet, { getClient: (n) => getReadClient(n) })
|
|
41
|
+
|
|
42
|
+
const {
|
|
43
|
+
selectedRelayHost,
|
|
44
|
+
availableRelays,
|
|
45
|
+
purchaseAccessAvailable,
|
|
46
|
+
estimatedCost,
|
|
47
|
+
fileSizeBytes,
|
|
48
|
+
checkOperatorRelayHealth,
|
|
49
|
+
} = useWalrusRelay(walrusNet, {
|
|
50
|
+
gateConfigured: accessGate.gateConfigured,
|
|
51
|
+
hasAccess: accessGate.hasAccess,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const purchasing = ref(false)
|
|
55
|
+
|
|
56
|
+
async function refreshAccess(): Promise<void> {
|
|
57
|
+
if (isWalrusNetwork.value && accessGate.gateConfigured && account.value) {
|
|
58
|
+
await accessGate.checkOwnership(account.value.address)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Buy relay access (mints the gate NFT to the wallet), then re-check ownership. */
|
|
63
|
+
async function purchaseAccess(): Promise<void> {
|
|
64
|
+
if (!account.value) {
|
|
65
|
+
uploadError.value = 'Connect your wallet first.'
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
purchasing.value = true
|
|
69
|
+
uploadError.value = null
|
|
70
|
+
try {
|
|
71
|
+
const executor = await buildDeployExecutor(props.network as Network)
|
|
72
|
+
await accessGate.purchase(executor, account.value.address)
|
|
73
|
+
} catch (e) {
|
|
74
|
+
uploadError.value = e instanceof Error ? e.message : String(e)
|
|
75
|
+
} finally {
|
|
76
|
+
purchasing.value = false
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const tab = ref<'url' | 'upload' | 'browse'>('url')
|
|
81
|
+
|
|
82
|
+
// Upload state
|
|
83
|
+
const uploading = ref(false)
|
|
84
|
+
const status = ref('')
|
|
85
|
+
const uploadError = ref<string | null>(null)
|
|
86
|
+
const fileName = ref('')
|
|
87
|
+
let bytes: Uint8Array | null = null
|
|
88
|
+
|
|
89
|
+
const permanent = ref(true)
|
|
90
|
+
const maxSizeLabel = `${Math.round(ICON_MAX_BYTES / 1024)} KB`
|
|
91
|
+
const ICON_YEARS = Math.round(ICON_EPOCHS / 26)
|
|
92
|
+
|
|
93
|
+
const showFreeRelayTip = computed(() => {
|
|
94
|
+
const publicHost = availableRelays.value.find((r) => r.isPublic)?.host
|
|
95
|
+
return availableRelays.value.length > 1 && selectedRelayHost.value !== publicHost
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
onMounted(() => {
|
|
99
|
+
if (isWalrusNetwork.value) {
|
|
100
|
+
checkOperatorRelayHealth()
|
|
101
|
+
void refreshAccess()
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Re-check NFT ownership whenever the connected account changes.
|
|
106
|
+
watch(
|
|
107
|
+
() => account.value?.address,
|
|
108
|
+
() => {
|
|
109
|
+
void refreshAccess()
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
function onFile(e: Event): void {
|
|
114
|
+
uploadError.value = null
|
|
115
|
+
bytes = null
|
|
116
|
+
fileName.value = ''
|
|
117
|
+
const f = (e.target as HTMLInputElement).files?.[0]
|
|
118
|
+
if (!f) return
|
|
119
|
+
const invalid = validateIconFile(f)
|
|
120
|
+
if (invalid) { uploadError.value = invalid; return }
|
|
121
|
+
fileName.value = f.name
|
|
122
|
+
fileSizeBytes.value = f.size
|
|
123
|
+
const reader = new FileReader()
|
|
124
|
+
reader.onload = () => { bytes = new Uint8Array(reader.result as ArrayBuffer) }
|
|
125
|
+
reader.readAsArrayBuffer(f)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function onRelayChange(newHost: string): void {
|
|
129
|
+
selectedRelayHost.value = newHost
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function upload(): Promise<void> {
|
|
133
|
+
if (!bytes) { uploadError.value = 'Choose an image first.'; return }
|
|
134
|
+
if (!account.value) { uploadError.value = 'Connect your wallet first.'; return }
|
|
135
|
+
uploading.value = true
|
|
136
|
+
uploadError.value = null
|
|
137
|
+
try {
|
|
138
|
+
const { createWalrusClient, createBlobUploadFlow, walrusBlobUrl } = await import(
|
|
139
|
+
'../lib/walrus.js'
|
|
140
|
+
)
|
|
141
|
+
const executor = await buildDeployExecutor(props.network as Network)
|
|
142
|
+
const suiClient = getReadClient(props.network as Network)
|
|
143
|
+
|
|
144
|
+
// If the gated operator relay is selected, prove NFT access: (single-use only) consume
|
|
145
|
+
// one use on-chain bound to the challenge nonce, then sign the challenge. The token rides
|
|
146
|
+
// the relay Authorization header; the nft-gate gateway verifies it before proxying.
|
|
147
|
+
let uploadRelayAuthToken: string | undefined
|
|
148
|
+
const publicHost = availableRelays.value.find((r) => r.isPublic)?.host
|
|
149
|
+
const usingOperatorRelay = selectedRelayHost.value !== publicHost
|
|
150
|
+
if (accessGate.gateConfigured && usingOperatorRelay && account.value) {
|
|
151
|
+
const challenge = await fetchRelayChallenge(selectedRelayHost.value)
|
|
152
|
+
let consumeDigest: string | undefined
|
|
153
|
+
if (accessGate.usesRemaining.value != null && accessGate.nftId.value) {
|
|
154
|
+
status.value = 'Consuming one access use (approve in wallet)…'
|
|
155
|
+
const consumeTx = accessGate.buildConsume(accessGate.nftId.value, challenge.nonce)
|
|
156
|
+
const cres = await executor.signAndExecute(consumeTx)
|
|
157
|
+
await executor.waitForTransaction(cres.digest)
|
|
158
|
+
consumeDigest = cres.digest
|
|
159
|
+
}
|
|
160
|
+
status.value = 'Signing relay access proof…'
|
|
161
|
+
const { signature } = await signPersonalMessage(personalMessageForNonce(challenge.nonce))
|
|
162
|
+
uploadRelayAuthToken = buildAccessProofToken({
|
|
163
|
+
address: account.value.address,
|
|
164
|
+
nonce: challenge.nonce,
|
|
165
|
+
signature,
|
|
166
|
+
consumeDigest,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const client = createWalrusClient(walrusNet, {
|
|
171
|
+
wasmUrl: walrusWasmUrl,
|
|
172
|
+
uploadRelayHost: selectedRelayHost.value,
|
|
173
|
+
uploadRelayAuthToken,
|
|
174
|
+
})
|
|
175
|
+
const flow = createBlobUploadFlow(client, bytes)
|
|
176
|
+
|
|
177
|
+
status.value = 'Encoding…'
|
|
178
|
+
await flow.encode()
|
|
179
|
+
|
|
180
|
+
let feeNote = ''
|
|
181
|
+
try {
|
|
182
|
+
const tipMist = await client.walrus.calculateUploadRelayTip({ size: bytes.length })
|
|
183
|
+
const tipSui = Number(tipMist) / 1e9
|
|
184
|
+
feeNote = tipMist > 0n ? ` Relay fee ≈ ${tipSui.toFixed(4)} SUI + storage (WAL).` : ''
|
|
185
|
+
} catch {
|
|
186
|
+
// Non-fatal — the register step surfaces any real tip error.
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
status.value = `Registering blob (approve in wallet)…${feeNote}`
|
|
190
|
+
const regTx = flow.register({
|
|
191
|
+
owner: account.value.address,
|
|
192
|
+
epochs: ICON_EPOCHS,
|
|
193
|
+
deletable: !permanent.value,
|
|
194
|
+
})
|
|
195
|
+
regTx.setSenderIfNotSet(account.value.address)
|
|
196
|
+
await regTx.build({ client: suiClient })
|
|
197
|
+
const reg = await executor.signAndExecute(regTx)
|
|
198
|
+
await executor.waitForTransaction(reg.digest)
|
|
199
|
+
|
|
200
|
+
status.value = 'Uploading to the relay…'
|
|
201
|
+
await flow.upload({ digest: reg.digest })
|
|
202
|
+
|
|
203
|
+
status.value = 'Certifying (approve in wallet)…'
|
|
204
|
+
const certTx = flow.certify()
|
|
205
|
+
certTx.setSenderIfNotSet(account.value.address)
|
|
206
|
+
await certTx.build({ client: suiClient })
|
|
207
|
+
const cert = await executor.signAndExecute(certTx)
|
|
208
|
+
await executor.waitForTransaction(cert.digest)
|
|
209
|
+
|
|
210
|
+
const blob = await flow.getBlob()
|
|
211
|
+
emit('update:modelValue', walrusBlobUrl(walrusNet, blob.blobId))
|
|
212
|
+
tab.value = 'url'
|
|
213
|
+
status.value = 'Uploaded ✓ — URL filled in above.'
|
|
214
|
+
} catch (e) {
|
|
215
|
+
uploadError.value = e instanceof Error ? e.message : String(e)
|
|
216
|
+
status.value = ''
|
|
217
|
+
} finally {
|
|
218
|
+
uploading.value = false
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function onBrowseSelect(url: string): void {
|
|
223
|
+
emit('update:modelValue', url)
|
|
224
|
+
tab.value = 'url'
|
|
225
|
+
}
|
|
226
|
+
</script>
|
|
227
|
+
|
|
228
|
+
<template>
|
|
229
|
+
<fieldset class="icon-picker">
|
|
230
|
+
<legend>Icon <span class="hint">(optional)</span></legend>
|
|
231
|
+
|
|
232
|
+
<div class="icon-tabs" role="group" aria-label="Icon source">
|
|
233
|
+
<label :class="{ active: tab === 'url' }">
|
|
234
|
+
<input type="radio" v-model="tab" value="url" />
|
|
235
|
+
URL
|
|
236
|
+
</label>
|
|
237
|
+
<label
|
|
238
|
+
:class="{ active: tab === 'upload', disabled: !isWalrusNetwork }"
|
|
239
|
+
:title="isWalrusNetwork ? undefined : 'Not available on localnet'"
|
|
240
|
+
>
|
|
241
|
+
<input type="radio" v-model="tab" value="upload" :disabled="!isWalrusNetwork" />
|
|
242
|
+
Upload to Walrus
|
|
243
|
+
</label>
|
|
244
|
+
<label
|
|
245
|
+
:class="{ active: tab === 'browse', disabled: !isWalrusNetwork }"
|
|
246
|
+
:title="isWalrusNetwork ? undefined : 'Not available on localnet'"
|
|
247
|
+
>
|
|
248
|
+
<input type="radio" v-model="tab" value="browse" :disabled="!isWalrusNetwork" />
|
|
249
|
+
My Walrus blobs
|
|
250
|
+
</label>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<!-- URL panel -->
|
|
254
|
+
<div v-show="tab === 'url'" class="icon-panel">
|
|
255
|
+
<input
|
|
256
|
+
id="icon"
|
|
257
|
+
type="url"
|
|
258
|
+
:value="modelValue"
|
|
259
|
+
placeholder="https://…"
|
|
260
|
+
:aria-invalid="error ? true : undefined"
|
|
261
|
+
:aria-describedby="error ? 'icon-error' : undefined"
|
|
262
|
+
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
|
263
|
+
@blur="emit('blur')"
|
|
264
|
+
/>
|
|
265
|
+
<p v-if="error" id="icon-error" class="field-error">{{ error }}</p>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<!-- Upload panel -->
|
|
269
|
+
<div v-show="tab === 'upload' && isWalrusNetwork" class="icon-panel">
|
|
270
|
+
<div class="row">
|
|
271
|
+
<input
|
|
272
|
+
id="walrus-file"
|
|
273
|
+
type="file"
|
|
274
|
+
accept="image/*"
|
|
275
|
+
aria-label="Choose an image to upload to Walrus"
|
|
276
|
+
aria-describedby="walrus-help"
|
|
277
|
+
style="width: auto"
|
|
278
|
+
@change="onFile"
|
|
279
|
+
/>
|
|
280
|
+
<button type="button" :disabled="uploading || !connected || !fileName" @click="upload">
|
|
281
|
+
<span v-if="uploading" class="spinner" aria-hidden="true"></span>
|
|
282
|
+
Upload to Walrus
|
|
283
|
+
</button>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<label class="permanent-toggle">
|
|
287
|
+
<input type="checkbox" v-model="permanent" />
|
|
288
|
+
Permanent blob <span class="hint">(cannot be deleted)</span>
|
|
289
|
+
<FieldHint field-id="blob-permanent">
|
|
290
|
+
A permanent blob cannot be deleted once registered, even within its storage period.
|
|
291
|
+
Deletable blobs can be removed early by the owner to reclaim unused storage fees (WAL).
|
|
292
|
+
Permanent is the safer default for a token icon that should remain available indefinitely.
|
|
293
|
+
</FieldHint>
|
|
294
|
+
</label>
|
|
295
|
+
|
|
296
|
+
<fieldset v-if="fileName && availableRelays.length > 1" style="margin: 0.5rem 0">
|
|
297
|
+
<legend>Upload relay</legend>
|
|
298
|
+
<div style="display: flex; flex-direction: column; gap: 0.5rem">
|
|
299
|
+
<label
|
|
300
|
+
v-for="option in availableRelays"
|
|
301
|
+
:key="option.host"
|
|
302
|
+
style="display: flex; align-items: center; gap: 0.5rem"
|
|
303
|
+
>
|
|
304
|
+
<input
|
|
305
|
+
type="radio"
|
|
306
|
+
:value="option.host"
|
|
307
|
+
:checked="selectedRelayHost === option.host"
|
|
308
|
+
@change="(e) => onRelayChange((e.target as HTMLInputElement).value)"
|
|
309
|
+
/>
|
|
310
|
+
<span>{{ option.label }}</span>
|
|
311
|
+
</label>
|
|
312
|
+
</div>
|
|
313
|
+
</fieldset>
|
|
314
|
+
|
|
315
|
+
<AppNotice v-if="fileName && estimatedCost" style="margin: 0.5rem 0">
|
|
316
|
+
<strong>Estimated cost:</strong> {{ estimatedCost.label }}
|
|
317
|
+
</AppNotice>
|
|
318
|
+
|
|
319
|
+
<!-- NFT-gated relay: the operator relay is live but this wallet lacks the access NFT.
|
|
320
|
+
The public relay is still available; purchasing unlocks the operator relay. -->
|
|
321
|
+
<AppNotice v-if="fileName && purchaseAccessAvailable" style="margin: 0.5rem 0">
|
|
322
|
+
<p style="margin: 0 0 0.5rem">
|
|
323
|
+
The operator relay requires an access NFT. You can keep using the public relay, or
|
|
324
|
+
purchase access to support this app’s relay.
|
|
325
|
+
</p>
|
|
326
|
+
<button type="button" :disabled="purchasing || !connected" @click="purchaseAccess">
|
|
327
|
+
{{ purchasing ? 'Purchasing…' : 'Purchase relay access' }}
|
|
328
|
+
</button>
|
|
329
|
+
</AppNotice>
|
|
330
|
+
|
|
331
|
+
<p v-if="showFreeRelayTip" class="hint" style="margin: 0.5rem 0">
|
|
332
|
+
Tip: select the public relay above to avoid the relay fee.
|
|
333
|
+
</p>
|
|
334
|
+
|
|
335
|
+
<p id="walrus-help" class="hint" aria-live="polite">
|
|
336
|
+
Max {{ maxSizeLabel }} · PNG/JPEG/WebP/SVG. Requires <strong>WAL</strong> (storage) and
|
|
337
|
+
<strong>SUI</strong> (gas + relay fee) in your wallet; two approvals. Stored for
|
|
338
|
+
~{{ ICON_YEARS }} years. <span v-if="status">{{ status }}</span>
|
|
339
|
+
</p>
|
|
340
|
+
<AppNotice v-if="uploadError" type="error">{{ uploadError }}</AppNotice>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<!-- Browse panel -->
|
|
344
|
+
<div v-show="tab === 'browse' && isWalrusNetwork" class="icon-panel">
|
|
345
|
+
<WalrusBlobBrowser :network="walrusNet" :connected="connected" @select="onBrowseSelect" />
|
|
346
|
+
</div>
|
|
347
|
+
</fieldset>
|
|
348
|
+
</template>
|
|
349
|
+
|
|
350
|
+
<style scoped>
|
|
351
|
+
.icon-tabs {
|
|
352
|
+
display: flex;
|
|
353
|
+
border: 1px solid var(--border);
|
|
354
|
+
border-radius: 6px;
|
|
355
|
+
overflow: hidden;
|
|
356
|
+
width: fit-content;
|
|
357
|
+
margin-bottom: 0.75rem;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.icon-tabs label {
|
|
361
|
+
display: flex;
|
|
362
|
+
align-items: center;
|
|
363
|
+
padding: 0.4rem 0.75rem;
|
|
364
|
+
cursor: pointer;
|
|
365
|
+
font-size: 0.875rem;
|
|
366
|
+
background: transparent;
|
|
367
|
+
border-right: 1px solid var(--border);
|
|
368
|
+
transition: background 0.15s, color 0.15s;
|
|
369
|
+
user-select: none;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.icon-tabs label:last-child {
|
|
373
|
+
border-right: none;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.icon-tabs label.active {
|
|
377
|
+
background: var(--accent);
|
|
378
|
+
color: var(--accent-contrast);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.icon-tabs label.disabled {
|
|
382
|
+
opacity: 0.5;
|
|
383
|
+
cursor: not-allowed;
|
|
384
|
+
pointer-events: none;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.icon-tabs input[type='radio'] {
|
|
388
|
+
position: absolute;
|
|
389
|
+
opacity: 0;
|
|
390
|
+
width: 0;
|
|
391
|
+
height: 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.icon-panel {
|
|
395
|
+
padding-top: 0.5rem;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.permanent-toggle {
|
|
399
|
+
display: flex;
|
|
400
|
+
align-items: center;
|
|
401
|
+
gap: 0.4rem;
|
|
402
|
+
margin-top: 0.5rem;
|
|
403
|
+
font-size: 0.875rem;
|
|
404
|
+
cursor: pointer;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.permanent-toggle input[type='checkbox'] {
|
|
408
|
+
margin: 0;
|
|
409
|
+
}
|
|
410
|
+
</style>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue'
|
|
3
|
+
import { fetchLicenseList, fetchLicenseText, NO_LICENSE } from '../lib/licenses.js'
|
|
4
|
+
import type { SpdxLicense } from '../lib/licenses.js'
|
|
5
|
+
import FormSelect from './FormSelect.vue'
|
|
6
|
+
import FieldHint from './FieldHint.vue'
|
|
7
|
+
import AppNotice from './AppNotice.vue'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{ modelValue: string }>()
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
(e: 'update:modelValue', id: string): void
|
|
12
|
+
(e: 'name', name: string): void
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const popular = ref<SpdxLicense[]>([])
|
|
16
|
+
const all = ref<SpdxLicense[]>([])
|
|
17
|
+
const loading = ref(true)
|
|
18
|
+
const error = ref<string | null>(null)
|
|
19
|
+
|
|
20
|
+
const allExceptNone = computed(() => all.value.filter((x) => x.id !== NO_LICENSE.id))
|
|
21
|
+
const currentLicenseName = computed(
|
|
22
|
+
() => all.value.find((l) => l.id === props.modelValue)?.name ?? props.modelValue,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
function emitSelection(id: string): void {
|
|
26
|
+
emit('update:modelValue', id)
|
|
27
|
+
const found =
|
|
28
|
+
all.value.find((l) => l.id === id) ?? (id === NO_LICENSE.id ? NO_LICENSE : undefined)
|
|
29
|
+
emit('name', found?.name ?? id)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const selectModel = computed({
|
|
33
|
+
get: () => props.modelValue,
|
|
34
|
+
set: (id: string) => emitSelection(id),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
onMounted(async () => {
|
|
38
|
+
try {
|
|
39
|
+
const cat = await fetchLicenseList()
|
|
40
|
+
popular.value = cat.popular
|
|
41
|
+
all.value = cat.all
|
|
42
|
+
emitSelection(props.modelValue)
|
|
43
|
+
} catch (e) {
|
|
44
|
+
error.value = e instanceof Error ? e.message : String(e)
|
|
45
|
+
} finally {
|
|
46
|
+
loading.value = false
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// License view modal
|
|
51
|
+
const licenseDialogEl = ref<HTMLDialogElement>()
|
|
52
|
+
const licenseText = ref<string | null>(null)
|
|
53
|
+
const licenseLoading = ref(false)
|
|
54
|
+
const licenseError = ref<string | null>(null)
|
|
55
|
+
|
|
56
|
+
async function openLicenseModal(): Promise<void> {
|
|
57
|
+
licenseError.value = null
|
|
58
|
+
if (props.modelValue !== NO_LICENSE.id) {
|
|
59
|
+
licenseLoading.value = true
|
|
60
|
+
try {
|
|
61
|
+
licenseText.value = await fetchLicenseText(props.modelValue)
|
|
62
|
+
} catch (e) {
|
|
63
|
+
licenseError.value = e instanceof Error ? e.message : String(e)
|
|
64
|
+
} finally {
|
|
65
|
+
licenseLoading.value = false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
licenseDialogEl.value?.showModal()
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<div>
|
|
74
|
+
<label for="license-select">
|
|
75
|
+
License
|
|
76
|
+
<FieldHint field-id="license-select">
|
|
77
|
+
Applies only to the downloadable Move source package — embedded in the LICENSE file and
|
|
78
|
+
source headers. Has <b>no effect on the on-chain token</b> or its transferability.
|
|
79
|
+
Choose <b>CC0-1.0</b> (default) for public domain. Choose <b>None</b> to retain all rights.
|
|
80
|
+
</FieldHint>
|
|
81
|
+
</label>
|
|
82
|
+
<div class="license-row">
|
|
83
|
+
<FormSelect
|
|
84
|
+
id="license-select"
|
|
85
|
+
v-model="selectModel"
|
|
86
|
+
:disabled="loading || Boolean(error)"
|
|
87
|
+
aria-describedby="license-load-status"
|
|
88
|
+
>
|
|
89
|
+
<option :value="NO_LICENSE.id" :title="NO_LICENSE.name">{{ NO_LICENSE.id }}</option>
|
|
90
|
+
<optgroup v-if="popular.length" label="Popular">
|
|
91
|
+
<option
|
|
92
|
+
v-for="l in popular"
|
|
93
|
+
:key="'p-' + l.id"
|
|
94
|
+
:value="l.id"
|
|
95
|
+
:title="`${l.id} — ${l.name}`"
|
|
96
|
+
>{{ l.id }}</option>
|
|
97
|
+
</optgroup>
|
|
98
|
+
<optgroup v-if="all.length" label="All licenses">
|
|
99
|
+
<option
|
|
100
|
+
v-for="l in allExceptNone"
|
|
101
|
+
:key="l.id"
|
|
102
|
+
:value="l.id"
|
|
103
|
+
:title="`${l.id} — ${l.name}`"
|
|
104
|
+
>{{ l.id }}</option>
|
|
105
|
+
</optgroup>
|
|
106
|
+
</FormSelect>
|
|
107
|
+
<button type="button" class="view-btn" :disabled="loading" @click="openLicenseModal">
|
|
108
|
+
View
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
<p id="license-load-status" class="hint" aria-live="polite">
|
|
112
|
+
<span v-if="loading"><span class="spinner" aria-hidden="true"></span> Loading licenses from SPDX…</span>
|
|
113
|
+
<span v-else-if="error" class="field-error">Couldn't load the license list: {{ error }}</span>
|
|
114
|
+
</p>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<dialog ref="licenseDialogEl" class="license-dialog" @click.self="licenseDialogEl?.close()">
|
|
118
|
+
<div class="dialog-header">
|
|
119
|
+
<h2>{{ currentLicenseName }}</h2>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
aria-label="Close"
|
|
123
|
+
style="background: none; border: none; font-size: 1.5rem; padding: 0; cursor: pointer; color: var(--text)"
|
|
124
|
+
@click="licenseDialogEl?.close()"
|
|
125
|
+
>×</button>
|
|
126
|
+
</div>
|
|
127
|
+
<div v-if="licenseLoading" style="padding: 1rem 0">
|
|
128
|
+
<span class="spinner" aria-hidden="true"></span> Loading…
|
|
129
|
+
</div>
|
|
130
|
+
<AppNotice v-else-if="licenseError" type="error">{{ licenseError }}</AppNotice>
|
|
131
|
+
<template v-else-if="modelValue === NO_LICENSE.id">
|
|
132
|
+
<p>No LICENSE file will be generated. All rights are reserved by the author.</p>
|
|
133
|
+
</template>
|
|
134
|
+
<template v-else>
|
|
135
|
+
<p style="margin-top: 0">
|
|
136
|
+
<a
|
|
137
|
+
:href="`https://spdx.org/licenses/${modelValue}.html`"
|
|
138
|
+
target="_blank"
|
|
139
|
+
rel="noopener noreferrer"
|
|
140
|
+
>View on SPDX ↗</a>
|
|
141
|
+
</p>
|
|
142
|
+
<pre class="license-text">{{ licenseText }}</pre>
|
|
143
|
+
</template>
|
|
144
|
+
<div style="margin-top: 1rem; text-align: right">
|
|
145
|
+
<button type="button" @click="licenseDialogEl?.close()">Close</button>
|
|
146
|
+
</div>
|
|
147
|
+
</dialog>
|
|
148
|
+
</template>
|
|
149
|
+
|
|
150
|
+
<style scoped>
|
|
151
|
+
.license-row {
|
|
152
|
+
display: flex;
|
|
153
|
+
gap: 0.5rem;
|
|
154
|
+
align-items: center;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.license-row :deep(.select-wrapper) {
|
|
158
|
+
flex: 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.view-btn {
|
|
162
|
+
flex-shrink: 0;
|
|
163
|
+
white-space: nowrap;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.license-dialog {
|
|
167
|
+
width: min(680px, 92vw);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.license-text {
|
|
171
|
+
white-space: pre-wrap;
|
|
172
|
+
overflow-y: auto;
|
|
173
|
+
max-height: 55vh;
|
|
174
|
+
font-size: 0.8rem;
|
|
175
|
+
font-family: monospace;
|
|
176
|
+
background: var(--bg);
|
|
177
|
+
border: 1px solid var(--border);
|
|
178
|
+
border-radius: 6px;
|
|
179
|
+
padding: 0.75rem;
|
|
180
|
+
margin: 0;
|
|
181
|
+
}
|
|
182
|
+
</style>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { PublishResult, TokenConfig } from '../lib/types.js'
|
|
4
|
+
import { explorerObjectUrl } from '../config.js'
|
|
5
|
+
import { generatePackageZip } from '../lib/generatePackage.js'
|
|
6
|
+
import { fetchLicenseText } from '../lib/licenses.js'
|
|
7
|
+
import GithubPush from './GithubPush.vue'
|
|
8
|
+
import AppNotice from './AppNotice.vue'
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{ result: PublishResult; config: TokenConfig }>()
|
|
11
|
+
defineEmits<{ (e: 'restart'): void }>()
|
|
12
|
+
|
|
13
|
+
const downloading = ref(false)
|
|
14
|
+
const downloadError = ref<string | null>(null)
|
|
15
|
+
|
|
16
|
+
const packageUrl = computed(() => explorerObjectUrl(props.result.network, props.result.packageId))
|
|
17
|
+
|
|
18
|
+
async function downloadPackage(): Promise<void> {
|
|
19
|
+
downloading.value = true
|
|
20
|
+
downloadError.value = null
|
|
21
|
+
try {
|
|
22
|
+
const licenseText = await fetchLicenseText(props.config.license)
|
|
23
|
+
const zip = generatePackageZip({
|
|
24
|
+
config: props.config,
|
|
25
|
+
licenseText: licenseText ?? undefined,
|
|
26
|
+
result: props.result,
|
|
27
|
+
})
|
|
28
|
+
const blob = new Blob([zip as BlobPart], { type: 'application/zip' })
|
|
29
|
+
const url = URL.createObjectURL(blob)
|
|
30
|
+
const a = document.createElement('a')
|
|
31
|
+
a.href = url
|
|
32
|
+
a.download = `${props.config.packageName}.zip`
|
|
33
|
+
document.body.appendChild(a)
|
|
34
|
+
a.click()
|
|
35
|
+
a.remove()
|
|
36
|
+
URL.revokeObjectURL(url)
|
|
37
|
+
} catch (e) {
|
|
38
|
+
downloadError.value = e instanceof Error ? e.message : String(e)
|
|
39
|
+
} finally {
|
|
40
|
+
downloading.value = false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<section class="card" aria-labelledby="result-h">
|
|
47
|
+
<h2 id="result-h">🎉 {{ config.symbol }} is live on {{ result.network }}</h2>
|
|
48
|
+
|
|
49
|
+
<dl>
|
|
50
|
+
<dt>Coin type</dt>
|
|
51
|
+
<dd class="mono">{{ result.coinType }}</dd>
|
|
52
|
+
<dt>Package</dt>
|
|
53
|
+
<dd class="mono">
|
|
54
|
+
<a :href="packageUrl" target="_blank" rel="noopener noreferrer">
|
|
55
|
+
{{ result.packageId }}
|
|
56
|
+
</a>
|
|
57
|
+
</dd>
|
|
58
|
+
<dt v-if="result.treasuryCapId">TreasuryCap</dt>
|
|
59
|
+
<dd v-if="result.treasuryCapId" class="mono">{{ result.treasuryCapId }}</dd>
|
|
60
|
+
<dt v-if="result.metadataCapId">MetadataCap</dt>
|
|
61
|
+
<dd v-if="result.metadataCapId" class="mono">{{ result.metadataCapId }}</dd>
|
|
62
|
+
<dt>Package code</dt>
|
|
63
|
+
<dd>{{ result.upgradeCapId ? 'Upgradeable' : 'Immutable (UpgradeCap burned)' }}</dd>
|
|
64
|
+
</dl>
|
|
65
|
+
|
|
66
|
+
<div class="row">
|
|
67
|
+
<button type="button" class="primary" :disabled="downloading" @click="downloadPackage">
|
|
68
|
+
<span v-if="downloading" class="spinner" aria-hidden="true"></span>
|
|
69
|
+
Download source package (.zip)
|
|
70
|
+
</button>
|
|
71
|
+
<button type="button" @click="$emit('restart')">Deploy another</button>
|
|
72
|
+
</div>
|
|
73
|
+
<AppNotice v-if="downloadError" type="error">{{ downloadError }}</AppNotice>
|
|
74
|
+
|
|
75
|
+
<div style="margin-top: 1rem">
|
|
76
|
+
<GithubPush :config="config" :result="result" />
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<p v-once class="hint" style="margin-top: 1rem">
|
|
80
|
+
The download is the full, buildable Move package (matching this on-chain deployment) — your
|
|
81
|
+
TreasuryCap grants minting rights; keep it safe. This tool takes no custody of your token.
|
|
82
|
+
</p>
|
|
83
|
+
<p v-once class="hint">
|
|
84
|
+
You are the sole author of this token. This tool does not review or endorse it, and its
|
|
85
|
+
appearance here is not a mark of legitimacy.
|
|
86
|
+
</p>
|
|
87
|
+
</section>
|
|
88
|
+
</template>
|