@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/README.md +34 -0
- package/dist/confirm-modal.d.ts +10 -0
- package/dist/doc-history.d.ts +10 -0
- package/dist/frame.d.ts +10 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +711 -0
- package/dist/modal-styles.d.ts +17 -0
- package/dist/mount.d.ts +2 -0
- package/dist/new-doc-modal.d.ts +8 -0
- package/dist/share-modal.d.ts +10 -0
- package/dist/url-hash.d.ts +3 -0
- package/package.json +39 -0
- package/src/confirm-modal.tsx +68 -0
- package/src/doc-history.ts +58 -0
- package/src/frame.tsx +660 -0
- package/src/index.ts +25 -0
- package/src/modal-styles.ts +18 -0
- package/src/mount.tsx +10 -0
- package/src/new-doc-modal.tsx +98 -0
- package/src/share-modal.tsx +349 -0
- package/src/url-hash.ts +13 -0
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
|