@jtfmumm/patchwork-standalone-frame 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.
package/src/frame.tsx ADDED
@@ -0,0 +1,660 @@
1
+ import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
2
+ import {
3
+ Repo,
4
+ type AutomergeUrl,
5
+ type DocHandle,
6
+ } from "@automerge/automerge-repo";
7
+ import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
8
+ import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
9
+ import {
10
+ initializeAutomergeRepoKeyhive,
11
+ initKeyhiveWasm,
12
+ docIdFromAutomergeUrl,
13
+ Identifier,
14
+ uint8ArrayToHex,
15
+ type AutomergeRepoKeyhive,
16
+ } from "@automerge/automerge-repo-keyhive";
17
+ import type { ToolRegistration, ToolElement } from "./index.ts";
18
+ import {
19
+ type DocHistoryEntry,
20
+ loadHistory,
21
+ upsertHistory,
22
+ removeFromHistory,
23
+ truncateUrl,
24
+ activeDocKey,
25
+ } from "./doc-history.ts";
26
+ import { getDocUrlFromHash, setDocUrlInHash } from "./url-hash.ts";
27
+ import { ShareModal } from "./share-modal.tsx";
28
+ import { ConfirmModal } from "./confirm-modal.tsx";
29
+ import { NewDocModal } from "./new-doc-modal.tsx";
30
+
31
+ // Init keyhive WASM at module level (must happen before initializeAutomergeRepoKeyhive)
32
+ initKeyhiveWasm();
33
+
34
+ declare global {
35
+ interface Window {
36
+ hive?: AutomergeRepoKeyhive;
37
+ }
38
+ }
39
+
40
+ export function StandaloneApp<D>(props: { tool: ToolRegistration<D> }) {
41
+ const tool = props.tool;
42
+
43
+ const [handle, setHandle] = createSignal<DocHandle<D> | null>(null);
44
+ const [docTitle, setDocTitle] = createSignal<string>("...");
45
+ const [history, setHistory] = createSignal<DocHistoryEntry[]>([]);
46
+ const [dropdownOpen, setDropdownOpen] = createSignal(false);
47
+ const [hive, setHive] = createSignal<AutomergeRepoKeyhive | null>(null);
48
+ const [identityHexId, setIdentityHexId] = createSignal<string>("");
49
+ const [shareModalOpen, setShareModalOpen] = createSignal(false);
50
+ const [confirmRemoveOpen, setConfirmRemoveOpen] = createSignal(false);
51
+ const [newDocModalOpen, setNewDocModalOpen] = createSignal(false);
52
+ const [copiedFeedback, setCopiedFeedback] = createSignal(false);
53
+ const [copiedUrlFeedback, setCopiedUrlFeedback] = createSignal(false);
54
+ const [ready, setReady] = createSignal(false);
55
+ const [docUnavailable, setDocUnavailable] = createSignal(false);
56
+ const [pendingDocUrl, setPendingDocUrl] = createSignal<string | null>(null);
57
+ const [loading, setLoading] = createSignal(false);
58
+
59
+ let repo!: Repo;
60
+ let titleCleanup: (() => void) | null = null;
61
+ let loadGeneration = 0;
62
+
63
+ function trackTitle(h: DocHandle<D>): void {
64
+ if (titleCleanup) {
65
+ titleCleanup();
66
+ titleCleanup = null;
67
+ }
68
+
69
+ const doc = h.doc() as D;
70
+ const initialTitle = doc ? tool.getTitle(doc) : tool.defaultTitle;
71
+ setDocTitle(initialTitle);
72
+ setHistory(upsertHistory(tool.id, identityHexId(), h.url, initialTitle));
73
+
74
+ const onChange = () => {
75
+ const d = h.doc() as D;
76
+ if (d) {
77
+ const t = tool.getTitle(d);
78
+ setDocTitle(t);
79
+ setHistory(upsertHistory(tool.id, identityHexId(), h.url, t));
80
+ }
81
+ };
82
+ h.on("change", onChange);
83
+ titleCleanup = () => h.off("change", onChange);
84
+ }
85
+
86
+ async function checkAccess(url: string): Promise<boolean> {
87
+ const h = hive();
88
+ if (!h) return true;
89
+ try {
90
+ const docId = docIdFromAutomergeUrl(url as AutomergeUrl);
91
+ const myId = h.active.individual.id;
92
+ const publicId = Identifier.publicId();
93
+ const [myAccess, publicAccess] = await Promise.all([
94
+ h.accessForDoc(myId, docId).catch(() => null),
95
+ h.accessForDoc(publicId, docId).catch(() => null),
96
+ ]);
97
+ console.log(`[${tool.name}] Access check for ${url.slice(0, 30)}...: my=${myAccess}, public=${publicAccess}`);
98
+ return !!(myAccess || publicAccess);
99
+ } catch (err) {
100
+ console.error(`[${tool.name}] Access check error for ${url}:`, err);
101
+ return true;
102
+ }
103
+ }
104
+
105
+ async function loadDoc(url: string): Promise<void> {
106
+ const gen = ++loadGeneration;
107
+
108
+ if (titleCleanup) { titleCleanup(); titleCleanup = null; }
109
+ setHandle(null);
110
+ setDocUnavailable(false);
111
+ setPendingDocUrl(null);
112
+ setLoading(true);
113
+ setDocTitle("Loading...");
114
+
115
+ try {
116
+ if (!(await checkAccess(url))) {
117
+ if (gen !== loadGeneration) return;
118
+ console.warn(`[${tool.name}] No access to doc: ${url}`);
119
+ setLoading(false);
120
+ setDocUnavailable(true);
121
+ setPendingDocUrl(url);
122
+ setDocUrlInHash(url as AutomergeUrl);
123
+ return;
124
+ }
125
+ if (gen !== loadGeneration) return;
126
+
127
+ const isDocReady = (h: DocHandle<D>) => {
128
+ const d = h.doc() as D;
129
+ if (!d) return false;
130
+ return tool.isDocReady ? tool.isDocReady(d) : !!d;
131
+ };
132
+
133
+ let docHandle = await repo.find<D>(url as AutomergeUrl);
134
+ await docHandle.whenReady();
135
+ if (gen !== loadGeneration) return;
136
+
137
+ if (!isDocReady(docHandle)) {
138
+ console.log(`[${tool.name}] Doc incomplete, forcing re-sync for: ${url.slice(0, 30)}...`);
139
+ try { repo.delete(url as AutomergeUrl); } catch { /* ignore */ }
140
+ await new Promise((r) => setTimeout(r, 100));
141
+ if (gen !== loadGeneration) return;
142
+
143
+ docHandle = await repo.find<D>(url as AutomergeUrl);
144
+ await docHandle.whenReady();
145
+ if (gen !== loadGeneration) return;
146
+ }
147
+
148
+ if (!isDocReady(docHandle)) {
149
+ setDocUrlInHash(url as AutomergeUrl);
150
+ await new Promise<void>((resolve) => {
151
+ const check = () => {
152
+ if (gen !== loadGeneration) { docHandle.off("change", check); resolve(); return; }
153
+ if (isDocReady(docHandle)) {
154
+ docHandle.off("change", check);
155
+ resolve();
156
+ }
157
+ };
158
+ docHandle.on("change", check);
159
+ setTimeout(() => { docHandle.off("change", check); resolve(); }, 30_000);
160
+ });
161
+ if (gen !== loadGeneration) return;
162
+ }
163
+
164
+ setLoading(false);
165
+ setDocUrlInHash(url as AutomergeUrl);
166
+ localStorage.setItem(activeDocKey(tool.id, identityHexId()), url);
167
+ trackTitle(docHandle);
168
+ setHandle(docHandle);
169
+ } catch (err) {
170
+ if (gen !== loadGeneration) return;
171
+ console.error(`[${tool.name}] Failed to load doc: ${url}`, err);
172
+ setLoading(false);
173
+ setDocUnavailable(true);
174
+ setPendingDocUrl(url);
175
+ setDocUrlInHash(url as AutomergeUrl);
176
+ }
177
+ }
178
+
179
+ async function createNewDoc(title: string = tool.defaultTitle): Promise<void> {
180
+ const h = hive();
181
+ if (!h) return;
182
+
183
+ const initialDoc = {} as Record<string, unknown>;
184
+ tool.init(initialDoc as D, repo);
185
+ if (tool.setTitle) tool.setTitle(initialDoc as D, title);
186
+ initialDoc["@patchwork"] = { type: tool.id };
187
+ const docHandle = await repo.create2<D>(initialDoc as D);
188
+ console.log(`[${tool.name}] Created document: ${docHandle.url}`);
189
+
190
+ await h.addSyncServerPullToDoc(docHandle.url);
191
+ await h.keyhiveStorage.saveKeyhiveWithHash(h.keyhive);
192
+
193
+ setHandle(docHandle);
194
+ setDocUrlInHash(docHandle.url);
195
+ localStorage.setItem(activeDocKey(tool.id, identityHexId()), docHandle.url);
196
+ trackTitle(docHandle);
197
+ }
198
+
199
+ function closeDoc(): void {
200
+ if (titleCleanup) {
201
+ titleCleanup();
202
+ titleCleanup = null;
203
+ }
204
+ setHandle(null);
205
+ setDocTitle("...");
206
+ setDocUnavailable(false);
207
+ setPendingDocUrl(null);
208
+ localStorage.removeItem(activeDocKey(tool.id, identityHexId()));
209
+ window.history.replaceState(null, "", window.location.pathname);
210
+ }
211
+
212
+ function removeDoc(): void {
213
+ const h = handle();
214
+ if (!h) return;
215
+ const url = h.url;
216
+ closeDoc();
217
+ repo.delete(url);
218
+ setHistory(removeFromHistory(tool.id, identityHexId(), url));
219
+ }
220
+
221
+ async function copyContactCard(): Promise<void> {
222
+ const h = hive();
223
+ if (!h) return;
224
+ try {
225
+ const json = h.active.contactCard.toJson();
226
+ await navigator.clipboard.writeText(json);
227
+ setCopiedFeedback(true);
228
+ setTimeout(() => setCopiedFeedback(false), 1500);
229
+ } catch (err) {
230
+ console.error(`[${tool.name}] Failed to copy contact card:`, err);
231
+ }
232
+ }
233
+
234
+ onMount(async () => {
235
+ const keyhiveStorage = new IndexedDBStorageAdapter(`${tool.id}-keyhive`);
236
+ const envSyncUrl = (import.meta as unknown as Record<string, Record<string, string>>).env?.VITE_SYNC_URL;
237
+ const networkAdapter = new BrowserWebSocketClientAdapter(
238
+ envSyncUrl || tool.syncUrl || "ws://localhost:3030"
239
+ );
240
+ const peerIdSuffix = `${tool.id}-${Math.random().toString(36).slice(2)}`;
241
+
242
+ const automergeRepoKeyhive = await initializeAutomergeRepoKeyhive({
243
+ storage: keyhiveStorage,
244
+ peerIdSuffix,
245
+ networkAdapter,
246
+ automaticArchiveIngestion: true,
247
+ onlyShareWithHardcodedServerPeerId: true,
248
+ cacheHashes: true,
249
+ });
250
+ window.hive = automergeRepoKeyhive;
251
+
252
+ repo = new Repo({
253
+ storage: new IndexedDBStorageAdapter(),
254
+ network: [automergeRepoKeyhive.networkAdapter],
255
+ peerId: automergeRepoKeyhive.peerId,
256
+ sharePolicy: async (peerId) => {
257
+ return peerId === automergeRepoKeyhive.syncServer?.peerId;
258
+ },
259
+ idFactory: automergeRepoKeyhive.idFactory,
260
+ });
261
+
262
+ automergeRepoKeyhive.linkRepo(repo);
263
+ setHive(automergeRepoKeyhive);
264
+
265
+ const hexId = uint8ArrayToHex(
266
+ automergeRepoKeyhive.active.individual.id.toBytes()
267
+ );
268
+ setIdentityHexId(hexId);
269
+ setHistory(loadHistory(tool.id, hexId));
270
+ setReady(true);
271
+
272
+ console.log(`[${tool.name}] Keyhive initialized, identity: 0x${hexId.slice(0, 12)}...`);
273
+
274
+ (automergeRepoKeyhive.networkAdapter as { on: (event: string, cb: () => void) => void }).on("ingest-remote", async () => {
275
+ const pending = pendingDocUrl();
276
+ if (pending) {
277
+ if (await checkAccess(pending)) {
278
+ console.log(`[${tool.name}] Access granted, loading: ${pending}`);
279
+ await loadDoc(pending);
280
+ }
281
+ return;
282
+ }
283
+
284
+ const h = handle();
285
+ if (h) {
286
+ if (!(await checkAccess(h.url))) {
287
+ console.log(`[${tool.name}] Access revoked for: ${h.url}`);
288
+ if (titleCleanup) { titleCleanup(); titleCleanup = null; }
289
+ setHandle(null);
290
+ setDocTitle("...");
291
+ setDocUnavailable(true);
292
+ setPendingDocUrl(h.url);
293
+ }
294
+ }
295
+ });
296
+
297
+ const hashUrl = getDocUrlFromHash();
298
+ if (hashUrl) {
299
+ console.log(`[${tool.name}] Loading doc from hash: ${hashUrl}`);
300
+ await loadDoc(hashUrl);
301
+ return;
302
+ }
303
+
304
+ const existingUrl = localStorage.getItem(activeDocKey(tool.id, hexId));
305
+ if (existingUrl) {
306
+ console.log(`[${tool.name}] Found existing doc: ${existingUrl}`);
307
+ await loadDoc(existingUrl);
308
+ return;
309
+ }
310
+
311
+ console.log(`[${tool.name}] No doc found, starting blank`);
312
+ });
313
+
314
+ onMount(() => {
315
+ const handleHashChange = async () => {
316
+ const hashUrl = getDocUrlFromHash();
317
+ if (hashUrl) {
318
+ console.log(`[${tool.name}] Hash changed, loading: ${hashUrl}`);
319
+ await loadDoc(hashUrl);
320
+ }
321
+ };
322
+ window.addEventListener("hashchange", handleHashChange);
323
+ onCleanup(() => window.removeEventListener("hashchange", handleHashChange));
324
+ });
325
+
326
+ onMount(() => {
327
+ const handleMouseDown = (e: MouseEvent) => {
328
+ const target = e.target as HTMLElement;
329
+ if (!target.closest("[data-doc-switcher]")) {
330
+ setDropdownOpen(false);
331
+ }
332
+ };
333
+ document.addEventListener("mousedown", handleMouseDown);
334
+ onCleanup(() => document.removeEventListener("mousedown", handleMouseDown));
335
+ });
336
+
337
+ return (
338
+ <div style={{ display: "flex", "flex-direction": "column", height: "100vh", background: "#1d232a" }}>
339
+ {/* Top bar */}
340
+ <div
341
+ style={{
342
+ display: "flex",
343
+ "align-items": "center",
344
+ height: "52px",
345
+ "min-height": "52px",
346
+ background: "#191e24",
347
+ color: "#edf2f7",
348
+ "font-size": "14px",
349
+ "font-family": "system-ui, sans-serif",
350
+ padding: "0 12px",
351
+ "border-bottom": "1px solid #15191e",
352
+ "box-sizing": "border-box",
353
+ }}
354
+ >
355
+ <button
356
+ onClick={() => setNewDocModalOpen(true)}
357
+ disabled={!ready()}
358
+ style={{
359
+ background: "none",
360
+ border: "1px solid #2a323c",
361
+ color: "#edf2f7",
362
+ "font-size": "13px",
363
+ padding: "4px 10px",
364
+ "border-radius": "4px",
365
+ cursor: "pointer",
366
+ "flex-shrink": "0",
367
+ "margin-right": "12px",
368
+ "white-space": "nowrap",
369
+ opacity: ready() ? "1" : "0.4",
370
+ }}
371
+ >
372
+ + New
373
+ </button>
374
+
375
+ <input
376
+ type="text"
377
+ placeholder="Paste automerge:… URL"
378
+ style={{
379
+ background: "#15191e",
380
+ border: "1px solid #2a323c",
381
+ color: "#edf2f7",
382
+ "font-size": "13px",
383
+ padding: "4px 10px",
384
+ "border-radius": "4px",
385
+ width: "200px",
386
+ "min-width": "80px",
387
+ "flex-shrink": "1",
388
+ "margin-right": "12px",
389
+ outline: "none",
390
+ }}
391
+ onKeyDown={(e) => {
392
+ if (e.key === "Enter") {
393
+ const val = e.currentTarget.value.trim();
394
+ if (val) {
395
+ const url = val.startsWith("automerge:") ? val : `automerge:${val}`;
396
+ void loadDoc(url);
397
+ e.currentTarget.value = "";
398
+ }
399
+ }
400
+ }}
401
+ />
402
+
403
+ <div data-doc-switcher style={{ position: "relative", "flex": "1", "min-width": "120px" }}>
404
+ <button
405
+ onClick={() => setDropdownOpen(!dropdownOpen())}
406
+ style={{
407
+ background: "none",
408
+ border: "none",
409
+ color: "#edf2f7",
410
+ "font-size": "14px",
411
+ cursor: "pointer",
412
+ padding: "4px 8px",
413
+ "border-radius": "4px",
414
+ "max-width": "100%",
415
+ display: "flex",
416
+ "align-items": "center",
417
+ gap: "4px",
418
+ }}
419
+ >
420
+ <span style={{ overflow: "hidden", "text-overflow": "ellipsis", "white-space": "nowrap" }}>
421
+ {docTitle()}
422
+ </span>
423
+ <span style={{ "flex-shrink": "0" }}>▾</span>
424
+ </button>
425
+
426
+ <Show when={dropdownOpen()}>
427
+ <div
428
+ style={{
429
+ position: "absolute",
430
+ top: "34px",
431
+ left: "0",
432
+ background: "#191e24",
433
+ border: "1px solid #2a323c",
434
+ "border-radius": "4px",
435
+ "max-height": "400px",
436
+ "overflow-y": "auto",
437
+ "min-width": "280px",
438
+ "max-width": "420px",
439
+ "z-index": "1000",
440
+ "box-shadow": "0 4px 12px rgba(0,0,0,0.4)",
441
+ }}
442
+ >
443
+ <For each={history()}>
444
+ {(entry) => (
445
+ <div
446
+ onClick={() => {
447
+ void loadDoc(entry.url);
448
+ setDropdownOpen(false);
449
+ }}
450
+ style={{
451
+ padding: "8px 12px",
452
+ cursor: "pointer",
453
+ "border-bottom": "1px solid #15191e",
454
+ overflow: "hidden",
455
+ "text-overflow": "ellipsis",
456
+ "white-space": "nowrap",
457
+ color: handle()?.url === entry.url ? "#7ab4f5" : "#edf2f7",
458
+ "font-weight": handle()?.url === entry.url ? "bold" : "normal",
459
+ }}
460
+ onMouseEnter={(e) => {
461
+ (e.currentTarget as HTMLElement).style.background = "#1d232a";
462
+ }}
463
+ onMouseLeave={(e) => {
464
+ (e.currentTarget as HTMLElement).style.background = "transparent";
465
+ }}
466
+ >
467
+ {entry.title || "Untitled"}{" "}
468
+ <span style={{ color: "#6b7280", "font-size": "12px" }}>
469
+ {truncateUrl(entry.url)}
470
+ </span>
471
+ </div>
472
+ )}
473
+ </For>
474
+ <Show when={history().length === 0}>
475
+ <div style={{ padding: "8px 10px", color: "#6b7280", "font-style": "italic" }}>
476
+ No documents yet
477
+ </div>
478
+ </Show>
479
+ </div>
480
+ </Show>
481
+ </div>
482
+
483
+ {/* Right-side buttons */}
484
+ <div style={{ display: "flex", "align-items": "center", "flex-shrink": "0", gap: "8px" }}>
485
+
486
+ {/* Share button */}
487
+ <Show when={handle() && hive()}>
488
+ <button
489
+ onClick={() => setShareModalOpen(true)}
490
+ style={{
491
+ background: "none",
492
+ border: "1px solid #2a323c",
493
+ color: "#edf2f7",
494
+ "font-size": "13px",
495
+ padding: "4px 10px",
496
+ "border-radius": "4px",
497
+ cursor: "pointer",
498
+ "white-space": "nowrap",
499
+ }}
500
+ >
501
+ Share
502
+ </button>
503
+ </Show>
504
+
505
+ {/* Copy URL button */}
506
+ <Show when={handle()}>
507
+ <button
508
+ onClick={async () => {
509
+ const h = handle();
510
+ if (!h) return;
511
+ await navigator.clipboard.writeText(h.url);
512
+ setCopiedUrlFeedback(true);
513
+ setTimeout(() => setCopiedUrlFeedback(false), 1500);
514
+ }}
515
+ style={{
516
+ background: "none",
517
+ border: "1px solid #2a323c",
518
+ color: copiedUrlFeedback() ? "#b5bd68" : "#edf2f7",
519
+ "font-size": "13px",
520
+ padding: "4px 10px",
521
+ "border-radius": "4px",
522
+ cursor: "pointer",
523
+ "white-space": "nowrap",
524
+ }}
525
+ title="Copy automerge URL"
526
+ >
527
+ {copiedUrlFeedback() ? "Copied!" : "Copy URL"}
528
+ </button>
529
+ </Show>
530
+
531
+ {/* Copy Contact Card button */}
532
+ <Show when={hive()}>
533
+ <button
534
+ onClick={() => void copyContactCard()}
535
+ style={{
536
+ background: "none",
537
+ border: "1px solid #2a323c",
538
+ color: copiedFeedback() ? "#b5bd68" : "#edf2f7",
539
+ "font-size": "13px",
540
+ padding: "4px 10px",
541
+ "border-radius": "4px",
542
+ cursor: "pointer",
543
+ "white-space": "nowrap",
544
+ }}
545
+ >
546
+ {copiedFeedback() ? "Copied!" : "Contact Card"}
547
+ </button>
548
+ </Show>
549
+
550
+ {/* Remove Doc button */}
551
+ <Show when={handle()}>
552
+ <button
553
+ onClick={() => setConfirmRemoveOpen(true)}
554
+ style={{
555
+ background: "none",
556
+ border: "1px solid #944",
557
+ color: "#c66",
558
+ "font-size": "13px",
559
+ padding: "4px 10px",
560
+ "border-radius": "4px",
561
+ cursor: "pointer",
562
+ "white-space": "nowrap",
563
+ }}
564
+ >
565
+ Remove Doc
566
+ </button>
567
+ </Show>
568
+ </div>
569
+ </div>
570
+
571
+ {/* Tool area */}
572
+ <div style={{ flex: "1", "min-height": "0", overflow: "hidden" }}>
573
+ <Show
574
+ when={handle()}
575
+ keyed
576
+ fallback={
577
+ <div
578
+ style={{
579
+ display: "flex",
580
+ "flex-direction": "column",
581
+ "align-items": "center",
582
+ "justify-content": "center",
583
+ gap: "12px",
584
+ height: "100%",
585
+ color: docUnavailable() ? "#c66" : "#6b7280",
586
+ background: "#1d232a",
587
+ "font-family": "system-ui, sans-serif",
588
+ "font-size": "14px",
589
+ }}
590
+ >
591
+ <Show when={loading() || !ready()}>
592
+ <div style={{
593
+ width: "24px",
594
+ height: "24px",
595
+ border: "2px solid #2a323c",
596
+ "border-top-color": "#6b7280",
597
+ "border-radius": "50%",
598
+ animation: "spin 0.8s linear infinite",
599
+ }} />
600
+ </Show>
601
+ {docUnavailable()
602
+ ? "Document unavailable: you may not have access"
603
+ : loading()
604
+ ? "Loading document..."
605
+ : ready()
606
+ ? "No document open"
607
+ : "Initializing keyhive..."}
608
+ </div>
609
+ }
610
+ >
611
+ {(h) => {
612
+ const toolEl = document.createElement("div") as unknown as ToolElement;
613
+ toolEl.repo = repo;
614
+ toolEl.style.height = "100%";
615
+ return (
616
+ <div style={{ height: "100%" }} ref={(el) => {
617
+ el.appendChild(toolEl);
618
+ tool.render(h as DocHandle<D>, toolEl);
619
+ }} />
620
+ );
621
+ }}
622
+ </Show>
623
+ </div>
624
+
625
+ {/* Share Modal */}
626
+ <Show when={hive() && handle()}>
627
+ <ShareModal
628
+ isOpen={shareModalOpen()}
629
+ docUrl={handle()!.url}
630
+ hive={hive()!}
631
+ onClose={() => setShareModalOpen(false)}
632
+ />
633
+ </Show>
634
+
635
+ {/* New Doc Modal */}
636
+ <NewDocModal
637
+ isOpen={newDocModalOpen()}
638
+ defaultTitle={tool.defaultTitle}
639
+ onConfirm={(title) => {
640
+ setNewDocModalOpen(false);
641
+ void createNewDoc(title);
642
+ }}
643
+ onCancel={() => setNewDocModalOpen(false)}
644
+ />
645
+
646
+ {/* Confirm Remove Modal */}
647
+ <ConfirmModal
648
+ isOpen={confirmRemoveOpen()}
649
+ title="Remove Document"
650
+ message="Remove this document from your history? The document data will be deleted locally."
651
+ confirmLabel="Remove"
652
+ onConfirm={() => {
653
+ setConfirmRemoveOpen(false);
654
+ removeDoc();
655
+ }}
656
+ onCancel={() => setConfirmRemoveOpen(false)}
657
+ />
658
+ </div>
659
+ );
660
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { Repo, DocHandle } from "@automerge/automerge-repo";
2
+ import type { AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
3
+
4
+ export interface ToolElement extends HTMLDivElement {
5
+ repo: Repo;
6
+ hive?: AutomergeRepoKeyhive;
7
+ }
8
+
9
+ export interface ToolRegistration<D = unknown> {
10
+ id: string;
11
+ name: string;
12
+ defaultTitle: string;
13
+ init: (doc: D, repo: Repo) => void;
14
+ getTitle: (doc: D) => string;
15
+ syncUrl?: string;
16
+ setTitle?: (doc: D, title: string) => void;
17
+ isDocReady?: (doc: D) => boolean;
18
+ render: (handle: DocHandle<D>, element: ToolElement) => (() => void);
19
+ }
20
+
21
+ export { mountStandaloneApp } from "./mount.tsx";
22
+ export { ShareModal } from "./share-modal.tsx";
23
+ export { ConfirmModal } from "./confirm-modal.tsx";
24
+ export { NewDocModal } from "./new-doc-modal.tsx";
25
+ export { overlayStyle, cardStyle } from "./modal-styles.ts";
@@ -0,0 +1,18 @@
1
+ export const overlayStyle = {
2
+ position: "fixed",
3
+ inset: "0",
4
+ background: "rgba(0,0,0,0.5)",
5
+ display: "flex",
6
+ "align-items": "center",
7
+ "justify-content": "center",
8
+ "z-index": "2000",
9
+ } as const
10
+
11
+ export const cardStyle = {
12
+ background: "#191e24",
13
+ color: "#edf2f7",
14
+ "border-radius": "8px",
15
+ padding: "20px 24px",
16
+ "box-shadow": "0 8px 24px rgba(0,0,0,0.5)",
17
+ "font-family": "system-ui, sans-serif",
18
+ } as const