a proof of concept realtime collaborative todo list
0

Configure Feed

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

Fix size for talk

+142 -104
+8 -8
app.js
··· 13 13 listIsOwned, 14 14 listEditors, 15 15 showEditorsModal, 16 + showDevtools, 17 + devtoolsPanelHeight, 16 18 } from "./state.js"; 17 19 import { ListsView } from "./lists.js"; 18 20 import { ListView } from "./list.js"; ··· 71 73 } 72 74 73 75 class App extends Component { 74 - showDevtools = signal(false); 75 - panelHeight = signal(280); 76 76 handle = signal(""); 77 77 accountsPopoverRef = createRef(); 78 78 ··· 137 137 <button 138 138 class="icon-button" 139 139 title="Toggle records panel" 140 - onClick=${() => (this.showDevtools.value = !this.showDevtools.value)} 140 + onClick=${() => (showDevtools.value = !showDevtools.value)} 141 141 > 142 142 <svg 143 143 width="14" ··· 165 165 did=${session.value.did} 166 166 /> 167 167 ${this.renderBody()} 168 - ${this.showDevtools.value && 168 + ${showDevtools.value && 169 169 html` 170 170 <div class="devtools-fixed-wrapper"> 171 171 <div ··· 173 173 onPointerDown=${(e) => { 174 174 e.currentTarget.setPointerCapture(e.pointerId); 175 175 const startY = e.clientY; 176 - const startH = this.panelHeight.value; 176 + const startH = devtoolsPanelHeight.value; 177 177 const el = e.currentTarget; 178 178 const onMove = (mv) => { 179 - this.panelHeight.value = Math.max( 179 + devtoolsPanelHeight.value = Math.max( 180 180 120, 181 181 Math.min(window.innerHeight - 60, startH + (startY - mv.clientY)), 182 182 ); ··· 194 194 <${DevToolsPanel} 195 195 did=${session.value.did} 196 196 listUri=${listUri.value} 197 - style=${"height:" + this.panelHeight.value + "px"} 198 - onClose=${() => (this.showDevtools.value = false)} 197 + style=${"height:" + devtoolsPanelHeight.value + "px"} 198 + onClose=${() => (showDevtools.value = false)} 199 199 /> 200 200 </div> 201 201 `}
+97 -94
devtools.js
··· 97 97 for (const [did, recs] of Object.entries(byDid)) { 98 98 if (selRepo && did !== selRepo) continue; 99 99 for (const rec of recs) { 100 + if (tab.id === "list" && listRkey && rec.uri.split("/").pop() !== listRkey) continue; 100 101 if (tab.id === "item" && listRkey && rec.value?.listId !== listRkey) continue; 101 102 if (itemRkeys && !itemRkeys.has(rec.value?.item)) continue; 102 103 out.push({ did, uri: rec.uri, value: rec.value }); ··· 131 132 132 133 return html` 133 134 <div class="devtools-panel" style=${this.props.style}> 134 - <div class="devtools-header"> 135 - <div class="devtools-tabs"> 136 - ${TABS.map( 137 - (t) => html` 138 - <button 139 - key=${t.id} 140 - class=${tab === t.id ? "active" : ""} 141 - onClick=${() => this.switchTab(t.id)} 142 - > 143 - ${t.label} 144 - </button> 145 - ` 146 - )} 135 + <div class="devtools-zoom"> 136 + <div class="devtools-header"> 137 + <div class="devtools-tabs"> 138 + ${TABS.map( 139 + (t) => html` 140 + <button 141 + key=${t.id} 142 + class=${tab === t.id ? "active" : ""} 143 + onClick=${() => this.switchTab(t.id)} 144 + > 145 + ${t.label} 146 + </button> 147 + ` 148 + )} 149 + </div> 150 + <button 151 + class="icon-button devtools-action" 152 + style="margin-left:auto" 153 + title=${this.paused.value ? "Resume live updates" : "Pause live updates"} 154 + disabled=${this.resuming.value || !devtoolsSubscription.value} 155 + onClick=${this.#togglePause} 156 + > 157 + ${this.paused.value 158 + ? html`<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><polygon points="3,2 13,8 3,14"/></svg> Play` 159 + : html`<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="3.5" height="12" rx="1"/><rect x="9.5" y="2" width="3.5" height="12" rx="1"/></svg> Pause`} 160 + </button> 161 + <button class="icon-button devtools-action" title="Close" onClick=${this.props.onClose}> 162 + <svg 163 + width="12" 164 + height="12" 165 + viewBox="0 0 16 16" 166 + fill="none" 167 + stroke="currentColor" 168 + stroke-width="2" 169 + stroke-linecap="round" 170 + > 171 + <line x1="4" y1="4" x2="12" y2="12" /> 172 + <line x1="12" y1="4" x2="4" y2="12" /> 173 + </svg> 174 + </button> 147 175 </div> 148 - <button 149 - class="icon-button devtools-action" 150 - style="margin-left:auto" 151 - title=${this.paused.value ? "Resume live updates" : "Pause live updates"} 152 - disabled=${this.resuming.value || !devtoolsSubscription.value} 153 - onClick=${this.#togglePause} 154 - > 155 - ${this.paused.value 156 - ? html`<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><polygon points="3,2 13,8 3,14"/></svg> Play` 157 - : html`<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="3.5" height="12" rx="1"/><rect x="9.5" y="2" width="3.5" height="12" rx="1"/></svg> Pause`} 158 - </button> 159 - <button class="icon-button devtools-action" title="Close" onClick=${this.props.onClose}> 160 - <svg 161 - width="12" 162 - height="12" 163 - viewBox="0 0 16 16" 164 - fill="none" 165 - stroke="currentColor" 166 - stroke-width="2" 167 - stroke-linecap="round" 168 - > 169 - <line x1="4" y1="4" x2="12" y2="12" /> 170 - <line x1="12" y1="4" x2="4" y2="12" /> 171 - </svg> 172 - </button> 173 - </div> 174 - <div class="devtools-body"> 175 - <div class="devtools-tab-content"> 176 - ${html` 177 - <div class="devtools-subtabs"> 178 - <button 179 - class=${!selRepo ? "active" : ""} 180 - onClick=${() => { 181 - this.selectedRepo.value = null; 182 - this.selected.value = null; 183 - }} 184 - > 185 - All 186 - </button> 187 - ${dids.map( 188 - (did) => html` 189 - <button 190 - key=${did} 191 - class=${selRepo === did ? "active" : ""} 192 - onClick=${() => { 193 - this.selectedRepo.value = did; 194 - this.selected.value = null; 195 - }} 196 - > 197 - @${handles[did] || did.slice(8, 20) + "…"} 198 - </button> 199 - ` 200 - )} 201 - </div> 202 - `} 203 - <div class=${`devtools-body-inner${selEntry ? " has-detail" : ""}`}> 204 - <div class="devtools-timeline"> 205 - ${dids.length === 0 206 - ? html`<p class="devtools-empty">Open a list to inspect records.</p>` 207 - : entries.length === 0 208 - ? html`<p class="devtools-empty">No records.</p>` 209 - : entries.map((entry, i) => { 210 - const active = sel === entry.uri; 211 - const rkey = entry.uri.split("/").pop(); 212 - const author = "@" + (handles[entry.did] || entry.did.slice(8, 20) + "…"); 213 - return html` 214 - <div 215 - class=${`devtools-entry${active ? " active" : ""}`} 216 - key=${entry.uri} 217 - onClick=${() => (this.selected.value = active ? null : entry.uri)} 218 - > 219 - <div class="devtools-entry-meta"> 220 - <span class="devtools-entry-index">${i + 1}</span> 221 - <span class="devtools-entry-author">${author}</span> 222 - <span class="devtools-entry-time">${rkey}</span> 176 + <div class="devtools-body"> 177 + <div class="devtools-tab-content"> 178 + ${html` 179 + <div class="devtools-subtabs"> 180 + <button 181 + class=${!selRepo ? "active" : ""} 182 + onClick=${() => { 183 + this.selectedRepo.value = null; 184 + this.selected.value = null; 185 + }} 186 + > 187 + All 188 + </button> 189 + ${dids.map( 190 + (did) => html` 191 + <button 192 + key=${did} 193 + class=${selRepo === did ? "active" : ""} 194 + onClick=${() => { 195 + this.selectedRepo.value = did; 196 + this.selected.value = null; 197 + }} 198 + > 199 + @${handles[did] || did.slice(8, 20) + "…"} 200 + </button> 201 + ` 202 + )} 203 + </div> 204 + `} 205 + <div class=${`devtools-body-inner${selEntry ? " has-detail" : ""}`}> 206 + <div class="devtools-timeline"> 207 + ${dids.length === 0 208 + ? html`<p class="devtools-empty">Open a list to inspect records.</p>` 209 + : entries.length === 0 210 + ? html`<p class="devtools-empty">No records.</p>` 211 + : entries.map((entry, i) => { 212 + const active = sel === entry.uri; 213 + const rkey = entry.uri.split("/").pop(); 214 + const author = "@" + (handles[entry.did] || entry.did.slice(8, 20) + "…"); 215 + return html` 216 + <div 217 + class=${`devtools-entry${active ? " active" : ""}`} 218 + key=${entry.uri} 219 + onClick=${() => (this.selected.value = active ? null : entry.uri)} 220 + > 221 + <div class="devtools-entry-meta"> 222 + <span class="devtools-entry-index">${i + 1}</span> 223 + <span class="devtools-entry-author">${author}</span> 224 + <span class="devtools-entry-time">${rkey}</span> 225 + </div> 226 + <div class="devtools-entry-uri">${entry.uri}</div> 227 + <pre class="devtools-entry-json">${formatRecord(entry.value)}</pre> 223 228 </div> 224 - <div class="devtools-entry-uri">${entry.uri}</div> 225 - <pre class="devtools-entry-json">${formatRecord(entry.value)}</pre> 226 - </div> 227 - `; 228 - })} 229 + `; 230 + })} 231 + </div> 232 + ${selEntry ? this.renderDetail(selEntry) : null} 229 233 </div> 230 - ${selEntry ? this.renderDetail(selEntry) : null} 231 234 </div> 232 235 </div> 233 236 </div>
+12 -1
list.js
··· 16 16 devtoolsRecords, 17 17 devtoolsDids, 18 18 devtoolsSubscription, 19 + showDevtools, 20 + devtoolsPanelHeight, 21 + DEVTOOLS_HANDLE_HEIGHT, 19 22 } from "./state.js"; 20 23 import { 21 24 listRecords, ··· 201 204 }; 202 205 203 206 render() { 207 + const devtoolsHeight = showDevtools.value 208 + ? devtoolsPanelHeight.value + DEVTOOLS_HANDLE_HEIGHT 209 + : 0; 210 + 204 211 return html` 205 - <div class="modal-bg" onClick=${this.props.onClose}> 212 + <div 213 + class="modal-bg" 214 + style=${devtoolsHeight ? `bottom:${devtoolsHeight}px` : ""} 215 + onClick=${this.props.onClose} 216 + > 206 217 <div class="modal" onClick=${(e) => e.stopPropagation()}> 207 218 <h3>Editors</h3> 208 219 ${this.props.editors.length === 0
+12
state.js
··· 45 45 export const devtoolsDids = signal([]); 46 46 // { pause: () => void, resume: () => Promise<void> } while a list is active, null otherwise. 47 47 export const devtoolsSubscription = signal(null); 48 + 49 + // UI state for the resizable devtools panel. Lives here (rather than as local 50 + // component state) so other parts of the tree — like the editors modal — can 51 + // react to whether it's open and how tall it is. 52 + export const showDevtools = signal(false); 53 + export const devtoolsPanelHeight = signal(280); 54 + 55 + // Kept in sync with the resize handle's height in style.css so other 56 + // components can compute the devtools panel's actual on-screen height 57 + // without measuring the DOM. (The panel's internal zoom only affects its 58 + // content, not its own box, so it isn't a factor here.) 59 + export const DEVTOOLS_HANDLE_HEIGHT = 6;
+13 -1
style.css
··· 1 1 :root { 2 2 color-scheme: light dark; 3 3 font-family: system-ui, -apple-system, sans-serif; 4 - font-size: 15px; 4 + font-size: 22.5px; 5 5 } 6 6 7 7 *, *::before, *::after { box-sizing: border-box; } ··· 457 457 flex-direction: column; 458 458 border-top: 1px solid #ddd; 459 459 background: #fff; 460 + } 461 + 462 + /* Everything visible actually lives in here, not directly in .devtools-panel. 463 + That keeps the panel's own box at its real, unzoomed drag-resized height 464 + (so the resize handle keeps tracking the cursor 1:1) while the zoomed 465 + content just scrolls internally if it doesn't fit. */ 466 + .devtools-zoom { 467 + display: flex; 468 + flex-direction: column; 469 + flex: 1; 470 + min-height: 0; 460 471 font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, 461 472 "DejaVu Sans Mono", monospace; 462 473 font-size: 12px; 474 + zoom: 2; 463 475 } 464 476 465 477 .devtools-header {