@@ -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 >