a proof of concept realtime collaborative todo list
5.8 kB
197 lines
1import { Component, render } from "preact";
2import { html } from "htm/preact";
3import { signal } from "@preact/signals";
4import { logIn, listSessions, logOut } from "./atsw.js";
5import {
6 session,
7 listUri,
8 navigate,
9 parseAtUri,
10 LIST_NSID,
11 BASE,
12 listName,
13 listIsOwned,
14 listEditors,
15 showEditorsModal,
16} from "./state.js";
17import { ListsView } from "./lists.js";
18import { ListView } from "./list.js";
19import { initQueue, flushQueue } from "./offline.js";
20import { DevToolsPanel } from "./devtools.js";
21import { resolveHandle } from "./atproto.js";
22
23const config = {
24 clientId: "https://atodo.jakelazaroff.com/client-metadata.json",
25 redirectUri: "https://atodo.jakelazaroff.com/",
26 scope:
27 "atproto " +
28 "repo?collection=com.atproto.server.getSession " +
29 "repo?collection=com.jakelazaroff.atodo.list " +
30 "repo?collection=com.jakelazaroff.atodo.item " +
31 "repo?collection=com.jakelazaroff.atodo.tombstone",
32};
33
34const ready = signal(false);
35
36class LoginForm extends Component {
37 handle = signal("");
38 busy = signal(false);
39 error = signal("");
40
41 onSubmit = async (e) => {
42 e.preventDefault();
43 const handle = this.handle.value.trim();
44 if (!handle) return;
45 this.busy.value = true;
46 this.error.value = "";
47 try {
48 await logIn(config, handle);
49 } catch (err) {
50 this.error.value = err.message;
51 this.busy.value = false;
52 }
53 };
54
55 render() {
56 return html`
57 <form onSubmit=${this.onSubmit}>
58 <input
59 placeholder="you.bsky.social"
60 value=${this.handle.value}
61 onInput=${(e) => (this.handle.value = e.target.value)}
62 disabled=${this.busy.value}
63 />
64 <button disabled=${this.busy.value}>${this.busy.value ? "Logging in…" : "Login"}</button>
65 </form>
66 ${this.error.value && html`<pre>${this.error.value}</pre>`}
67 `;
68 }
69}
70
71class App extends Component {
72 showDevtools = signal(false);
73 panelHeight = signal(280);
74 handle = signal("");
75
76 async componentDidMount() {
77 await navigator.serviceWorker.register(new URL("atsw.js", import.meta.url).href, {
78 type: "module",
79 });
80 const sessions = await listSessions();
81 session.value = sessions[0] ?? null;
82 ready.value = true;
83 if (session.value) {
84 initQueue();
85 flushQueue();
86 resolveHandle(session.value.did)
87 .then((h) => (this.handle.value = h))
88 .catch(() => {});
89 }
90 }
91
92 onLogOut = async () => {
93 await logOut(session.value.did);
94 location.reload();
95 };
96
97 renderBody() {
98 const uri = listUri.value;
99 if (!uri) return html`<${ListsView} />`;
100 const parsed = parseAtUri(uri);
101 if (parsed && parsed.collection === LIST_NSID) return html`<${ListView} uri=${uri} />`;
102 return html`<p class="error">Not a list URI</p>`;
103 }
104
105 render() {
106 if (!ready.value) return html`<p>Loading…</p>`;
107
108 if (!session.value)
109 return html`
110 <h1>atodo</h1>
111 <${LoginForm} />
112 `;
113
114 return html`
115 <header class="topbar">
116 ${listUri.value
117 ? html` <div class="topbar-left">
118 <a
119 href=${BASE}
120 class="back-link"
121 onClick=${(e) => (e.preventDefault(), navigate(null))}
122 >← back</a
123 >
124 <strong class="list-title">${listName.value}</strong>
125 </div>`
126 : html`<h1>atodo</h1>`}
127 <div class="who">
128 <span>@${this.handle.value || session.value.did}</span>
129 ${listUri.value &&
130 listIsOwned.value &&
131 html` <button onClick=${() => (showEditorsModal.value = true)}>
132 Editors (${listEditors.value.length})
133 </button>`}
134 <button
135 class="icon-button"
136 title="Toggle records panel"
137 onClick=${() => (this.showDevtools.value = !this.showDevtools.value)}
138 >
139 <svg
140 width="14"
141 height="14"
142 viewBox="0 0 16 16"
143 fill="none"
144 stroke="currentColor"
145 stroke-width="1.5"
146 stroke-linecap="round"
147 stroke-linejoin="round"
148 >
149 <path
150 d="M5 1h6M6 1v4.5L2.5 12a1.5 1.5 0 0 0 1.3 2.25h8.4A1.5 1.5 0 0 0 13.5 12L10 5.5V1"
151 />
152 <path d="M4 10.5h8" />
153 </svg>
154 </button>
155 <button onClick=${this.onLogOut}>Log out</button>
156 </div>
157 </header>
158 ${this.renderBody()}
159 ${this.showDevtools.value &&
160 html`
161 <div class="devtools-fixed-wrapper">
162 <div
163 class="devtools-resize-handle"
164 onPointerDown=${(e) => {
165 e.currentTarget.setPointerCapture(e.pointerId);
166 const startY = e.clientY;
167 const startH = this.panelHeight.value;
168 const el = e.currentTarget;
169 const onMove = (mv) => {
170 this.panelHeight.value = Math.max(
171 120,
172 Math.min(window.innerHeight - 60, startH + (startY - mv.clientY)),
173 );
174 };
175 el.addEventListener("pointermove", onMove);
176 el.addEventListener(
177 "pointerup",
178 () => el.removeEventListener("pointermove", onMove),
179 {
180 once: true,
181 },
182 );
183 }}
184 ></div>
185 <${DevToolsPanel}
186 did=${session.value.did}
187 listUri=${listUri.value}
188 style=${"height:" + this.panelHeight.value + "px"}
189 onClose=${() => (this.showDevtools.value = false)}
190 />
191 </div>
192 `}
193 `;
194 }
195}
196
197render(html`<${App} />`, document.getElementById("app"));