@tollbooth-dpyc/web 0.1.0

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.
Files changed (52) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +69 -0
  3. package/dist/avatar.d.ts +20 -0
  4. package/dist/avatar.js +54 -0
  5. package/dist/client.d.ts +42 -0
  6. package/dist/client.js +169 -0
  7. package/dist/config.d.ts +39 -0
  8. package/dist/config.js +33 -0
  9. package/dist/debugLog.d.ts +18 -0
  10. package/dist/debugLog.js +29 -0
  11. package/dist/identity.d.ts +43 -0
  12. package/dist/identity.js +106 -0
  13. package/dist/index.d.ts +12 -0
  14. package/dist/index.js +11 -0
  15. package/dist/inlineProof.d.ts +10 -0
  16. package/dist/inlineProof.js +21 -0
  17. package/dist/nostrProfile.d.ts +13 -0
  18. package/dist/nostrProfile.js +54 -0
  19. package/dist/pagesProxy.d.ts +22 -0
  20. package/dist/pagesProxy.js +54 -0
  21. package/dist/profilePresentation.d.ts +17 -0
  22. package/dist/profilePresentation.js +32 -0
  23. package/dist/react/Avatar.d.ts +6 -0
  24. package/dist/react/Avatar.js +7 -0
  25. package/dist/react/AvatarPicker.d.ts +9 -0
  26. package/dist/react/AvatarPicker.js +64 -0
  27. package/dist/react/NostrProfilePanel.d.ts +8 -0
  28. package/dist/react/NostrProfilePanel.js +116 -0
  29. package/dist/react/NpubGate.d.ts +16 -0
  30. package/dist/react/NpubGate.js +159 -0
  31. package/dist/react/WalletCard.d.ts +11 -0
  32. package/dist/react/WalletCard.js +82 -0
  33. package/dist/react/index.d.ts +6 -0
  34. package/dist/react/index.js +6 -0
  35. package/dist/react/ui.d.ts +9 -0
  36. package/dist/react/ui.js +9 -0
  37. package/dist/react/useSession.d.ts +19 -0
  38. package/dist/react/useSession.js +39 -0
  39. package/dist/sessionNsec.d.ts +23 -0
  40. package/dist/sessionNsec.js +59 -0
  41. package/dist/signInSituation.d.ts +22 -0
  42. package/dist/signInSituation.js +42 -0
  43. package/dist/signedIn.d.ts +26 -0
  44. package/dist/signedIn.js +26 -0
  45. package/dist/standardTools.d.ts +134 -0
  46. package/dist/standardTools.js +68 -0
  47. package/dist/storage.d.ts +14 -0
  48. package/dist/storage.js +46 -0
  49. package/dist/toolResult.d.ts +37 -0
  50. package/dist/toolResult.js +60 -0
  51. package/package.json +60 -0
  52. package/theme.css +47 -0
@@ -0,0 +1,29 @@
1
+ /**
2
+ * An in-memory activity log of MCP calls, for a site's debug panel.
3
+ *
4
+ * A ring buffer with subscribers, so the one `callTool` can push and any panel
5
+ * can listen. It survives route changes and is gone on reload. Arguments are
6
+ * logged only as the wrapper's own parameters — the npub/proof envelope is
7
+ * added after logging, so no proof ever lands here.
8
+ */
9
+ const log = [];
10
+ const listeners = new Set();
11
+ const MAX = 60;
12
+ export function debugPush(type, message) {
13
+ log.unshift({ ts: new Date().toLocaleTimeString(), type, message });
14
+ if (log.length > MAX)
15
+ log.length = MAX;
16
+ listeners.forEach((fn) => fn());
17
+ }
18
+ export function clearDebug() {
19
+ log.length = 0;
20
+ listeners.forEach((fn) => fn());
21
+ }
22
+ export function debugEntries() {
23
+ return log;
24
+ }
25
+ /** Subscribe to changes. Returns an unsubscribe function. */
26
+ export function onDebug(fn) {
27
+ listeners.add(fn);
28
+ return () => listeners.delete(fn);
29
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Who this browser says it is, and what backs the claim.
3
+ *
4
+ * Three stored things, kept apart on purpose:
5
+ * - the **npub** the app acts as — written only once it is proven;
6
+ * - the cached **DM proof** that backs it, when the patron signed in by DM;
7
+ * - the **last typed** npub, remembered only to prefill the field. Writing a
8
+ * name into the identity slot before it was proven is what once let an
9
+ * unanswered challenge become a session.
10
+ */
11
+ import { type Claim } from "./signedIn.ts";
12
+ export declare function getStoredNpub(): string;
13
+ export declare function setStoredNpub(npub: string): void;
14
+ export declare function getStoredProof(): string;
15
+ export declare function setStoredProof(proof: string): void;
16
+ export declare function clearStoredProof(): void;
17
+ export declare function getLastTypedNpub(): string;
18
+ export declare function setLastTypedNpub(npub: string): void;
19
+ export interface RecentLogin {
20
+ npub: string;
21
+ proof: string;
22
+ /** Unix ms. */
23
+ expiresAt: number;
24
+ /** Unix ms. */
25
+ lastUsed: number;
26
+ }
27
+ /** Unexpired recent logins, most recent first. Prunes the expired ones. */
28
+ export declare function getValidRecentLogins(): RecentLogin[];
29
+ /**
30
+ * Record or refresh a successful login. The TTL is derated by 30 seconds so a
31
+ * straggler never serves an already-expired token to the next paid call.
32
+ */
33
+ export declare function recordRecentLogin(npub: string, proof: string, expiresInSec: number): void;
34
+ export declare function forgetRecentLogin(npub: string): void;
35
+ /** Read once and handed to the pure predicates in `signedIn.ts`. */
36
+ export declare function currentClaim(): Claim;
37
+ export declare function isLoggedIn(): boolean;
38
+ export declare function canSign(): boolean;
39
+ /**
40
+ * Everything that says who you are goes, including the session key — a
41
+ * sign-out that leaves a usable signing key behind is not one.
42
+ */
43
+ export declare function logOut(): void;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Who this browser says it is, and what backs the claim.
3
+ *
4
+ * Three stored things, kept apart on purpose:
5
+ * - the **npub** the app acts as — written only once it is proven;
6
+ * - the cached **DM proof** that backs it, when the patron signed in by DM;
7
+ * - the **last typed** npub, remembered only to prefill the field. Writing a
8
+ * name into the identity slot before it was proven is what once let an
9
+ * unanswered challenge become a session.
10
+ */
11
+ import { clearSessionNsec, hasSessionNsec, sessionNsecNpub } from "./sessionNsec.js";
12
+ import { canSignFor, isProven } from "./signedIn.js";
13
+ import { readStored, removeStored, writeStored } from "./storage.js";
14
+ const NPUB = "patron_npub:v1";
15
+ const PROOF = "proof_token:v1";
16
+ const LAST_TYPED = "last_typed_npub:v1";
17
+ const RECENT = "recent-logins:v1";
18
+ const MAX_RECENT = 5;
19
+ export function getStoredNpub() {
20
+ return readStored(NPUB);
21
+ }
22
+ export function setStoredNpub(npub) {
23
+ writeStored(NPUB, npub);
24
+ }
25
+ export function getStoredProof() {
26
+ return readStored(PROOF);
27
+ }
28
+ export function setStoredProof(proof) {
29
+ writeStored(PROOF, proof);
30
+ }
31
+ export function clearStoredProof() {
32
+ removeStored(PROOF);
33
+ }
34
+ export function getLastTypedNpub() {
35
+ return readStored(LAST_TYPED);
36
+ }
37
+ export function setLastTypedNpub(npub) {
38
+ writeStored(LAST_TYPED, npub);
39
+ }
40
+ function readRecent() {
41
+ try {
42
+ const parsed = JSON.parse(readStored(RECENT) || "[]");
43
+ if (!Array.isArray(parsed))
44
+ return [];
45
+ return parsed.filter((e) => typeof e === "object" && e !== null &&
46
+ typeof e.npub === "string" && typeof e.proof === "string" &&
47
+ typeof e.expiresAt === "number" && typeof e.lastUsed === "number");
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ }
53
+ function writeRecent(entries) {
54
+ writeStored(RECENT, JSON.stringify(entries));
55
+ }
56
+ /** Unexpired recent logins, most recent first. Prunes the expired ones. */
57
+ export function getValidRecentLogins() {
58
+ const now = Date.now();
59
+ const entries = readRecent();
60
+ const valid = entries.filter((e) => e.expiresAt > now);
61
+ if (valid.length !== entries.length)
62
+ writeRecent(valid);
63
+ return valid.sort((a, b) => b.lastUsed - a.lastUsed);
64
+ }
65
+ /**
66
+ * Record or refresh a successful login. The TTL is derated by 30 seconds so a
67
+ * straggler never serves an already-expired token to the next paid call.
68
+ */
69
+ export function recordRecentLogin(npub, proof, expiresInSec) {
70
+ const now = Date.now();
71
+ const next = {
72
+ npub,
73
+ proof,
74
+ expiresAt: now + Math.max(0, expiresInSec - 30) * 1000,
75
+ lastUsed: now,
76
+ };
77
+ const others = readRecent().filter((e) => e.npub !== npub);
78
+ writeRecent([next, ...others].slice(0, MAX_RECENT));
79
+ }
80
+ export function forgetRecentLogin(npub) {
81
+ writeRecent(readRecent().filter((e) => e.npub !== npub));
82
+ }
83
+ // ─── The claim ───────────────────────────────────────────────────────────
84
+ /** Read once and handed to the pure predicates in `signedIn.ts`. */
85
+ export function currentClaim() {
86
+ return {
87
+ npub: getStoredNpub(),
88
+ proof: getStoredProof(),
89
+ sessionNpub: hasSessionNsec() ? sessionNsecNpub() : null,
90
+ };
91
+ }
92
+ export function isLoggedIn() {
93
+ return isProven(currentClaim());
94
+ }
95
+ export function canSign() {
96
+ return canSignFor(currentClaim());
97
+ }
98
+ /**
99
+ * Everything that says who you are goes, including the session key — a
100
+ * sign-out that leaves a usable signing key behind is not one.
101
+ */
102
+ export function logOut() {
103
+ removeStored(NPUB);
104
+ removeStored(PROOF);
105
+ clearSessionNsec();
106
+ }
@@ -0,0 +1,12 @@
1
+ export { configureTollbooth, tollboothConfig, toolName, type TollboothConfig } from "./config.ts";
2
+ export { callTool, callToolWithContent, onProofExpired, ProofRequiredError, type CallOptions, type ToolAnswer, } from "./client.ts";
3
+ export type { ImageBlock } from "./toolResult.ts";
4
+ export * from "./identity.ts";
5
+ export * from "./standardTools.ts";
6
+ export { clearSessionNsec, getSessionNsec, hasSessionNsec, sessionNsecNpub, setSessionNsec, } from "./sessionNsec.ts";
7
+ export { signInlineProof } from "./inlineProof.ts";
8
+ export { canSignFor, isProven, type Claim } from "./signedIn.ts";
9
+ export { readSignInFailure, type Situation } from "./signInSituation.ts";
10
+ export { canSignProfile, fetchProfile, publishProfile } from "./nostrProfile.ts";
11
+ export * from "./avatar.ts";
12
+ export { clearDebug, debugEntries, debugPush, onDebug, type DebugEntry } from "./debugLog.ts";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export { configureTollbooth, tollboothConfig, toolName } from "./config.js";
2
+ export { callTool, callToolWithContent, onProofExpired, ProofRequiredError, } from "./client.js";
3
+ export * from "./identity.js";
4
+ export * from "./standardTools.js";
5
+ export { clearSessionNsec, getSessionNsec, hasSessionNsec, sessionNsecNpub, setSessionNsec, } from "./sessionNsec.js";
6
+ export { signInlineProof } from "./inlineProof.js";
7
+ export { canSignFor, isProven } from "./signedIn.js";
8
+ export { readSignInFailure } from "./signInSituation.js";
9
+ export { canSignProfile, fetchProfile, publishProfile } from "./nostrProfile.js";
10
+ export * from "./avatar.js";
11
+ export { clearDebug, debugEntries, debugPush, onDebug } from "./debugLog.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Inline kind-27235 identity proof — Tactic 2 in the wheel's
3
+ * `identity_proof.verify_proof`. A fresh signed event per paid call, bound to:
4
+ * the sender pubkey (must match the claimed npub), the `u` tag (must match the
5
+ * runtime tool name, e.g. "chart_snapshot_display"), and `created_at` (within
6
+ * about 60 seconds of server time). The wheel verifies the Schnorr signature
7
+ * inline — no relay round trip, no cached phrase.
8
+ */
9
+ /** The JSON-stringified signed event, exactly what `verify_proof` expects. */
10
+ export declare function signInlineProof(runtimeToolName: string, secretKey?: Uint8Array): string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Inline kind-27235 identity proof — Tactic 2 in the wheel's
3
+ * `identity_proof.verify_proof`. A fresh signed event per paid call, bound to:
4
+ * the sender pubkey (must match the claimed npub), the `u` tag (must match the
5
+ * runtime tool name, e.g. "chart_snapshot_display"), and `created_at` (within
6
+ * about 60 seconds of server time). The wheel verifies the Schnorr signature
7
+ * inline — no relay round trip, no cached phrase.
8
+ */
9
+ import { finalizeEvent } from "nostr-tools";
10
+ import { getSessionNsecBytes } from "./sessionNsec.js";
11
+ const PROOF_KIND = 27235;
12
+ /** The JSON-stringified signed event, exactly what `verify_proof` expects. */
13
+ export function signInlineProof(runtimeToolName, secretKey) {
14
+ const signed = finalizeEvent({
15
+ kind: PROOF_KIND,
16
+ created_at: Math.floor(Date.now() / 1000),
17
+ tags: [["u", runtimeToolName]],
18
+ content: "",
19
+ }, secretKey ?? getSessionNsecBytes());
20
+ return JSON.stringify(signed);
21
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The patron's Nostr kind-0 profile.
3
+ *
4
+ * Relay I/O lives in the wheel. The browser keeps only the part that must stay
5
+ * client side — SIGNING — with the session key or a NIP-07 extension, and
6
+ * hands the signed event over. No nsec ever goes to a server.
7
+ */
8
+ import { type Kind0, type PublishNostrProfileResult } from "./standardTools.ts";
9
+ /** A session key or a NIP-07 signer is present. */
10
+ export declare function canSignProfile(): boolean;
11
+ export declare function fetchProfile(npub: string): Promise<Kind0 | null>;
12
+ /** Sign a kind-0 in the browser and have the wheel relay it. */
13
+ export declare function publishProfile(content: Kind0): Promise<PublishNostrProfileResult>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The patron's Nostr kind-0 profile.
3
+ *
4
+ * Relay I/O lives in the wheel. The browser keeps only the part that must stay
5
+ * client side — SIGNING — with the session key or a NIP-07 extension, and
6
+ * hands the signed event over. No nsec ever goes to a server.
7
+ */
8
+ import { finalizeEvent } from "nostr-tools";
9
+ import { getStoredNpub } from "./identity.js";
10
+ import { getSessionNsecBytes, hasSessionNsec } from "./sessionNsec.js";
11
+ import { getNostrProfile, publishNostrProfile, } from "./standardTools.js";
12
+ function nip07() {
13
+ return globalThis.nostr;
14
+ }
15
+ /** A session key or a NIP-07 signer is present. */
16
+ export function canSignProfile() {
17
+ return hasSessionNsec() || !!nip07();
18
+ }
19
+ export async function fetchProfile(npub) {
20
+ try {
21
+ const r = await getNostrProfile(npub);
22
+ return r.profile && Object.keys(r.profile).length ? r.profile : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /** Sign a kind-0 in the browser and have the wheel relay it. */
29
+ export async function publishProfile(content) {
30
+ const clean = {};
31
+ for (const [k, v] of Object.entries(content)) {
32
+ if (typeof v === "string" && v.trim())
33
+ clean[k] = v.trim();
34
+ }
35
+ const template = {
36
+ kind: 0,
37
+ created_at: Math.floor(Date.now() / 1000),
38
+ tags: [],
39
+ content: JSON.stringify(clean),
40
+ };
41
+ let signed;
42
+ const signer = nip07();
43
+ if (hasSessionNsec()) {
44
+ signed = finalizeEvent(template, getSessionNsecBytes());
45
+ }
46
+ else if (signer) {
47
+ const pubkey = await signer.getPublicKey();
48
+ signed = await signer.signEvent({ ...template, pubkey });
49
+ }
50
+ else {
51
+ throw new Error("No signer available — sign in with a session key or a NIP-07 extension to publish your Nostr profile.");
52
+ }
53
+ return publishNostrProfile(getStoredNpub(), JSON.stringify(signed));
54
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A Cloudflare Pages Function that proxies `/mcp` to an operator on Horizon.
3
+ *
4
+ * Keeping the browser → MCP transport same-origin avoids CORS preflight
5
+ * trouble and lets the page use a relative `mcpUrl` of "/mcp". POST (tool
6
+ * calls), GET (SSE), DELETE (session close) and OPTIONS (preflight).
7
+ *
8
+ * // functions/mcp.js
9
+ * import { makeMcpProxy } from "@tollbooth-dpyc/web/pages-proxy";
10
+ * export const onRequest = makeMcpProxy("https://chartremotely-mcp.fastmcp.app/mcp");
11
+ *
12
+ * Only the headers MCP needs are forwarded upstream. Cookies and anything else
13
+ * the browser attached stay behind.
14
+ */
15
+ export declare const CORS_HEADERS: Record<string, string>;
16
+ type Fetch = (input: string, init: RequestInit & {
17
+ duplex?: string;
18
+ }) => Promise<Response>;
19
+ export declare function makeMcpProxy(upstream: string, fetchImpl?: Fetch): (context: {
20
+ request: Request;
21
+ }) => Promise<Response>;
22
+ export {};
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A Cloudflare Pages Function that proxies `/mcp` to an operator on Horizon.
3
+ *
4
+ * Keeping the browser → MCP transport same-origin avoids CORS preflight
5
+ * trouble and lets the page use a relative `mcpUrl` of "/mcp". POST (tool
6
+ * calls), GET (SSE), DELETE (session close) and OPTIONS (preflight).
7
+ *
8
+ * // functions/mcp.js
9
+ * import { makeMcpProxy } from "@tollbooth-dpyc/web/pages-proxy";
10
+ * export const onRequest = makeMcpProxy("https://chartremotely-mcp.fastmcp.app/mcp");
11
+ *
12
+ * Only the headers MCP needs are forwarded upstream. Cookies and anything else
13
+ * the browser attached stay behind.
14
+ */
15
+ export const CORS_HEADERS = {
16
+ "Access-Control-Allow-Origin": "*",
17
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
18
+ "Access-Control-Allow-Headers": "content-type, mcp-session-id, accept, last-event-id",
19
+ "Access-Control-Expose-Headers": "mcp-session-id",
20
+ };
21
+ const FORWARDED = new Set(["content-type", "accept", "mcp-session-id", "last-event-id"]);
22
+ export function makeMcpProxy(upstream, fetchImpl = fetch) {
23
+ const host = new URL(upstream).host;
24
+ return async function onRequest(context) {
25
+ const req = context.request;
26
+ if (req.method === "OPTIONS") {
27
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
28
+ }
29
+ const headers = new Headers();
30
+ for (const [k, v] of req.headers) {
31
+ if (FORWARDED.has(k.toLowerCase()))
32
+ headers.set(k, v);
33
+ }
34
+ headers.set("Host", host);
35
+ const init = { method: req.method, headers };
36
+ if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "DELETE") {
37
+ init.body = req.body;
38
+ init.duplex = "half";
39
+ }
40
+ try {
41
+ const resp = await fetchImpl(upstream, init);
42
+ const out = new Headers(resp.headers);
43
+ for (const [k, v] of Object.entries(CORS_HEADERS))
44
+ out.set(k, v);
45
+ return new Response(resp.body, { status: resp.status, headers: out });
46
+ }
47
+ catch (err) {
48
+ return new Response(JSON.stringify({ error: "Proxy error", detail: String(err) }), {
49
+ status: 502,
50
+ headers: { "Content-Type": "application/json", ...CORS_HEADERS },
51
+ });
52
+ }
53
+ };
54
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Pure rules for the Nostr profile panel, testable under node:test.
3
+ */
4
+ export type PublishControlMode = "publish" | "how-to-set";
5
+ /** Without a session key or NIP-07 signer, explain instead of publishing. */
6
+ export declare function publishControlMode(canSign: boolean): PublishControlMode;
7
+ export declare function publishControlLabel(mode: PublishControlMode, publishing: boolean): string;
8
+ /**
9
+ * "How to set…" must never be disabled: a disabled element fires no pointer
10
+ * events, so its handler never runs — and an iPad has no hover to fall back on.
11
+ */
12
+ export declare function publishControlDisabled(mode: PublishControlMode, publishing: boolean): boolean;
13
+ export declare function collapsedDisplayName(displayName: string): string;
14
+ export declare function howToSetExplainer(appName: string): {
15
+ title: string;
16
+ paragraphs: string[];
17
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Pure rules for the Nostr profile panel, testable under node:test.
3
+ */
4
+ /** Without a session key or NIP-07 signer, explain instead of publishing. */
5
+ export function publishControlMode(canSign) {
6
+ return canSign ? "publish" : "how-to-set";
7
+ }
8
+ export function publishControlLabel(mode, publishing) {
9
+ if (mode === "how-to-set")
10
+ return "How to set…";
11
+ return publishing ? "Publishing…" : "Publish to Nostr";
12
+ }
13
+ /**
14
+ * "How to set…" must never be disabled: a disabled element fires no pointer
15
+ * events, so its handler never runs — and an iPad has no hover to fall back on.
16
+ */
17
+ export function publishControlDisabled(mode, publishing) {
18
+ return mode === "publish" && publishing;
19
+ }
20
+ export function collapsedDisplayName(displayName) {
21
+ return displayName.trim() || "Nostr profile";
22
+ }
23
+ export function howToSetExplainer(appName) {
24
+ return {
25
+ title: "How to set your Nostr profile",
26
+ paragraphs: [
27
+ `Nostr identity is a secure keypair. Your profile is owned by the key, not by ${appName}.`,
28
+ "To change it, use your preferred Nostr client — the one that already holds your key.",
29
+ `${appName} could accept your nsec directly, but deliberately does not ask for it. Handing a private key to a web front end is the thing the keypair model exists to avoid.`,
30
+ ],
31
+ };
32
+ }
@@ -0,0 +1,6 @@
1
+ /** An emoji glyph or an image, in a circle. */
2
+ export default function Avatar({ value, size, className, }: {
3
+ value?: string | null;
4
+ size?: number;
5
+ className?: string;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { isAvatarUrl } from "../avatar.js";
3
+ /** An emoji glyph or an image, in a circle. */
4
+ export default function Avatar({ value, size = 40, className = "", }) {
5
+ const raw = value && value.trim() ? value : "🃏";
6
+ return (_jsx("span", { style: { width: size, height: size, minWidth: size, fontSize: Math.round(size * 0.55), lineHeight: 1 }, className: `inline-flex items-center justify-center overflow-hidden rounded-full select-none border border-[var(--tb-line)] bg-[var(--tb-surface-2)] ${className}`, children: isAvatarUrl(raw) ? (_jsx("img", { src: raw, alt: "", loading: "lazy", className: "block h-full w-full object-cover" })) : (raw) }));
7
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Avatar chooser: a paged Iconify catalog, the glyph palette, and a custom
3
+ * URL or glyph. Iconify (api.iconify.design) is a free public endpoint; each
4
+ * SVG loads lazily per tile and nothing is bundled.
5
+ */
6
+ export default function AvatarPicker({ value, onChange }: {
7
+ value: string;
8
+ onChange: (next: string) => void;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,64 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Avatar chooser: a paged Iconify catalog, the glyph palette, and a custom
4
+ * URL or glyph. Iconify (api.iconify.design) is a free public endpoint; each
5
+ * SVG loads lazily per tile and nothing is bundled.
6
+ */
7
+ import { useEffect, useState } from "react";
8
+ import { Loader2 } from "lucide-react";
9
+ import { AVATAR_CHOICES } from "../avatar.js";
10
+ import Avatar from "./Avatar.js";
11
+ import { errBox, input, muted } from "./ui.js";
12
+ const COLLECTIONS = [
13
+ { prefix: "fluent-emoji-flat", label: "Fluent — Microsoft, flat" },
14
+ { prefix: "twemoji", label: "Twemoji — Twitter's set" },
15
+ { prefix: "noto", label: "Noto — Google" },
16
+ { prefix: "openmoji", label: "OpenMoji — CC-BY-SA" },
17
+ { prefix: "emojione-v1", label: "EmojiOne — classic" },
18
+ ];
19
+ const PAGE_SIZE = 30;
20
+ const iconUrl = (prefix, name) => `https://api.iconify.design/${prefix}/${name}.svg`;
21
+ const tile = (selected) => `flex items-center justify-center aspect-square rounded-md p-1 border transition-colors ${selected
22
+ ? "border-[var(--tb-accent)] bg-[var(--tb-surface-2)]"
23
+ : "border-[var(--tb-line)] hover:border-[var(--tb-accent)]"}`;
24
+ export default function AvatarPicker({ value, onChange }) {
25
+ const [tab, setTab] = useState("catalog");
26
+ const [collection, setCollection] = useState(COLLECTIONS[0].prefix);
27
+ const [names, setNames] = useState([]);
28
+ const [page, setPage] = useState(0);
29
+ const [loading, setLoading] = useState(false);
30
+ const [error, setError] = useState("");
31
+ useEffect(() => {
32
+ let alive = true;
33
+ setLoading(true);
34
+ setError("");
35
+ setNames([]);
36
+ setPage(0);
37
+ fetch(`https://api.iconify.design/collection?prefix=${collection}`)
38
+ .then((r) => {
39
+ if (!r.ok)
40
+ throw new Error(`Iconify returned ${r.status}`);
41
+ return r.json();
42
+ })
43
+ .then((data) => {
44
+ if (!alive)
45
+ return;
46
+ const out = [];
47
+ for (const list of Object.values(data.categories ?? {}))
48
+ out.push(...list);
49
+ out.push(...(data.uncategorized ?? []));
50
+ setNames(out);
51
+ })
52
+ .catch((e) => alive && setError(e.message))
53
+ .finally(() => alive && setLoading(false));
54
+ return () => {
55
+ alive = false;
56
+ };
57
+ }, [collection]);
58
+ const totalPages = Math.max(1, Math.ceil(names.length / PAGE_SIZE));
59
+ const visible = names.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
60
+ return (_jsxs("div", { children: [_jsx("div", { className: "flex gap-2 mb-3 text-xs", children: ["catalog", "glyphs"].map((t) => (_jsx("button", { type: "button", onClick: () => setTab(t), className: `px-3 py-1.5 rounded-full transition-colors ${tab === t ? "bg-[var(--tb-accent)] text-[var(--tb-on-accent)]" : `${muted} hover:bg-[var(--tb-surface-2)]`}`, children: t === "catalog" ? "Catalog" : "Glyphs" }, t))) }), tab === "catalog" && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center gap-2 mb-3", children: [_jsx("select", { value: collection, onChange: (e) => setCollection(e.target.value), className: `${input} py-1.5 text-xs font-sans`, children: COLLECTIONS.map((c) => (_jsx("option", { value: c.prefix, children: c.label }, c.prefix))) }), !loading && !error && names.length > 0 && (_jsxs("span", { className: `text-xs whitespace-nowrap ${muted}`, children: [page + 1, "/", totalPages] }))] }), loading && (_jsxs("p", { className: `flex items-center justify-center gap-1.5 py-6 text-xs ${muted}`, children: [_jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }), " Loading catalog\u2026"] })), error && _jsxs("div", { className: errBox, children: ["Couldn't reach Iconify: ", error] }), !loading && !error && (_jsxs(_Fragment, { children: [_jsx("div", { className: "grid grid-cols-10 gap-1.5", children: visible.map((name) => {
61
+ const url = iconUrl(collection, name);
62
+ return (_jsx("button", { type: "button", onClick: () => onChange(url), title: name.replace(/-/g, " "), className: tile(value === url), children: _jsx("img", { src: url, alt: name, loading: "lazy", className: "h-full w-full object-contain" }) }, name));
63
+ }) }), _jsxs("div", { className: "flex items-center justify-center gap-3 mt-3 text-xs", children: [_jsx("button", { type: "button", onClick: () => setPage((p) => Math.max(0, p - 1)), disabled: page === 0, className: "px-3 py-1 rounded-full border border-[var(--tb-line)] disabled:opacity-30", children: "\u2190 Prev" }), _jsx("button", { type: "button", onClick: () => setPage((p) => Math.min(totalPages - 1, p + 1)), disabled: page >= totalPages - 1, className: "px-3 py-1 rounded-full border border-[var(--tb-line)] disabled:opacity-30", children: "Next \u2192" })] })] }))] })), tab === "glyphs" && (_jsx("div", { className: "grid grid-cols-10 gap-1.5", children: AVATAR_CHOICES.map((glyph) => (_jsx("button", { type: "button", onClick: () => onChange(glyph), className: `${tile(value === glyph)} text-lg`, children: glyph }, glyph))) })), _jsxs("details", { className: "mt-3", children: [_jsx("summary", { className: `text-xs uppercase tracking-wider cursor-pointer ${muted}`, children: "Custom (image URL or glyph)" }), _jsx("input", { type: "text", value: value, onChange: (e) => onChange(e.target.value), placeholder: "https://\u2026 or a single emoji", className: `mt-2 ${input} py-1.5 text-xs` })] }), value && (_jsxs("div", { className: "mt-4 flex items-center gap-3", children: [_jsx(Avatar, { value: value, size: 48 }), _jsx("span", { className: `text-xs ${muted}`, children: "Selected \u2014 applies immediately." })] }))] }));
64
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The patron's Nostr kind-0 profile — the self-sovereign home of their name
3
+ * and avatar. Edits publish a new signed kind-0, visible in every Nostr client.
4
+ * Without a signer the fields are read-only and the action explains why.
5
+ */
6
+ export default function NostrProfilePanel({ npub }: {
7
+ npub: string;
8
+ }): import("react").JSX.Element;