a proof of concept realtime collaborative todo list
0

Configure Feed

Select the types of activity you want to include in your feed.

atodo / app.js
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"));