When the user adds a claim, append an `add` event to their sigchain
on the chain service (rust-sig-server); when they remove a claim,
append a `revoke`. Implements SPEC.md §8 — the sigchain is now the
canonical, verifiable record of what the user currently claims, not
a per-claim field.
• lib/sigchain-service.ts: new module that fetches the current chain
to compute the next seq + prev hash, signs the event locally (the
chain service never sees the seed), and POSTs. Returns a typed
SigchainSyncResult so the caller can record the seq + status.
• lib/kez.ts: sigchain event types + helpers (nextChainCursor,
sigchainEventHash, signSigchainEvent, SignedSigchainEvent /
SigchainOp types). Mirrors the Rust + Node + Python core surface.
• lib/api.ts: getSigchain (GET full chain) + postSigchainEvent
(POST one signed event) wrappers against the chain service.
• lib/claims-store: StoredClaim gains chain_service, sigchain_seq,
sigchain_status ("synced" | "error"), sigchain_error fields +
setSigchainSync helper.
• routes/AddClaim: on successful claim creation, fires an `add` to
the chain service in the background; surfaces sync errors with a
"Retry sync" button.
• routes/Claims: a `revoke` is posted to the chain service first
when the user removes a claim. Best-effort — if the service is
unreachable, asks before dropping the local copy so the chain
doesn't silently drift. Per-row "Sync to chain" button retries
failed adds.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
545 lines
21 KiB
Svelte
545 lines
21 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { push } from "svelte-spa-router";
|
|
import {
|
|
signClaim,
|
|
identityFromSeed,
|
|
toPrettyJson,
|
|
toMarkdown,
|
|
toCompact,
|
|
type SignedClaimEnvelope,
|
|
} from "../lib/kez.js";
|
|
import { addClaim, setSigchainSync } from "../lib/claims-store.js";
|
|
import {
|
|
appendSubjectEvent,
|
|
resolveChainService,
|
|
} from "../lib/sigchain-service.js";
|
|
import { session } from "../lib/store.svelte.js";
|
|
import {
|
|
hasNip07,
|
|
getNostrNpub,
|
|
publishKezClaimToNostr,
|
|
type PublishResult,
|
|
} from "../lib/nip07.js";
|
|
|
|
type ChannelKey = "github" | "dns" | "web" | "nostr" | "bluesky" | "ap";
|
|
|
|
type Format = "compact" | "markdown" | "json";
|
|
|
|
interface ChannelDef {
|
|
key: ChannelKey;
|
|
label: string;
|
|
identifierPlaceholder: string;
|
|
identifierLabel: string;
|
|
/** Build the canonical KEZ identifier from the user's input. */
|
|
toSubject: (raw: string) => string;
|
|
/** Free-text instructions shown next to the publish artifact. */
|
|
instructions: (subject: string) => string;
|
|
/** Which encoding to show by default. */
|
|
preferredFormat: Format;
|
|
}
|
|
|
|
const CHANNELS: ChannelDef[] = [
|
|
{
|
|
key: "github",
|
|
label: "GitHub",
|
|
identifierLabel: "GitHub username",
|
|
identifierPlaceholder: "tudisco",
|
|
toSubject: (s) => `github:${s.trim()}`,
|
|
preferredFormat: "markdown",
|
|
instructions: (subject) =>
|
|
`1. Create a new PUBLIC gist on github.com.\n` +
|
|
`2. Filename: \`kez.md\`\n` +
|
|
`3. Paste the markdown block on the right as the file content.\n` +
|
|
`4. (Alternative) Add the same markdown block to your <user>/<user> profile README.\n\n` +
|
|
`A verifier resolving ${subject} will find the gist via the public gist listing API.`,
|
|
},
|
|
{
|
|
key: "dns",
|
|
label: "DNS",
|
|
identifierLabel: "Domain",
|
|
identifierPlaceholder: "tudisco.com",
|
|
toSubject: (s) => `dns:${s.trim().toLowerCase()}`,
|
|
preferredFormat: "compact",
|
|
instructions: (subject) => {
|
|
const domain = subject.slice(4);
|
|
return (
|
|
`1. Open your DNS registrar / nameserver console for ${domain}.\n` +
|
|
`2. Add a TXT record:\n` +
|
|
` Name: _kez.${domain}\n` +
|
|
` Value: (the kez:z1:… string on the right)\n` +
|
|
`3. Save. Propagation can take a few minutes.\n\n` +
|
|
`Notes:\n` +
|
|
`- DNS TXT records have a 255-byte segment limit. Most registrars (Cloudflare, Route 53, Gandi, etc.) handle long values by splitting into multiple segments automatically — just paste the whole string. If your registrar refuses, see the FAQ.\n` +
|
|
`- Verifiers resolve _kez.${domain} via DNS TXT lookups and decode the kez:z1: form to find your signed claim.`
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: "web",
|
|
label: "Your website",
|
|
identifierLabel: "HTTPS URL",
|
|
identifierPlaceholder: "https://tudisco.com",
|
|
toSubject: (s) => `web:${s.trim().replace(/\/$/, "")}`,
|
|
preferredFormat: "json",
|
|
instructions: (subject) => {
|
|
const base = subject.slice(4);
|
|
return (
|
|
`1. Save the JSON envelope on the right to a file named \`kez.json\`.\n` +
|
|
`2. Upload it to your server at: ${base}/.well-known/kez.json\n` +
|
|
`3. Make sure it's publicly fetchable (no auth, no robots.txt block).\n\n` +
|
|
`Verifiers fetch ${base}/.well-known/kez.json directly.`
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: "nostr",
|
|
label: "Nostr",
|
|
identifierLabel: "Your npub",
|
|
identifierPlaceholder: "npub1...",
|
|
toSubject: (s) => {
|
|
const raw = s.trim();
|
|
return raw.startsWith("nostr:") ? raw : `nostr:${raw}`;
|
|
},
|
|
preferredFormat: "markdown",
|
|
instructions: () =>
|
|
`Pick whichever is easiest — verifiers check all three:\n\n` +
|
|
`A. Add the markdown block on the right to your nostr profile bio\n` +
|
|
` (the "About" field on Damus, primal.net, etc.). One-time edit,\n` +
|
|
` anyone fetching your profile sees the proof.\n\n` +
|
|
`B. Post the markdown block as a normal nostr note (kind 1).\n` +
|
|
` Easiest if you're already active — just paste and publish.\n\n` +
|
|
`C. (Advanced) Publish a NIP-78 kind-30078 event with d='kez' and\n` +
|
|
` the JSON envelope as the event 'content'. Cleanest for tooling\n` +
|
|
` but needs a client that exposes kind-30078 creation.`,
|
|
},
|
|
{
|
|
key: "bluesky",
|
|
label: "Bluesky",
|
|
identifierLabel: "Bluesky handle",
|
|
identifierPlaceholder: "tudisco.bsky.social",
|
|
toSubject: (s) => `bluesky:${s.trim()}`,
|
|
preferredFormat: "markdown",
|
|
instructions: (subject) =>
|
|
`1. Open Bluesky and start a new post on your account (${subject.slice(8)}).\n` +
|
|
`2. Paste the markdown block on the right as the post body.\n` +
|
|
`3. Publish the post (publicly).\n\n` +
|
|
`Verifiers scan your public posts looking for the kez fence.`,
|
|
},
|
|
{
|
|
key: "ap",
|
|
label: "ActivityPub (Mastodon, Pleroma, …)",
|
|
identifierLabel: "ActivityPub handle",
|
|
identifierPlaceholder: "tudisco@mastodon.social",
|
|
toSubject: (s) => {
|
|
const raw = s.trim().replace(/^@/, "");
|
|
return `ap:@${raw}`;
|
|
},
|
|
preferredFormat: "markdown",
|
|
instructions: (subject) =>
|
|
`1. Go to your profile settings on your instance.\n` +
|
|
`2. Either paste the markdown block on the right into your "Profile fields" / metadata,\n` +
|
|
` or post it as a pinned post.\n\n` +
|
|
`Verifiers resolve ${subject} via WebFinger then fetch the actor JSON.`,
|
|
},
|
|
];
|
|
|
|
let step = $state<"pick" | "identifier" | "sign" | "publish" | "done">("pick");
|
|
let selected = $state<ChannelDef | null>(null);
|
|
let identifierInput = $state("");
|
|
let envelope = $state<SignedClaimEnvelope | null>(null);
|
|
let format = $state<Format>("compact");
|
|
let copied = $state(false);
|
|
/** Result of one-click "Publish via Nostr extension" — shown on the publish step. */
|
|
let nostrPublish = $state<
|
|
| { status: "idle" }
|
|
| { status: "pending" }
|
|
| { status: "ok"; result: PublishResult }
|
|
| { status: "error"; message: string }
|
|
>({ status: "idle" });
|
|
|
|
/** Outcome of mirroring this claim into the user's sigchain on the chain service. */
|
|
let sigchainSync = $state<
|
|
| { status: "idle" }
|
|
| { status: "pending" }
|
|
| { status: "ok"; seq: number; noop: boolean }
|
|
| { status: "error"; message: string }
|
|
>({ status: "idle" });
|
|
|
|
/** Re-evaluated each render; cheap (just a typeof check on window.nostr). */
|
|
const nip07Available = $derived(hasNip07());
|
|
|
|
/**
|
|
* Render the current envelope in the chosen format.
|
|
* Async because the compact form initializes the zstd WASM module on
|
|
* first use (subsequent calls are sync). Returns "" if no envelope.
|
|
*/
|
|
async function renderArtifact(
|
|
env: SignedClaimEnvelope,
|
|
fmt: Format,
|
|
): Promise<string> {
|
|
if (fmt === "markdown") return toMarkdown(env);
|
|
if (fmt === "json") return toPrettyJson(env);
|
|
return await toCompact(env);
|
|
}
|
|
|
|
onMount(() => {
|
|
if (!session.unlocked) push("/unlock");
|
|
});
|
|
|
|
function pickChannel(c: ChannelDef) {
|
|
selected = c;
|
|
format = c.preferredFormat; // DNS → compact, github → markdown, web → json
|
|
step = "identifier";
|
|
}
|
|
|
|
async function fillFromNostrExtension() {
|
|
try {
|
|
identifierInput = await getNostrNpub();
|
|
} catch (e) {
|
|
alert(`Couldn't read pubkey from nostr extension: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
async function publishViaNostrExtension() {
|
|
if (!envelope) return;
|
|
nostrPublish = { status: "pending" };
|
|
try {
|
|
const markdown = toMarkdown(envelope);
|
|
const result = await publishKezClaimToNostr(markdown);
|
|
nostrPublish = { status: "ok", result };
|
|
} catch (e) {
|
|
nostrPublish = { status: "error", message: (e as Error).message };
|
|
}
|
|
}
|
|
|
|
function buildAndSign() {
|
|
if (!selected || !session.unlocked) return;
|
|
const subject = selected.toSubject(identifierInput);
|
|
if (!subject.includes(":") || subject.endsWith(":")) {
|
|
alert("Identifier looks empty — please fill in the field.");
|
|
return;
|
|
}
|
|
// UnlockedIdentity has {handle, server, primary, seed} — not the
|
|
// Ed25519Identity {seed, publicKey, identity} shape signClaim wants.
|
|
// Reconstruct from the seed so signer.identity is defined and
|
|
// ends up in payload.primary + signature.key.
|
|
const signer = identityFromSeed(session.unlocked.seed);
|
|
envelope = signClaim(signer, subject);
|
|
step = "publish";
|
|
}
|
|
|
|
async function copyArtifact() {
|
|
if (!envelope) return;
|
|
const text = await renderArtifact(envelope, format);
|
|
await navigator.clipboard.writeText(text);
|
|
copied = true;
|
|
setTimeout(() => (copied = false), 1500);
|
|
}
|
|
|
|
async function saveAndDone() {
|
|
if (!envelope || !selected || !session.unlocked) return;
|
|
const id = crypto.randomUUID();
|
|
const subject = envelope.payload.subject;
|
|
try {
|
|
// $state wraps `envelope` in a deep Proxy; structuredClone (used
|
|
// by idb-keyval) can't clone proxies and throws DataCloneError.
|
|
// $state.snapshot returns a plain, cloneable object.
|
|
await addClaim({
|
|
id,
|
|
envelope: $state.snapshot(envelope) as SignedClaimEnvelope,
|
|
channel: selected.key,
|
|
});
|
|
step = "done";
|
|
} catch (e) {
|
|
console.error("saveAndDone failed", e);
|
|
alert(`Failed to save claim: ${(e as Error).message}`);
|
|
return;
|
|
}
|
|
// Mirror the claim into the user's sigchain on the chain service: append
|
|
// a signed `add` event for this subject. Best-effort — the claim is
|
|
// already saved locally; if the chain service is unreachable we record
|
|
// the error and the user can retry from the Claims page later.
|
|
sigchainSync = { status: "pending" };
|
|
try {
|
|
const chainService = await resolveChainService(session.unlocked.handle);
|
|
const proofUrl =
|
|
nostrPublish.status === "ok" ? nostrPublish.result.evidence_url : undefined;
|
|
const result = await appendSubjectEvent({
|
|
seed: session.unlocked.seed,
|
|
chainService,
|
|
subject,
|
|
op: "add",
|
|
proofUrl,
|
|
});
|
|
await setSigchainSync(id, {
|
|
chain_service: result.chainService,
|
|
sigchain_seq: result.seq,
|
|
sigchain_status: "synced",
|
|
});
|
|
sigchainSync = { status: "ok", seq: result.seq, noop: result.noop };
|
|
} catch (e) {
|
|
await setSigchainSync(id, {
|
|
sigchain_status: "error",
|
|
sigchain_error: (e as Error).message,
|
|
});
|
|
sigchainSync = { status: "error", message: (e as Error).message };
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-6">
|
|
<div class="flex items-center justify-between">
|
|
<h1 class="text-2xl font-bold text-text">Add a claim</h1>
|
|
<button
|
|
class="text-sm text-text-muted hover:text-text"
|
|
onclick={() => push("/claims")}
|
|
>
|
|
← Back to claims
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Stepper -->
|
|
<ol class="flex gap-2 text-xs text-text-muted">
|
|
<li class={step === "pick" ? "font-semibold text-text" : ""}>1. Channel</li>
|
|
<li>→</li>
|
|
<li class={step === "identifier" ? "font-semibold text-text" : ""}>2. Identifier</li>
|
|
<li>→</li>
|
|
<li class={step === "publish" ? "font-semibold text-text" : ""}>3. Publish</li>
|
|
<li>→</li>
|
|
<li class={step === "done" ? "font-semibold text-text" : ""}>4. Done</li>
|
|
</ol>
|
|
|
|
{#if step === "pick"}
|
|
<div class="grid sm:grid-cols-2 gap-3">
|
|
{#each CHANNELS as c}
|
|
<button
|
|
class="text-left border border-border rounded-lg p-4 bg-surface hover:border-accent-dim transition"
|
|
onclick={() => pickChannel(c)}
|
|
>
|
|
<p class="font-semibold text-text">{c.label}</p>
|
|
<p class="text-xs text-text-muted mt-1 font-mono">{c.key}:<…></p>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if step === "identifier" && selected}
|
|
<form
|
|
class="space-y-4"
|
|
onsubmit={(e) => { e.preventDefault(); buildAndSign(); }}
|
|
>
|
|
<div>
|
|
<label class="block text-sm font-medium text-text-secondary" for="ident">
|
|
{selected.identifierLabel}
|
|
</label>
|
|
<input
|
|
id="ident"
|
|
type="text"
|
|
bind:value={identifierInput}
|
|
placeholder={selected.identifierPlaceholder}
|
|
class="mt-1 w-full px-3 py-2 border border-border rounded-md font-mono"
|
|
autocomplete="off"
|
|
/>
|
|
{#if selected.key === "nostr" && nip07Available}
|
|
<button
|
|
type="button"
|
|
class="mt-2 inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-accent/40 bg-accent/10 text-accent rounded-md hover:bg-accent/20"
|
|
onclick={fillFromNostrExtension}
|
|
>
|
|
⚡ Use my nostr extension
|
|
</button>
|
|
<span class="ml-2 text-xs text-text-muted">Reads your pubkey via NIP-07 — no copy/paste.</span>
|
|
{/if}
|
|
{#if identifierInput.trim()}
|
|
<p class="mt-1 text-xs text-text-muted">
|
|
Subject will be: <code class="bg-elevated px-1 rounded">{selected.toSubject(identifierInput)}</code>
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<button
|
|
type="button"
|
|
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
|
|
onclick={() => { step = "pick"; selected = null; }}
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
|
|
disabled={identifierInput.trim().length === 0}
|
|
>
|
|
Sign claim
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if step === "publish" && envelope && selected}
|
|
<div class="grid lg:grid-cols-2 gap-6">
|
|
<section class="space-y-3">
|
|
<h2 class="text-sm font-semibold text-text-secondary uppercase tracking-wide">
|
|
1. Publish on {selected.label}
|
|
</h2>
|
|
<pre class="whitespace-pre-wrap text-sm bg-elevated border border-border rounded p-4 leading-relaxed">{selected.instructions(envelope.payload.subject)}</pre>
|
|
</section>
|
|
|
|
<section class="space-y-3">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<h2 class="text-sm font-semibold text-text-secondary uppercase tracking-wide flex-1">
|
|
2. Copy this:
|
|
</h2>
|
|
<div class="flex border border-border rounded overflow-hidden text-xs">
|
|
<button
|
|
class={`px-2 py-1 ${format === "compact" ? "bg-accent text-accent-contrast" : "bg-surface text-text-secondary hover:bg-elevated"}`}
|
|
onclick={() => (format = "compact")}
|
|
title="kez:z1: — zstd + base64url. Fits in tight places like DNS TXT records, QR codes, chat messages."
|
|
>compact</button>
|
|
<button
|
|
class={`px-2 py-1 border-l border-border ${format === "markdown" ? "bg-accent text-accent-contrast" : "bg-surface text-text-secondary hover:bg-elevated"}`}
|
|
onclick={() => (format = "markdown")}
|
|
title="Human-readable; embeds the JSON inside a ```kez fence. Best for gists and README files."
|
|
>markdown</button>
|
|
<button
|
|
class={`px-2 py-1 border-l border-border ${format === "json" ? "bg-accent text-accent-contrast" : "bg-surface text-text-secondary hover:bg-elevated"}`}
|
|
onclick={() => (format = "json")}
|
|
title="Raw envelope JSON. Best for .well-known/kez.json and developer tooling."
|
|
>JSON</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#await renderArtifact(envelope, format)}
|
|
<pre class="text-xs bg-accent text-text rounded p-4 font-mono leading-relaxed max-h-96 min-h-32 flex items-center justify-center text-text-muted">Computing…</pre>
|
|
{:then text}
|
|
<pre class="text-xs bg-accent text-text rounded p-4 overflow-x-auto font-mono leading-relaxed max-h-96 overflow-y-auto whitespace-pre-wrap break-all">{text}</pre>
|
|
{#if format === "compact"}
|
|
<p class="text-xs text-text-muted">
|
|
{text.length} chars · zstd-compressed signed envelope, base64url-encoded.
|
|
</p>
|
|
{/if}
|
|
{:catch e}
|
|
<pre class="text-xs bg-danger/10 border border-danger/40 text-danger rounded p-4">Error: {e.message}</pre>
|
|
{/await}
|
|
|
|
<button
|
|
class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated"
|
|
onclick={copyArtifact}
|
|
>
|
|
{copied ? "✓ Copied" : "Copy to clipboard"}
|
|
</button>
|
|
</section>
|
|
</div>
|
|
|
|
{#if selected.key === "nostr" && nip07Available}
|
|
<div class="border border-accent/40 bg-accent/10 rounded-lg p-4">
|
|
<p class="text-sm font-semibold text-accent">⚡ One-click publish via your nostr extension</p>
|
|
<p class="mt-1 text-xs text-accent">
|
|
Wraps the markdown block in a normal nostr post (kind 1), asks
|
|
your extension to sign it, and broadcasts to the relay pool.
|
|
Verifiers (web + Rust CLI) will pick it up automatically.
|
|
</p>
|
|
<div class="mt-3 flex items-center gap-3">
|
|
<button
|
|
class="px-3 py-1.5 text-sm bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
|
|
onclick={publishViaNostrExtension}
|
|
disabled={nostrPublish.status === "pending"}
|
|
>
|
|
{nostrPublish.status === "pending" ? "Publishing…" : "Publish to nostr"}
|
|
</button>
|
|
{#if nostrPublish.status === "ok"}
|
|
<span class="text-xs text-verified">
|
|
✓ Posted to {nostrPublish.result.ok.length} relay(s).
|
|
<a
|
|
href={nostrPublish.result.evidence_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="underline"
|
|
>view on njump.me</a>
|
|
</span>
|
|
{:else if nostrPublish.status === "error"}
|
|
<span class="text-xs text-danger">✗ {nostrPublish.message}</span>
|
|
{/if}
|
|
</div>
|
|
{#if nostrPublish.status === "ok" && nostrPublish.result.failed.length > 0}
|
|
<p class="mt-2 text-xs text-warning">
|
|
{nostrPublish.result.failed.length} relay(s) didn't ack:
|
|
{nostrPublish.result.failed.map((f) => f.relay).join(", ")}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex gap-2 pt-4 border-t border-border">
|
|
<button
|
|
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
|
|
onclick={() => { step = "identifier"; }}
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim"
|
|
onclick={saveAndDone}
|
|
>
|
|
Save claim
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if step === "done" && envelope}
|
|
<div class="border border-verified/40 bg-verified/10 rounded-lg p-6">
|
|
<p class="text-lg font-semibold text-verified">✓ Claim saved</p>
|
|
<p class="mt-2 text-sm text-verified">
|
|
You signed a claim for
|
|
<code class="font-mono">{envelope.payload.subject}</code>.
|
|
Once you've published the proof on that channel, come back to the
|
|
Claims page and mark it published.
|
|
</p>
|
|
|
|
<!-- Chain-service (sigchain) mirror status -->
|
|
<div class="mt-3 text-sm">
|
|
{#if sigchainSync.status === "pending"}
|
|
<p class="text-text-secondary">⏳ Updating your sigchain on the chain service…</p>
|
|
{:else if sigchainSync.status === "ok"}
|
|
<p class="text-verified">
|
|
{#if sigchainSync.noop}
|
|
⛓ Already on your sigchain (seq {sigchainSync.seq}) — nothing to add.
|
|
{:else}
|
|
⛓ Sigchain updated — added at seq {sigchainSync.seq} on the chain service.
|
|
{/if}
|
|
</p>
|
|
{:else if sigchainSync.status === "error"}
|
|
<p class="text-warning">
|
|
⚠ Couldn't update your sigchain on the chain service:
|
|
{sigchainSync.message}. The claim is saved locally — retry the
|
|
sigchain sync from the Claims page.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="mt-4 flex gap-2">
|
|
<a
|
|
href="#/claims"
|
|
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim no-underline"
|
|
>
|
|
Back to claims
|
|
</a>
|
|
<button
|
|
class="px-4 py-2 border border-verified/40 bg-surface rounded-md text-verified hover:bg-elevated"
|
|
onclick={() => {
|
|
step = "pick";
|
|
selected = null;
|
|
identifierInput = "";
|
|
envelope = null;
|
|
nostrPublish = { status: "idle" };
|
|
sigchainSync = { status: "idle" };
|
|
}}
|
|
>
|
|
Add another
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|