a proof of concept realtime collaborative todo list
8.0 kB
240 lines
1import { Component } from "preact";
2import { html } from "htm/preact";
3import { signal } from "@preact/signals";
4import { LIST_NSID, ITEM_NSID, TOMBSTONE_NSID, parseAtUri } from "./state.js";
5import { listRecords, getRecord, resolveHandle } from "./atproto.js";
6import { subscribe } from "./subscribe.js";
7
8const TABS = [
9 { id: "list", nsid: LIST_NSID, label: "Lists" },
10 { id: "item", nsid: ITEM_NSID, label: "Items" },
11 { id: "tombstone", nsid: TOMBSTONE_NSID, label: "Tombstones" },
12];
13
14export class DevToolsPanel extends Component {
15 activeTab = signal("list");
16 selectedRepo = signal(null);
17 recordsByNsid = signal({});
18 selected = signal(null);
19 dids = signal([]);
20 handles = signal({});
21
22 #unsubscribe = null;
23
24 async loadData() {
25 const did = this.props.did;
26 if (!did) return;
27
28 let dids = [did];
29
30 if (this.props.listUri) {
31 try {
32 const parsed = parseAtUri(this.props.listUri);
33 if (parsed) {
34 const rec = await getRecord(parsed.did, LIST_NSID, parsed.rkey);
35 if (rec?.value) {
36 const editors = rec.value.editors ?? [];
37 dids = [...new Set([...dids, parsed.did, ...editors])];
38 }
39 }
40 } catch {}
41 }
42
43 this.dids.value = dids;
44
45 const [records] = await Promise.all([
46 Promise.all(
47 TABS.flatMap(({ nsid }) =>
48 dids.map(async (d) => ({ nsid, did: d, recs: await listRecords(d, nsid).catch(() => []) }))
49 )
50 ),
51 Promise.all(
52 dids
53 .filter((d) => !this.handles.value[d])
54 .map(async (d) => {
55 const h = await resolveHandle(d).catch(() => d);
56 this.handles.value = { ...this.handles.value, [d]: h };
57 })
58 ),
59 ]);
60
61 const next = {};
62 for (const { nsid, did: d, recs } of records) {
63 if (!next[nsid]) next[nsid] = {};
64 next[nsid][d] = recs;
65 }
66 this.recordsByNsid.value = next;
67
68 this.#unsubscribe?.();
69 this.#unsubscribe = subscribe({
70 dids,
71 collections: TABS.map((t) => t.nsid),
72 onCommit: this.#onCommit,
73 });
74 }
75
76 #onCommit = ({ did, collection, rkey, action, record }) => {
77 const uri = `at://${did}/${collection}/${rkey}`;
78 const outer = { ...this.recordsByNsid.value };
79 if (!outer[collection]) outer[collection] = {};
80 const inner = [...(outer[collection][did] ?? [])];
81
82 if (action === "delete") {
83 outer[collection] = { ...outer[collection], [did]: inner.filter((r) => r.uri !== uri) };
84 } else if (record) {
85 const idx = inner.findIndex((r) => r.uri === uri);
86 const updated = idx >= 0 ? inner.with(idx, { uri, value: record }) : [...inner, { uri, value: record }];
87 outer[collection] = { ...outer[collection], [did]: updated };
88 }
89
90 this.recordsByNsid.value = outer;
91 };
92
93 componentDidMount() {
94 this.loadData();
95 }
96
97 componentDidUpdate(prev) {
98 if (prev.did !== this.props.did || prev.listUri !== this.props.listUri) {
99 this.#unsubscribe?.();
100 this.selected.value = null;
101 this.selectedRepo.value = null;
102 this.loadData();
103 }
104 }
105
106 componentWillUnmount() {
107 this.#unsubscribe?.();
108 }
109
110 getEntries() {
111 const tab = TABS.find((t) => t.id === this.activeTab.value);
112 if (!tab) return [];
113 const byDid = this.recordsByNsid.value[tab.nsid] ?? {};
114 const selRepo = this.selectedRepo.value;
115 const out = [];
116 for (const [did, recs] of Object.entries(byDid)) {
117 if (selRepo && did !== selRepo) continue;
118 for (const rec of recs) out.push({ did, uri: rec.uri, value: rec.value });
119 }
120 out.sort((a, b) => (a.uri < b.uri ? 1 : -1));
121 return out;
122 }
123
124 switchTab(id) {
125 this.activeTab.value = id;
126 this.selected.value = null;
127 }
128
129 renderDetail(entry) {
130 return html`
131 <div class="devtools-detail">
132 <div class="devtools-detail-meta" style="word-break:break-all">${entry.uri}</div>
133 <pre class="devtools-doc-json">${JSON.stringify(entry.value, null, 2)}</pre>
134 </div>
135 `;
136 }
137
138 render() {
139 const tab = this.activeTab.value;
140 const dids = this.dids.value;
141 const handles = this.handles.value;
142 const selRepo = this.selectedRepo.value;
143 const entries = this.getEntries();
144 const sel = this.selected.value;
145
146 return html`
147 <div class="devtools-panel" style=${this.props.style}>
148 <div class="devtools-header">
149 <div class="devtools-tabs">
150 ${TABS.map(
151 (t) => html`
152 <button
153 key=${t.id}
154 class=${tab === t.id ? "active" : ""}
155 onClick=${() => this.switchTab(t.id)}
156 >
157 ${t.label}
158 </button>
159 `
160 )}
161 </div>
162 <button class="icon-button devtools-action" style="margin-left:auto" title="Close" onClick=${this.props.onClose}>
163 <svg
164 width="12"
165 height="12"
166 viewBox="0 0 16 16"
167 fill="none"
168 stroke="currentColor"
169 stroke-width="2"
170 stroke-linecap="round"
171 >
172 <line x1="4" y1="4" x2="12" y2="12" />
173 <line x1="12" y1="4" x2="4" y2="12" />
174 </svg>
175 </button>
176 </div>
177 <div class="devtools-body">
178 <div class="devtools-tab-content">
179 ${dids.length > 1
180 ? html`
181 <div class="devtools-subtabs">
182 <button
183 class=${!selRepo ? "active" : ""}
184 onClick=${() => {
185 this.selectedRepo.value = null;
186 this.selected.value = null;
187 }}
188 >
189 All
190 </button>
191 ${dids.map(
192 (did) => html`
193 <button
194 key=${did}
195 class=${selRepo === did ? "active" : ""}
196 onClick=${() => {
197 this.selectedRepo.value = did;
198 this.selected.value = null;
199 }}
200 >
201 @${handles[did] || did.slice(8, 20) + "…"}
202 </button>
203 `
204 )}
205 </div>
206 `
207 : null}
208 <div class=${`devtools-body-inner${sel ? " has-detail" : ""}`}>
209 <div class="devtools-timeline">
210 ${entries.length === 0
211 ? html`<p class="devtools-empty">No records.</p>`
212 : entries.map((entry, i) => {
213 const active = sel?.uri === entry.uri;
214 const rkey = entry.uri.split("/").pop();
215 const author = "@" + (handles[entry.did] || entry.did.slice(8, 20) + "…");
216 return html`
217 <div
218 class=${`devtools-entry${active ? " active" : ""}`}
219 key=${entry.uri}
220 onClick=${() => (this.selected.value = active ? null : entry)}
221 >
222 <div class="devtools-entry-meta">
223 <span class="devtools-entry-index">${i + 1}</span>
224 <span class="devtools-entry-author">${author}</span>
225 <span class="devtools-entry-time">${rkey}</span>
226 </div>
227 <div class="devtools-entry-uri">${entry.uri}</div>
228 <pre class="devtools-entry-json">${JSON.stringify(entry.value, null, 2)}</pre>
229 </div>
230 `;
231 })}
232 </div>
233 ${sel ? this.renderDetail(sel) : null}
234 </div>
235 </div>
236 </div>
237 </div>
238 `;
239 }
240}