@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/mount.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { render } from "solid-js/web";
|
|
2
|
+
import { StandaloneApp } from "./frame.tsx";
|
|
3
|
+
import type { ToolRegistration } from "./index.ts";
|
|
4
|
+
|
|
5
|
+
export function mountStandaloneApp<D>(
|
|
6
|
+
rootElement: HTMLElement,
|
|
7
|
+
tool: ToolRegistration<D>,
|
|
8
|
+
): void {
|
|
9
|
+
render(() => <StandaloneApp tool={tool} />, rootElement);
|
|
10
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Show, createEffect, createSignal, onCleanup } from "solid-js";
|
|
2
|
+
import { overlayStyle, cardStyle } from "./modal-styles.ts";
|
|
3
|
+
|
|
4
|
+
interface NewDocModalProps {
|
|
5
|
+
isOpen: boolean;
|
|
6
|
+
defaultTitle: string;
|
|
7
|
+
onConfirm: (title: string) => void;
|
|
8
|
+
onCancel: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function NewDocModal(props: NewDocModalProps) {
|
|
12
|
+
const [title, setTitle] = createSignal("");
|
|
13
|
+
let inputRef: HTMLInputElement | undefined;
|
|
14
|
+
|
|
15
|
+
createEffect(() => {
|
|
16
|
+
if (!props.isOpen) return;
|
|
17
|
+
setTitle("");
|
|
18
|
+
requestAnimationFrame(() => inputRef?.focus());
|
|
19
|
+
|
|
20
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
21
|
+
if (e.key === "Escape") props.onCancel();
|
|
22
|
+
};
|
|
23
|
+
document.addEventListener("keydown", handleEscape);
|
|
24
|
+
onCleanup(() => document.removeEventListener("keydown", handleEscape));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function submit() {
|
|
28
|
+
const t = title().trim();
|
|
29
|
+
props.onConfirm(t || props.defaultTitle);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Show when={props.isOpen}>
|
|
34
|
+
<div
|
|
35
|
+
onClick={(e) => { if (e.target === e.currentTarget) props.onCancel(); }}
|
|
36
|
+
style={overlayStyle}
|
|
37
|
+
>
|
|
38
|
+
<div
|
|
39
|
+
style={{ ...cardStyle, "min-width": "300px", "max-width": "400px" }}
|
|
40
|
+
>
|
|
41
|
+
<h3 style={{ margin: "0 0 12px", "font-size": "15px" }}>New Document</h3>
|
|
42
|
+
<input
|
|
43
|
+
ref={inputRef}
|
|
44
|
+
type="text"
|
|
45
|
+
placeholder={props.defaultTitle}
|
|
46
|
+
value={title()}
|
|
47
|
+
onInput={(e) => setTitle(e.currentTarget.value)}
|
|
48
|
+
onKeyDown={(e) => {
|
|
49
|
+
if (e.key === "Enter") submit();
|
|
50
|
+
}}
|
|
51
|
+
style={{
|
|
52
|
+
width: "100%",
|
|
53
|
+
background: "#15191e",
|
|
54
|
+
border: "1px solid #2a323c",
|
|
55
|
+
color: "#edf2f7",
|
|
56
|
+
"font-size": "14px",
|
|
57
|
+
padding: "8px 10px",
|
|
58
|
+
"border-radius": "4px",
|
|
59
|
+
outline: "none",
|
|
60
|
+
"box-sizing": "border-box",
|
|
61
|
+
"margin-bottom": "16px",
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
<div style={{ display: "flex", "justify-content": "flex-end", gap: "8px" }}>
|
|
65
|
+
<button
|
|
66
|
+
onClick={() => props.onCancel()}
|
|
67
|
+
style={{
|
|
68
|
+
background: "none",
|
|
69
|
+
border: "1px solid #2a323c",
|
|
70
|
+
color: "#edf2f7",
|
|
71
|
+
padding: "4px 14px",
|
|
72
|
+
"border-radius": "4px",
|
|
73
|
+
cursor: "pointer",
|
|
74
|
+
"font-size": "12px",
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
Cancel
|
|
78
|
+
</button>
|
|
79
|
+
<button
|
|
80
|
+
onClick={() => submit()}
|
|
81
|
+
style={{
|
|
82
|
+
background: "#2a5a8a",
|
|
83
|
+
border: "1px solid #3a6a9a",
|
|
84
|
+
color: "#fff",
|
|
85
|
+
padding: "4px 14px",
|
|
86
|
+
"border-radius": "4px",
|
|
87
|
+
cursor: "pointer",
|
|
88
|
+
"font-size": "12px",
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
Create
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</Show>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSignal,
|
|
3
|
+
createEffect,
|
|
4
|
+
Show,
|
|
5
|
+
For,
|
|
6
|
+
onCleanup,
|
|
7
|
+
createMemo,
|
|
8
|
+
} from "solid-js";
|
|
9
|
+
import type { AutomergeUrl } from "@automerge/automerge-repo";
|
|
10
|
+
import {
|
|
11
|
+
Access,
|
|
12
|
+
ContactCard,
|
|
13
|
+
docIdFromAutomergeUrl,
|
|
14
|
+
Identifier,
|
|
15
|
+
uint8ArrayToHex,
|
|
16
|
+
type AutomergeRepoKeyhive,
|
|
17
|
+
} from "@automerge/automerge-repo-keyhive";
|
|
18
|
+
import { overlayStyle, cardStyle } from "./modal-styles.ts";
|
|
19
|
+
|
|
20
|
+
type DocAccessList = Record<string, string>;
|
|
21
|
+
|
|
22
|
+
interface ShareModalProps {
|
|
23
|
+
isOpen: boolean;
|
|
24
|
+
docUrl: AutomergeUrl;
|
|
25
|
+
hive: AutomergeRepoKeyhive;
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ACCESS_LEVELS = ["Pull", "Read", "Write", "Admin"] as const;
|
|
30
|
+
|
|
31
|
+
async function fetchAccessList(
|
|
32
|
+
hive: AutomergeRepoKeyhive,
|
|
33
|
+
docUrl: AutomergeUrl
|
|
34
|
+
): Promise<DocAccessList> {
|
|
35
|
+
const keyhiveDocId = docIdFromAutomergeUrl(docUrl);
|
|
36
|
+
const accessList: DocAccessList = {};
|
|
37
|
+
const members = await hive.docMemberCapabilities(keyhiveDocId);
|
|
38
|
+
members.forEach((capability) => {
|
|
39
|
+
const hexId = uint8ArrayToHex(capability.who.id.toBytes());
|
|
40
|
+
accessList[hexId] = capability.can.toString();
|
|
41
|
+
});
|
|
42
|
+
return accessList;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const shareCardStyle = {
|
|
46
|
+
...cardStyle,
|
|
47
|
+
width: "420px",
|
|
48
|
+
"max-height": "80vh",
|
|
49
|
+
"overflow-y": "auto",
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
const btnStyle = {
|
|
53
|
+
background: "none",
|
|
54
|
+
border: "1px solid #2a323c",
|
|
55
|
+
color: "#edf2f7",
|
|
56
|
+
padding: "4px 12px",
|
|
57
|
+
"border-radius": "4px",
|
|
58
|
+
cursor: "pointer",
|
|
59
|
+
"font-size": "12px",
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
const sectionTitleStyle = {
|
|
63
|
+
"font-size": "13px",
|
|
64
|
+
color: "#6b7280",
|
|
65
|
+
margin: "16px 0 8px",
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
const dividerStyle = {
|
|
69
|
+
border: "none",
|
|
70
|
+
"border-top": "1px solid #2a323c",
|
|
71
|
+
margin: "12px 0",
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
export function ShareModal(props: ShareModalProps) {
|
|
75
|
+
const [contactCardInput, setContactCardInput] = createSignal("");
|
|
76
|
+
const [docAccessList, setDocAccessList] = createSignal<DocAccessList>({});
|
|
77
|
+
const [isLoadingAccessList, setIsLoadingAccessList] = createSignal(true);
|
|
78
|
+
const [currentUserAccess, setCurrentUserAccess] = createSignal<string | undefined>(undefined);
|
|
79
|
+
const [isSubmitting, setIsSubmitting] = createSignal(false);
|
|
80
|
+
const keyhiveDocId = createMemo(() => docIdFromAutomergeUrl(props.docUrl));
|
|
81
|
+
|
|
82
|
+
const currentUserHexId = createMemo(() => {
|
|
83
|
+
const id = props.hive.active.individual.id;
|
|
84
|
+
return id ? uint8ArrayToHex(id.toBytes()) : null;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const syncServerHexId = createMemo(() => {
|
|
88
|
+
const syncServer = props.hive.syncServer;
|
|
89
|
+
if (!syncServer) return null;
|
|
90
|
+
const contactCard = ContactCard.fromJson(syncServer.contactCard.toJson());
|
|
91
|
+
if (!contactCard) return null;
|
|
92
|
+
return uint8ArrayToHex(contactCard.individualId.bytes);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const publicHexId = createMemo(() => {
|
|
96
|
+
const publicId = Identifier.publicId();
|
|
97
|
+
return uint8ArrayToHex(publicId.toBytes());
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const currentPublicAccess = createMemo(() => {
|
|
101
|
+
const accessList = docAccessList();
|
|
102
|
+
return accessList[publicHexId()] || null;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const isAdmin = createMemo(() => currentUserAccess() === "Admin");
|
|
106
|
+
|
|
107
|
+
createEffect(() => {
|
|
108
|
+
if (!props.isOpen) return;
|
|
109
|
+
let cancelled = false;
|
|
110
|
+
(async () => {
|
|
111
|
+
const id = props.hive.active.individual.id;
|
|
112
|
+
if (!id) { if (!cancelled) setCurrentUserAccess(undefined); return; }
|
|
113
|
+
try {
|
|
114
|
+
const access = await props.hive.accessForDoc(id, keyhiveDocId());
|
|
115
|
+
if (!cancelled) setCurrentUserAccess(access ? access.toString() : undefined);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (!cancelled) { console.error("[ShareModal] Error checking access:", err); setCurrentUserAccess(undefined); }
|
|
118
|
+
}
|
|
119
|
+
})();
|
|
120
|
+
onCleanup(() => { cancelled = true; });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
createEffect(() => {
|
|
124
|
+
if (!props.isOpen) return;
|
|
125
|
+
let cancelled = false;
|
|
126
|
+
(async () => {
|
|
127
|
+
if (!cancelled) setIsLoadingAccessList(true);
|
|
128
|
+
try {
|
|
129
|
+
const accessList = await fetchAccessList(props.hive, props.docUrl);
|
|
130
|
+
if (!cancelled) { setDocAccessList(accessList); setIsLoadingAccessList(false); }
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (!cancelled) { console.error("[ShareModal] Error loading access list:", err); setDocAccessList({}); setIsLoadingAccessList(false); }
|
|
133
|
+
}
|
|
134
|
+
})();
|
|
135
|
+
onCleanup(() => { cancelled = true; });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
createEffect(() => {
|
|
139
|
+
if (!props.isOpen) return;
|
|
140
|
+
const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") props.onClose(); };
|
|
141
|
+
document.addEventListener("keydown", handleEscape);
|
|
142
|
+
onCleanup(() => document.removeEventListener("keydown", handleEscape));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const handleAddMember = async (e: Event) => {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
const input = contactCardInput().trim();
|
|
148
|
+
if (!input) return;
|
|
149
|
+
setIsSubmitting(true);
|
|
150
|
+
try {
|
|
151
|
+
const contactCard = ContactCard.fromJson(input);
|
|
152
|
+
if (!contactCard) throw new Error("Invalid ContactCard JSON");
|
|
153
|
+
const access = Access.tryFromString("write");
|
|
154
|
+
if (!access) throw new Error("Invalid access level");
|
|
155
|
+
await props.hive.addMemberToDoc(props.docUrl, contactCard, access);
|
|
156
|
+
setContactCardInput("");
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error("[ShareModal]", err);
|
|
159
|
+
} finally {
|
|
160
|
+
const accessList = await fetchAccessList(props.hive, props.docUrl);
|
|
161
|
+
setDocAccessList(accessList);
|
|
162
|
+
setIsSubmitting(false);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const handleRemoveMember = async (hexId: string) => {
|
|
167
|
+
try {
|
|
168
|
+
await props.hive.revokeMemberFromDoc(props.docUrl, hexId);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.error("[ShareModal]", err);
|
|
171
|
+
} finally {
|
|
172
|
+
const accessList = await fetchAccessList(props.hive, props.docUrl);
|
|
173
|
+
setDocAccessList(accessList);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const handleMakePublic = async () => {
|
|
178
|
+
try {
|
|
179
|
+
const access = Access.tryFromString("write");
|
|
180
|
+
if (!access) throw new Error("Invalid access level");
|
|
181
|
+
await props.hive.setPublicAccess(props.docUrl, access);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error("[ShareModal]", err);
|
|
184
|
+
} finally {
|
|
185
|
+
const accessList = await fetchAccessList(props.hive, props.docUrl);
|
|
186
|
+
setDocAccessList(accessList);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const handleMakePrivate = async () => {
|
|
191
|
+
try {
|
|
192
|
+
await props.hive.revokeMemberFromDoc(props.docUrl, publicHexId());
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error("[ShareModal]", err);
|
|
195
|
+
} finally {
|
|
196
|
+
const accessList = await fetchAccessList(props.hive, props.docUrl);
|
|
197
|
+
setDocAccessList(accessList);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const formatHexId = (hexId: string) => `0x${hexId.slice(0, 12)}...`;
|
|
202
|
+
|
|
203
|
+
const sortedMembers = createMemo(() =>
|
|
204
|
+
Object.entries(docAccessList()).sort(([a], [b]) => a.localeCompare(b))
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<Show when={props.isOpen}>
|
|
209
|
+
<div
|
|
210
|
+
onClick={(e) => { if (e.target === e.currentTarget) props.onClose(); }}
|
|
211
|
+
style={overlayStyle}
|
|
212
|
+
>
|
|
213
|
+
<div style={shareCardStyle} onClick={(e) => e.stopPropagation()}>
|
|
214
|
+
<div style={{ display: "flex", "justify-content": "space-between", "align-items": "center", "margin-bottom": "8px" }}>
|
|
215
|
+
<h2 style={{ margin: "0", "font-size": "16px" }}>Share this document</h2>
|
|
216
|
+
<button
|
|
217
|
+
onClick={() => props.onClose()}
|
|
218
|
+
style={{ ...btnStyle, border: "none", "font-size": "18px", padding: "0 4px" }}
|
|
219
|
+
aria-label="Close modal"
|
|
220
|
+
>
|
|
221
|
+
×
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Public access */}
|
|
226
|
+
<section>
|
|
227
|
+
<h3 style={sectionTitleStyle}>Public Access</h3>
|
|
228
|
+
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
|
229
|
+
<Show when={currentPublicAccess()}>
|
|
230
|
+
<span style={{ "font-size": "13px" }}>
|
|
231
|
+
This document is <strong>public</strong>
|
|
232
|
+
</span>
|
|
233
|
+
</Show>
|
|
234
|
+
<Show when={isAdmin()}>
|
|
235
|
+
<Show when={currentPublicAccess()}>
|
|
236
|
+
<button style={btnStyle} onClick={handleMakePrivate}>Revoke Public Access</button>
|
|
237
|
+
</Show>
|
|
238
|
+
<Show when={!currentPublicAccess()}>
|
|
239
|
+
<button style={btnStyle} onClick={handleMakePublic}>Make Public</button>
|
|
240
|
+
</Show>
|
|
241
|
+
</Show>
|
|
242
|
+
</div>
|
|
243
|
+
</section>
|
|
244
|
+
|
|
245
|
+
<Show when={isAdmin()}>
|
|
246
|
+
<hr style={dividerStyle} />
|
|
247
|
+
|
|
248
|
+
<form onSubmit={handleAddMember}>
|
|
249
|
+
<textarea
|
|
250
|
+
placeholder="Paste ContactCard JSON..."
|
|
251
|
+
value={contactCardInput()}
|
|
252
|
+
onInput={(e) => setContactCardInput(e.currentTarget.value)}
|
|
253
|
+
rows={3}
|
|
254
|
+
style={{
|
|
255
|
+
width: "100%",
|
|
256
|
+
background: "#15191e",
|
|
257
|
+
border: "1px solid #2a323c",
|
|
258
|
+
color: "#edf2f7",
|
|
259
|
+
"border-radius": "4px",
|
|
260
|
+
padding: "6px 8px",
|
|
261
|
+
"font-size": "12px",
|
|
262
|
+
"font-family": "monospace",
|
|
263
|
+
resize: "vertical",
|
|
264
|
+
"box-sizing": "border-box",
|
|
265
|
+
}}
|
|
266
|
+
/>
|
|
267
|
+
<div style={{ display: "flex", "justify-content": "flex-end", "margin-top": "6px" }}>
|
|
268
|
+
<button
|
|
269
|
+
type="submit"
|
|
270
|
+
style={{ ...btnStyle, opacity: isSubmitting() || !contactCardInput().trim() ? "0.5" : "1" }}
|
|
271
|
+
disabled={isSubmitting() || !contactCardInput().trim()}
|
|
272
|
+
>
|
|
273
|
+
{isSubmitting() ? "Adding..." : "Add Member"}
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
</form>
|
|
277
|
+
|
|
278
|
+
<hr style={dividerStyle} />
|
|
279
|
+
</Show>
|
|
280
|
+
|
|
281
|
+
{/* Members list */}
|
|
282
|
+
<section>
|
|
283
|
+
<h3 style={sectionTitleStyle}>Current Access</h3>
|
|
284
|
+
|
|
285
|
+
<Show when={isLoadingAccessList()}>
|
|
286
|
+
<p style={{ "font-size": "13px", color: "#6b7280" }}>Loading...</p>
|
|
287
|
+
</Show>
|
|
288
|
+
|
|
289
|
+
<Show when={!isLoadingAccessList() && sortedMembers().length === 0}>
|
|
290
|
+
<p style={{ "font-size": "13px", color: "#6b7280" }}>No users have access yet</p>
|
|
291
|
+
</Show>
|
|
292
|
+
|
|
293
|
+
<Show when={!isLoadingAccessList() && sortedMembers().length > 0}>
|
|
294
|
+
<div>
|
|
295
|
+
<For each={sortedMembers()}>
|
|
296
|
+
{([hexId, access]) => {
|
|
297
|
+
const isCurrentUser = hexId === currentUserHexId();
|
|
298
|
+
const isSyncServer = hexId === syncServerHexId();
|
|
299
|
+
const isPublic = hexId === publicHexId();
|
|
300
|
+
const myAccessIdx = ACCESS_LEVELS.indexOf(currentUserAccess() as typeof ACCESS_LEVELS[number]);
|
|
301
|
+
const memberAccessIdx = ACCESS_LEVELS.indexOf(access as typeof ACCESS_LEVELS[number]);
|
|
302
|
+
const canRemove = myAccessIdx >= 0 && memberAccessIdx >= 0 && memberAccessIdx <= myAccessIdx && !isCurrentUser && !isSyncServer;
|
|
303
|
+
|
|
304
|
+
const displayName = () => {
|
|
305
|
+
if (isCurrentUser) return "You";
|
|
306
|
+
if (isSyncServer) return "Sync Server";
|
|
307
|
+
if (isPublic) return "Public";
|
|
308
|
+
return formatHexId(hexId);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<div style={{
|
|
313
|
+
display: "flex",
|
|
314
|
+
"justify-content": "space-between",
|
|
315
|
+
"align-items": "center",
|
|
316
|
+
padding: "4px 0",
|
|
317
|
+
"border-bottom": "1px solid #15191e",
|
|
318
|
+
"font-size": "13px",
|
|
319
|
+
}}>
|
|
320
|
+
<div>
|
|
321
|
+
<span style={{
|
|
322
|
+
color: isCurrentUser ? "#7ab4f5" : isPublic ? "#b5bd68" : "#edf2f7",
|
|
323
|
+
"margin-right": "8px",
|
|
324
|
+
}}>
|
|
325
|
+
{displayName()}
|
|
326
|
+
</span>
|
|
327
|
+
<span style={{ color: "#6b7280", "font-size": "11px" }}>{access}</span>
|
|
328
|
+
</div>
|
|
329
|
+
<Show when={canRemove}>
|
|
330
|
+
<button
|
|
331
|
+
onClick={() => handleRemoveMember(hexId)}
|
|
332
|
+
style={{ ...btnStyle, color: "#c66", border: "1px solid #944", padding: "2px 8px" }}
|
|
333
|
+
aria-label="Remove member"
|
|
334
|
+
>
|
|
335
|
+
Revoke
|
|
336
|
+
</button>
|
|
337
|
+
</Show>
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}}
|
|
341
|
+
</For>
|
|
342
|
+
</div>
|
|
343
|
+
</Show>
|
|
344
|
+
</section>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
</Show>
|
|
348
|
+
);
|
|
349
|
+
}
|
package/src/url-hash.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AutomergeUrl } from "@automerge/automerge-repo";
|
|
2
|
+
|
|
3
|
+
export function getDocUrlFromHash(): AutomergeUrl | null {
|
|
4
|
+
const hash = window.location.hash.slice(1);
|
|
5
|
+
if (hash && hash.startsWith("automerge:")) {
|
|
6
|
+
return hash as AutomergeUrl;
|
|
7
|
+
}
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function setDocUrlInHash(url: AutomergeUrl): void {
|
|
12
|
+
window.history.replaceState(null, "", `#${url}`);
|
|
13
|
+
}
|