feat: integrate local Blossom, reviewed nsites and scoped app access
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onDeactivated, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { pendingWebsiteHtml } from '@/services/websiteImport'
|
||||
import { publishing, PUBLISH_ROUTES, websitePreview } from '@/services/publishing'
|
||||
import type { DnsPlan, PublishRoute, PublishingStatus, WebsiteProject } from '@/services/publishing'
|
||||
import type { DnsPlan, HttpsCheck, PublishRoute, PublishingStatus, WebsiteProject } from '@/services/publishing'
|
||||
import { nsiteIdentities, prepareNsite, publishNsite, retryNsite, requestNsiteDeletion, namedNsiteUrl, relayAddresses, storeLocalWebsite } from '@/services/nsitePublishing'
|
||||
import type { NsiteIdentity, PreparedNsite } from '@/services/nsitePublishing'
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const blossomInstalled = computed(() => !!appStore.data?.['package-data']?.blossom)
|
||||
const websiteMode = computed(() => route.name === 'publish-website')
|
||||
const status = ref<PublishingStatus | null>(null)
|
||||
const error = ref('')
|
||||
@@ -20,8 +26,27 @@ const destination = ref('')
|
||||
const prompt = ref('')
|
||||
const model = ref('')
|
||||
const dns = ref<DnsPlan | null>(null)
|
||||
const httpsCheck = ref<HttpsCheck | null>(null)
|
||||
watch([projectId, hostname, destination, () => status.value?.state.version], () => { httpsCheck.value = null })
|
||||
const acknowledgeFips = ref(false)
|
||||
const acknowledgeTor = ref(false)
|
||||
const identities = ref<NsiteIdentity[]>([])
|
||||
const identityId = ref('')
|
||||
const blossom = ref('')
|
||||
const relays = ref('')
|
||||
const gateway = ref('')
|
||||
const nsiteUrl = ref('')
|
||||
const guestApp = ref('')
|
||||
const guestLabel = ref('Guest')
|
||||
const guestHours = ref(24)
|
||||
const issuedAccess = ref<{ id: string; token: string; app_id: string; expires_at: number } | null>(null)
|
||||
onDeactivated(() => { issuedAccess.value = null })
|
||||
onBeforeUnmount(() => { issuedAccess.value = null })
|
||||
const shareableApps = computed(() => status.value?.apps.filter(a => a.guest_access).filter((a, i, all) => all.findIndex(b => b.id === a.id) === i) ?? [])
|
||||
const guestTarget = computed(() => shareableApps.value.find(a => a.id === guestApp.value))
|
||||
const acknowledgeNostr = ref(false)
|
||||
const acknowledgeUpload = ref(false)
|
||||
const nsiteReview = ref<{ projectId: string; version: number; identity: NsiteIdentity; relays: string[]; prepared: PreparedNsite } | null>(null)
|
||||
const onion = computed(() => status.value?.onions?.find(l => l.project_id === projectId.value))
|
||||
const listener = computed(() => status.value?.listeners?.find(l => l.project_id === projectId.value))
|
||||
const current = computed(() => status.value?.state.projects[projectId.value])
|
||||
@@ -39,10 +64,12 @@ async function perform(work: () => Promise<void>) {
|
||||
function selectProject(p: WebsiteProject) {
|
||||
projectId.value = p.id; name.value = p.name; selected.value = [...p.routes]
|
||||
html.value = p.draft; hostname.value = p.domain?.hostname ?? ''; destination.value = p.domain?.destination ?? ''; dns.value = null; acknowledgeFips.value = false; acknowledgeTor.value = false
|
||||
identityId.value = p.nsite_receipt?.identity_id ?? ''; blossom.value = p.nsite_receipt?.server ?? ''; acknowledgeNostr.value = false; nsiteUrl.value = ''
|
||||
}
|
||||
async function refresh() {
|
||||
await perform(async () => {
|
||||
status.value = await publishing.status()
|
||||
if (!relays.value) relays.value = (status.value.nostr_relays ?? []).join('\n')
|
||||
if (!websiteMode.value) selected.value = [...status.value.state.connections]
|
||||
else if (current.value) selectProject(current.value)
|
||||
else if (projects.value[0]) selectProject(projects.value[0])
|
||||
@@ -81,6 +108,7 @@ async function save() {
|
||||
} : { action: 'connections', routes: selected.value }
|
||||
const result = await publishing.update(status.value.state.version, change)
|
||||
status.value.state = result.state
|
||||
if (websiteMode.value) hostname.value = current.value?.domain?.hostname ?? ''
|
||||
message.value = websiteMode.value ? 'Draft and route choices saved on your node. Publish when you are ready to share this version.' : 'Connection preferences saved on your node. Existing app access has not changed.'
|
||||
})
|
||||
}
|
||||
@@ -127,6 +155,82 @@ async function setTorPublication(enable: boolean) {
|
||||
async function prepareDns() {
|
||||
await perform(async () => { dns.value = await publishing.dns({ hostname: hostname.value, destination: destination.value || null }) })
|
||||
}
|
||||
async function verifyHttps() {
|
||||
await perform(async () => {
|
||||
httpsCheck.value = null
|
||||
if (!status.value) return
|
||||
httpsCheck.value = await publishing.verifyHttps(projectId.value, status.value.state.version)
|
||||
})
|
||||
}
|
||||
async function loadIdentities() { await perform(async () => { identities.value = await nsiteIdentities() }) }
|
||||
async function createGuestAccess() {
|
||||
await perform(async () => {
|
||||
issuedAccess.value = null
|
||||
issuedAccess.value = await rpcClient.call({ method: 'publishing.access-create', params: { app_id: guestApp.value, label: guestLabel.value, hours: guestHours.value }, maxRetries: 0 })
|
||||
status.value = await publishing.status()
|
||||
message.value = 'App-only access created. Copy the token now; it cannot be shown again.'
|
||||
})
|
||||
}
|
||||
async function revokeGuestAccess(id: string) {
|
||||
await perform(async () => {
|
||||
await rpcClient.call({ method: 'publishing.access-revoke', params: { id }, maxRetries: 0 })
|
||||
if (issuedAccess.value?.id === id) issuedAccess.value = null
|
||||
status.value = await publishing.status()
|
||||
message.value = 'Access revoked for new requests. Content already downloaded cannot be recalled.'
|
||||
})
|
||||
}
|
||||
async function storeLocally() {
|
||||
await perform(async () => {
|
||||
const identity = identities.value.find(i => i.id === identityId.value)
|
||||
if (!identity || !status.value || !current.value) throw new Error('Choose a profile identity and save a draft first')
|
||||
await storeLocalWebsite(projectId.value, status.value.state.version, identity)
|
||||
status.value = await publishing.status()
|
||||
message.value = 'Saved draft stored in local Blossom and fetched back to verify its bytes. Nothing was announced or replicated externally.'
|
||||
})
|
||||
}
|
||||
watch([projectId, blossom, relays, identityId, html, selected], () => { nsiteReview.value = null; acknowledgeNostr.value = false; acknowledgeUpload.value = false }, { deep: true })
|
||||
async function reviewNsite() {
|
||||
await perform(async () => {
|
||||
if (!status.value || !current.value) return
|
||||
const identity = identities.value.find(i => i.id === identityId.value)
|
||||
if (!identity) throw new Error('Choose a profile identity first')
|
||||
const targets = relayAddresses(relays.value)
|
||||
const prepared = await prepareNsite(projectId.value, status.value.state.version, blossom.value, current.value.draft)
|
||||
nsiteReview.value = { projectId: projectId.value, version: status.value.state.version, identity: { ...identity }, relays: [...targets], prepared }
|
||||
acknowledgeNostr.value = false; acknowledgeUpload.value = false
|
||||
})
|
||||
}
|
||||
async function handleNsite(action: 'publish' | 'retry' | 'delete') {
|
||||
await perform(async () => {
|
||||
if (!status.value || !current.value || !acknowledgeNostr.value) return
|
||||
const targets = relayAddresses(relays.value)
|
||||
const identity = identities.value.find(i => i.id === identityId.value)
|
||||
const receipt = current.value.nsite_receipt
|
||||
try {
|
||||
if (action === 'retry' && receipt) await retryNsite(projectId.value, receipt, targets)
|
||||
else {
|
||||
if (!identity) throw new Error('Load identities and choose the profile identity you want to use')
|
||||
if (action === 'delete' && receipt) await requestNsiteDeletion(projectId.value, receipt, identity, targets)
|
||||
else {
|
||||
const review = nsiteReview.value
|
||||
if (!review || !acknowledgeUpload.value) throw new Error('Review the exact upload and explicitly approve replication first')
|
||||
await publishNsite(review.projectId, review.version, review.identity, review.relays, review.prepared)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
acknowledgeNostr.value = false; acknowledgeUpload.value = false; nsiteReview.value = null
|
||||
// A failed relay delivery can still leave a durable upload/manifest. Show
|
||||
// that receipt so retry never silently uploads or signs a second copy.
|
||||
status.value = await publishing.status()
|
||||
}
|
||||
message.value = action === 'delete' ? 'A relay accepted the deletion request. Other relays, Blossom servers and cached copies may retain the website.' : 'The uploaded bytes were checked and a relay accepted the named-site manifest. Gateway availability still needs checking.'
|
||||
})
|
||||
}
|
||||
async function showNsiteAddress() {
|
||||
await perform(async () => {
|
||||
if (current.value?.nsite_receipt) nsiteUrl.value = namedNsiteUrl(current.value.nsite_receipt, gateway.value)
|
||||
})
|
||||
}
|
||||
function download() {
|
||||
const url = URL.createObjectURL(new Blob([html.value], { type: 'text/html;charset=utf-8' }))
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'index.html'; a.click()
|
||||
@@ -146,7 +250,28 @@ onMounted(refresh)
|
||||
<p v-if="error" role="alert" class="rounded-xl p-4 bg-red-500/10 text-red-200">{{ error }}</p>
|
||||
<p v-if="message" role="status" class="rounded-xl p-4 bg-green-500/10 text-green-200">{{ message }}</p>
|
||||
<p v-if="busy" role="status" class="text-white/60">Working…</p>
|
||||
<template v-if="status">
|
||||
<template v-if="status">
|
||||
<section v-if="websiteMode" class="glass-card p-5 space-y-3" data-testid="blossom-setup">
|
||||
<h2 class="text-lg font-semibold">Local website files</h2>
|
||||
<template v-if="blossomInstalled">
|
||||
<p>Blossom is installed. You can skip installation.</p>
|
||||
<RouterLink to="/dashboard/apps/blossom" class="underline">Manage local Blossom</RouterLink>
|
||||
<template v-if="current">
|
||||
<button class="glass-button px-4 py-2" :disabled="busy" @click="loadIdentities">Choose a storage identity</button>
|
||||
<label class="block">Profile for local files<select v-model="identityId" :disabled="busy" class="field mt-2"><option value="">Choose an identity</option><option v-for="identity in identities" :key="identity.id" :value="identity.id">{{ identity.name }}</option></select></label>
|
||||
<button class="glass-button px-4 py-2" :disabled="busy || !identityId || !current.draft || html !== current.draft" @click="storeLocally">Store saved website in local Blossom</button>
|
||||
<p v-if="html !== current.draft" class="text-sm">Save your edits before storing this version in Blossom.</p>
|
||||
<p v-if="current.local_archive" class="text-sm break-all">Verified local snapshot: {{ current.local_archive.size }} bytes · {{ new Date(current.local_archive.created_at).toLocaleString() }} · SHA-256 {{ current.local_archive.sha256 }}</p>
|
||||
<p class="text-sm text-white/60">This stores the saved draft shown below. Later edits need another explicit store. Local files require node login; this does not create a public Blossom endpoint.</p>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>Install Blossom from the app catalogue to store website files on this node. Create a profile identity first; Blossom uses the normal Archipelago signer.</p>
|
||||
<RouterLink to="/dashboard/marketplace/blossom" class="glass-button inline-block px-4 py-2">Install Blossom</RouterLink>
|
||||
<p class="text-sm text-white/60">Return here after installation. This step is optional for a simple HTML page served directly by the node.</p>
|
||||
</template>
|
||||
<p class="text-sm text-white/60">Installation and local uploads do not announce anything on Nostr. Publishing files externally requires a separate review of the content and destinations.</p>
|
||||
</section>
|
||||
<div class="rounded-xl p-4 border border-amber-300/20 bg-amber-400/10 text-amber-100 text-sm">{{ status.notice }}</div>
|
||||
<fieldset :disabled="busy" class="space-y-6">
|
||||
<section v-if="websiteMode && pendingWebsiteHtml !== null" class="glass-card p-5 space-y-3">
|
||||
@@ -204,7 +329,10 @@ onMounted(refresh)
|
||||
<dt>Forward port</dt><dd>{{ current.fips_publication.port }}</dd>
|
||||
</dl>
|
||||
<p class="text-sm text-white/60">Point the domain’s DNS at your proxy’s public address. Request a certificate in the proxy’s SSL tab and enable Force SSL. Then open the HTTPS address from a device outside your home network.</p>
|
||||
<p class="text-sm text-amber-200">The proxy terminates HTTPS and can read the public page. This setup is manual; the dashboard has not verified it. Removing this FIPS publication also disconnects this proxy route.</p>
|
||||
<p class="text-sm text-amber-200">The proxy terminates HTTPS and can read the public page. Removing the upstream publication also disconnects this proxy route.</p>
|
||||
<button class="glass-button px-4 py-2" :disabled="hostname !== current.domain?.hostname || !current.routes.includes('public-web')" @click="verifyHttps">Check public HTTPS</button>
|
||||
<p v-if="httpsCheck" class="text-sm text-green-200">Verified https://{{ httpsCheck.hostname }}/ at {{ new Date(httpsCheck.checked_at).toLocaleString() }}: valid TLS and exact published content. Checked from this node; also test from an outside device.</p>
|
||||
<p v-else class="text-sm text-amber-200">Public HTTPS has not been verified for these saved settings.</p>
|
||||
</div>
|
||||
<button class="glass-button px-4 py-2" :disabled="!hostname || !destination" @click="prepareDns">Show DNS instructions</button>
|
||||
<div v-if="dns" class="space-y-3">
|
||||
@@ -220,12 +348,35 @@ onMounted(refresh)
|
||||
<p class="text-sm text-white/60">This inventory shows existing access policies. Local-only APIs are excluded. A local listener does not prove external reachability.</p>
|
||||
<ul class="space-y-2"><li v-for="app in status.apps" :key="app.id + app.port" class="flex flex-wrap justify-between gap-2 text-sm"><span>{{ app.name }} · {{ app.port }}</span><span class="text-white/60">{{ app.listener_claimed ? 'Local proxy listening' : 'Listener not confirmed' }} · {{ app.authentication === 'node-session' ? 'Node login required' : 'App access policy' }}</span></li></ul>
|
||||
</section>
|
||||
<section v-if="!websiteMode" class="glass-card p-5 space-y-3">
|
||||
<h2 class="text-lg font-semibold">Grant access to an app</h2>
|
||||
<p class="text-sm text-white/60">Give someone access to one application without sharing your dashboard login. Only apps that explicitly support guest sharing are offered. Their own account permissions still apply.</p>
|
||||
<label class="block">Application<select v-model="guestApp" class="field mt-2"><option value="">Choose an application</option><option v-for="app in shareableApps" :key="app.id" :value="app.id">{{ app.name }}</option></select></label>
|
||||
<label class="block">Who is this for?<input v-model="guestLabel" maxlength="64" class="field mt-2" /></label>
|
||||
<label class="block">Access expires<select v-model.number="guestHours" class="field mt-2"><option :value="1">After one hour</option><option :value="24">After one day</option><option :value="168">After one week</option><option :value="720">After 30 days</option></select></label>
|
||||
<div v-if="guestTarget" class="space-y-2 text-sm">
|
||||
<p v-if="status.fips_address" class="break-all">FIPS address: <a :href="`https://[${status.fips_address}]:${guestTarget.port}/`" target="_blank" rel="noopener noreferrer" class="underline">https://[{{ status.fips_address }}]:{{ guestTarget.port }}/</a></p>
|
||||
<p v-if="selected.includes('public-web')">For your public reverse proxy, use the FIPS address above as its forward host and port {{ guestTarget.port }}, with upstream scheme HTTP. Keep the app gate enabled. Configure the application's public URL if it requires one.</p>
|
||||
<p>This creates permission to use the app. A reachable FIPS connection, onion service or configured public proxy is also needed.</p>
|
||||
</div>
|
||||
<button class="glass-button px-4 py-2" :disabled="!guestApp || !guestLabel.trim()" @click="createGuestAccess">Create app-only access</button>
|
||||
<div v-if="issuedAccess" class="rounded-xl border border-amber-300/30 p-4 space-y-2">
|
||||
<p>Copy this token and share it privately with the intended guest. It is shown once and is never posted to Nostr or another service.</p>
|
||||
<code class="block break-all select-all">{{ issuedAccess.token }}</code>
|
||||
<p class="text-sm">The guest opens the app address and chooses “Have an app-only access token?”. API clients can use it as an Authorization Bearer token. It cannot log in to the dashboard.</p>
|
||||
<button class="underline text-sm" @click="issuedAccess = null">Hide token</button>
|
||||
</div>
|
||||
<div v-for="grant in status.grants ?? []" :key="grant.id" class="flex flex-wrap items-center justify-between gap-3 border-t border-white/10 pt-3">
|
||||
<p>{{ grant.label }} · {{ grant.apps.join(', ') }} · {{ grant.expires_at ? new Date(grant.expires_at * 1000).toLocaleString() : 'No expiry' }}</p>
|
||||
<button class="underline text-sm" @click="revokeGuestAccess(grant.id)">Revoke access</button>
|
||||
</div>
|
||||
</section>
|
||||
<button class="glass-button px-5 py-3" :disabled="websiteMode && !current" @click="save">{{ websiteMode ? 'Save website draft and choices' : 'Save connection choices' }}</button>
|
||||
<section v-if="websiteMode && current && status.publication_enabled" class="glass-card p-5 space-y-3">
|
||||
<h2 class="text-lg font-semibold">Publish the saved version on FIPS</h2>
|
||||
<h2 class="text-lg font-semibold">Publish the saved version on FIPS or your proxy</h2>
|
||||
<p class="text-sm text-white/60">Anyone who can reach this node through FIPS can view this website. Save your draft first. Scripts and external resources remain blocked in this first static-site version.</p>
|
||||
<label class="flex items-start gap-3"><input v-model="acknowledgeFips" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors on FIPS.</span></label>
|
||||
<button class="glass-button px-4 py-2" :disabled="!acknowledgeFips || !current.routes.includes('fips') || !current.draft" @click="setFipsPublication(true)">{{ current.fips_publication ? 'Publish saved update on FIPS' : 'Publish saved website on FIPS' }}</button>
|
||||
<button class="glass-button px-4 py-2" :disabled="!acknowledgeFips || (!current.routes.includes('fips') && !current.routes.includes('public-web')) || !current.draft" @click="setFipsPublication(true)">{{ current.fips_publication ? 'Publish saved update on FIPS' : 'Publish saved website on FIPS' }}</button>
|
||||
<button v-if="current.fips_publication" class="glass-button px-4 py-2 ml-2" @click="setFipsPublication(false)">Unpublish from FIPS</button>
|
||||
<div v-if="current.fips_publication" class="text-sm space-y-2">
|
||||
<p>{{ listener?.listening ? 'Local FIPS listener is ready.' : 'FIPS listener is not confirmed yet. Reload to check.' }}</p>
|
||||
@@ -247,6 +398,41 @@ onMounted(refresh)
|
||||
<p class="text-amber-200">An address alone does not confirm that Tor has connected or that visitors can reach the page.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="websiteMode && current && selected.includes('nostr')" class="glass-card p-5 space-y-3">
|
||||
<h2 class="text-lg font-semibold">Publish a named nsite</h2>
|
||||
<p class="text-sm text-white/60">Upload a public static copy to a Blossom server, then announce it on your chosen Nostr relays. Your saved source stays on your node. A compatible nsite gateway can give it a browser address without buying a domain.</p>
|
||||
<button class="glass-button px-4 py-2" @click="loadIdentities">Load signing identities</button>
|
||||
<label class="block">Profile identity<select v-model="identityId" class="field mt-2"><option value="">Choose an identity</option><option v-for="identity in identities" :key="identity.id" :value="identity.id">{{ identity.name }}</option></select></label>
|
||||
<p class="text-xs text-white/50">The node's operational identity is excluded. Your private key stays in the existing signer.</p>
|
||||
<label class="block">Blossom server<input v-model="blossom" class="field mt-2" placeholder="https://your-blossom-server.example" /></label>
|
||||
<label class="block">Relays<textarea v-model="relays" rows="3" class="field mt-2" placeholder="wss://your-relay.example" /></label>
|
||||
<p class="text-sm text-white/60">Choose servers you trust or host your own. The Blossom server must allow browser uploads and reads. Paid storage requires a separate arrangement; this flow never pays automatically.</p>
|
||||
<button class="glass-button px-4 py-2" :disabled="!identityId || !blossom || !current.draft" @click="reviewNsite">Prepare publication review — stays on this node</button>
|
||||
<div v-if="nsiteReview" class="rounded-xl border border-amber-300/30 p-4 space-y-3">
|
||||
<h3 class="font-semibold">Review exactly what will leave your node</h3>
|
||||
<p class="text-sm">Signing identity: {{ nsiteReview.identity.name }} <span class="font-mono break-all">{{ nsiteReview.identity.nostr_pubkey }}</span></p>
|
||||
<p class="text-sm break-all">Upload destination: {{ nsiteReview.prepared.server }}</p>
|
||||
<p class="text-sm break-all">Announcement relays: {{ nsiteReview.relays.join(', ') }}</p>
|
||||
<p class="text-xs font-mono break-all">Content SHA-256: {{ nsiteReview.prepared.sha256 }}</p>
|
||||
<iframe :srcdoc="websitePreview(nsiteReview.prepared.html)" sandbox="" referrerpolicy="no-referrer" title="Exact nsite publication preview" class="w-full h-64 rounded-xl bg-white" />
|
||||
<details><summary>Inspect the exact uploaded HTML</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ nsiteReview.prepared.html }}</pre></details>
|
||||
<details><summary>Inspect the public manifest</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ JSON.stringify(nsiteReview.prepared.manifest, null, 2) }}</pre></details>
|
||||
<p class="text-sm text-amber-200">Check for personal information, credentials, private addresses and anything you do not want copied. Sanitising HTML does not remove sensitive text. Public copies cannot be guaranteed erased.</p>
|
||||
<label class="flex items-start gap-3"><input v-model="acknowledgeUpload" type="checkbox" class="mt-1" /><span>I approve sending these exact website bytes to this Blossom server.</span></label>
|
||||
</div>
|
||||
<label class="flex items-start gap-3"><input v-model="acknowledgeNostr" type="checkbox" class="mt-1" /><span>I approve sending the displayed manifest or removal request to the listed relays when I press its action button. It identifies the author, and copies may remain after a deletion request.</span></label>
|
||||
<button class="glass-button px-4 py-2" :disabled="!acknowledgeNostr || !acknowledgeUpload || !nsiteReview || !current.routes.includes('nostr') || !current.draft || !identityId || !blossom" @click="handleNsite('publish')">Upload and publish saved website</button>
|
||||
<template v-if="current.nsite_receipt">
|
||||
<p class="text-sm">{{ current.nsite_receipt.deletion_requested ? 'Deletion requested; copies may remain.' : current.nsite_receipt.accepted_relays.length ? 'Relay delivery recorded; gateway access not verified.' : 'Signed manifest retained; relay delivery is pending.' }}</p>
|
||||
<details><summary>Review retained manifest for retry or removal</summary><pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{{ JSON.stringify(current.nsite_receipt.event, null, 2) }}</pre></details>
|
||||
<p class="text-sm break-all">Retry destinations: {{ relays }}. Removal also contacts relays that previously accepted this manifest: {{ current.nsite_receipt.accepted_relays.join(', ') || 'none recorded' }}.</p>
|
||||
<button class="glass-button px-4 py-2" :disabled="!acknowledgeNostr || current.nsite_receipt.deletion_requested" @click="handleNsite('retry')">Retry manifest delivery</button>
|
||||
<button class="glass-button px-4 py-2 ml-2" :disabled="!acknowledgeNostr || !identityId" @click="handleNsite('delete')">Request removal from relays</button>
|
||||
<label class="block">Compatible nsite gateway<input v-model="gateway" class="field mt-2" placeholder="https://your-nsite-gateway.example" /></label>
|
||||
<button class="glass-button px-4 py-2" :disabled="!gateway" @click="showNsiteAddress">Show browser address</button>
|
||||
<a v-if="nsiteUrl" :href="nsiteUrl" target="_blank" rel="noopener noreferrer" class="block break-all underline">{{ nsiteUrl }}</a>
|
||||
</template>
|
||||
</section>
|
||||
<section v-if="websiteMode && current?.revisions.length" class="glass-card p-5 space-y-3">
|
||||
<h2 class="text-lg font-semibold">Saved revisions</h2>
|
||||
<div v-for="revision in [...current.revisions].reverse()" :key="revision.id" class="flex justify-between gap-3"><span class="text-sm text-white/60">{{ new Date(revision.created_at).toLocaleString() }}</span><button class="text-sm underline" @click="restore(revision.id)">Restore draft</button></div>
|
||||
|
||||
Reference in New Issue
Block a user