Something went wrong. Try again.
a proof of concept realtime collaborative todo list
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813import { Component } from "preact";import { html } from "htm/preact";import { signal } from "@preact/signals";import { session, navigate, BASE, LIST_NSID, ITEM_NSID, TOMBSTONE_NSID, parseAtUri, listName, listIsOwned, listEditors, showEditorsModal, devtoolsRecords, devtoolsDids, devtoolsSubscription, showDevtools, devtoolsPanelHeight, DEVTOOLS_HANDLE_HEIGHT,} from "./state.js";import { listRecords, getRecord, tid, resolveHandleToDid, resolveHandle,} from "./atproto.js";import { mergeRecords, applyUpdates, crdtEqual } from "./crdt.js";import { subscribe } from "./subscribe.js";import { safePut, safeDelete, setCid, getQueue, flushQueue, setForcedOffline } from "./offline.js";
// Fractional indexing. atproto's data model forbids floats, so order can't be a// numeric midpoint — instead `index` is a lexicographically-sortable string key,// and reordering writes a new key strictly between the two neighbors. Only the// moved item's `index` changes, so concurrent reorders by different editors// don't clobber each other.const KEY_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz";
// Generate a key strictly between `a` (lower bound, null = start) and `b` (upper// bound, null = end), lexicographically. There's always room for another key// between any two, so reordering never needs to renumber neighbors.function generateKeyBetween(a, b) { const lower = a ?? ""; let upper = b ?? null; let prefix = ""; for (let i = 0; ; i++) { if (upper != null && i >= upper.length) upper = null; // equal bounds: degrade to "after" const ia = KEY_DIGITS.indexOf(i < lower.length ? lower[i] : KEY_DIGITS[0]); const ib = upper != null ? KEY_DIGITS.indexOf(upper[i]) : KEY_DIGITS.length; if (ib - ia > 1) return prefix + KEY_DIGITS[ia + ((ib - ia) >> 1)]; prefix += KEY_DIGITS[ia]; if (ib - ia === 1) upper = null; // adjacent digits: unbounded room below this one }}
// Order items by `index`. Legacy records carry a numeric index; those sort// before any string key until migrateIndices() rewrites them (see load()).function cmpIndex(a, b) { const sa = typeof a === "string"; const sb = typeof b === "string"; if (sa && sb) return a < b ? -1 : a > b ? 1 : 0; if (!sa && !sb) return (a ?? 0) - (b ?? 0); return sa ? 1 : -1;}
class ItemRow extends Component { editing = signal(false); text = signal("");
onToggle = () => { this.props.onUpdate(this.props.item.rkey, { done: !this.props.item.done }); };
onDragStart = (e) => { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", this.props.item.rkey); this.props.onDragStart(this.props.item.rkey); };
onDragOver = (e) => { if (!this.props.onDragOver) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; const rect = e.currentTarget.getBoundingClientRect(); const after = e.clientY > rect.top + rect.height / 2; this.props.onDragOver(this.props.item.rkey, after); };
onDrop = (e) => { e.preventDefault(); this.props.onDrop(); };
startEdit = () => { this.text.value = this.props.item.text ?? ""; this.editing.value = true; };
commitEdit = () => { this.editing.value = false; if (this.text.value !== this.props.item.text) { this.props.onUpdate(this.props.item.rkey, { text: this.text.value }); } };
onKey = (e) => { if (e.key === "Enter") { e.preventDefault(); this.commitEdit(); } else if (e.key === "Escape") { this.editing.value = false; } };
render() { const { item, drop, editable } = this.props; const cls = "item" + (item.done ? " done" : "") + (this.props.dragging ? " dragging" : "") + (drop === "before" ? " drop-before" : drop === "after" ? " drop-after" : ""); return html` <li class=${cls} draggable=${editable && !this.editing.value} onDragStart=${editable ? this.onDragStart : undefined} onDragOver=${editable ? this.onDragOver : undefined} onDrop=${editable ? this.onDrop : undefined} onDragEnd=${this.props.onDragEnd} > <span class="grip" aria-hidden="true">⠿</span> <input type="checkbox" checked=${!!item.done} disabled=${!editable} onChange=${this.onToggle} /> ${this.editing.value ? html`<input class="edit" autofocus value=${this.text.value} onInput=${(e) => (this.text.value = e.target.value)} onBlur=${this.commitEdit} onKeyDown=${this.onKey} />` : html`<span class="text" onClick=${editable ? this.startEdit : undefined} >${item.text || html`<em class="empty">empty</em>`}</span >`} ${editable && html`<button class="x" onClick=${() => this.props.onDelete(item.rkey)}>×</button>`} </li> `; }}
class EditorsModal extends Component { handle = signal(""); busy = signal(false); error = signal(""); // did -> handle, resolved lazily; falls back to the did until resolved. handles = signal(new Map());
componentDidMount() { this.resolveHandles(); }
componentDidUpdate() { this.resolveHandles(); }
resolveHandles() { for (const did of this.props.editors) { if (this.handles.value.has(did)) continue; this.handles.value = new Map(this.handles.value).set(did, did); resolveHandle(did).then((handle) => { this.handles.value = new Map(this.handles.value).set(did, handle); }); } }
add = async (e) => { e.preventDefault(); const input = this.handle.value.trim(); if (!input) return; this.busy.value = true; this.error.value = ""; try { const did = input.startsWith("did:") ? input : await resolveHandleToDid(input); const editors = this.props.editors.includes(did) ? this.props.editors : [...this.props.editors, did]; await this.props.onSave(editors); this.handle.value = ""; } catch (err) { this.error.value = err.message; } finally { this.busy.value = false; } };
remove = async (did) => { try { await this.props.onSave(this.props.editors.filter((e) => e !== did)); } catch (err) { this.error.value = err.message; } };
render() { const devtoolsHeight = showDevtools.value ? devtoolsPanelHeight.value + DEVTOOLS_HANDLE_HEIGHT : 0;
return html` <div class="modal-bg" style=${devtoolsHeight ? `bottom:${devtoolsHeight}px` : ""} onClick=${this.props.onClose} > <div class="modal" onClick=${(e) => e.stopPropagation()}> <h3>Editors</h3> ${this.props.editors.length === 0 ? html`<p class="empty">No editors yet.</p>` : html`<ul class="editors"> ${this.props.editors.map( (did) => html` <li key=${did}> <code title=${did}>${this.handles.value.get(did) || did}</code> <button onClick=${() => this.remove(did)}>Remove</button> </li> ` )} </ul>`} <form onSubmit=${this.add}> <input placeholder="handle or did" value=${this.handle.value} onInput=${(e) => (this.handle.value = e.target.value)} disabled=${this.busy.value} /> <button disabled=${this.busy.value}>Add</button> </form> ${this.error.value && html`<p class="error">${this.error.value}</p>`} <button class="close" onClick=${this.props.onClose}>Close</button> </div> </div> `; }}
export class ListView extends Component { listRecord = signal(null); loadError = signal(""); itemsLoaded = signal(false);
// Map<did, Map<rkey, recordValue>> of item records for this list itemsByDid = signal(new Map()); // Set<rkey> of tombstoned items (from any editor) tombstones = signal(new Set()); // Set<rkey> of items for which WE have written a tombstone record myTombstoneItems = signal(new Set());
newItemText = signal("");
// Drag-to-reorder state dragRkey = signal(null); dragOverRkey = signal(null); dragOverAfter = signal(false);
unsubscribe = null;
async componentDidMount() { await this.load(); }
componentWillUnmount() { if (this.unsubscribe) this.unsubscribe(); listName.value = ""; listIsOwned.value = false; listEditors.value = []; showEditorsModal.value = false; setForcedOffline(false); devtoolsRecords.value = {}; devtoolsDids.value = []; devtoolsSubscription.value = null; }
async componentDidUpdate(prevProps) { if (prevProps.uri !== this.props.uri) { if (this.unsubscribe) this.unsubscribe(); this.unsubscribe = null; this.listRecord.value = null; this.itemsLoaded.value = false; this.itemsByDid.value = new Map(); this.tombstones.value = new Set(); this.myTombstoneItems.value = new Set(); await this.load(); } }
parsed() { return parseAtUri(this.props.uri); }
// Whether the local user is allowed to write to this list: the owner, or // someone currently listed in the list record's `editors`. Re-derived from // `listRecord` on every call, so it reflects edits/removals live. canEdit() { const parsed = this.parsed(); if (!parsed) return false; const me = session.value.did; if (parsed.did === me) return true; return (this.listRecord.value?.editors || []).includes(me); }
async load() { const parsed = this.parsed(); if (!parsed) { this.loadError.value = "Invalid URI"; return; } const { did: ownerDid, rkey: listRkey } = parsed; const me = session.value.did;
try { const ownerRec = await getRecord(ownerDid, LIST_NSID, listRkey); if (!ownerRec) { this.loadError.value = "List not found"; return; } this.listRecord.value = ownerRec.value; listName.value = ownerRec.value.name || "(unnamed)"; listIsOwned.value = ownerDid === me && ownerRec.value.editors !== undefined; listEditors.value = ownerRec.value.editors || []; if (ownerDid === me) setCid(LIST_NSID, listRkey, ownerRec.cid);
// Auto-create a reference record in our own repo if we're an editor (but // not the owner), so the list shows up in our own list-of-lists. Skipped // for non-editors so merely opening a shared link never writes to their PDS. if (ownerDid !== me && this.canEdit()) { const myLists = await listRecords(me, LIST_NSID); const hasRef = myLists.some((r) => r.value.ref === this.props.uri); if (!hasRef) await safePut(LIST_NSID, tid(), { ref: this.props.uri }); }
const editors = ownerRec.value.editors || []; const dids = Array.from(new Set([ownerDid, ...editors, me])); this.dids = dids; devtoolsDids.value = dids;
await this.fetchDidsData(dids);
// Reflect any buffered offline writes for this list so the UI shows // them on reload, before the queue actually flushes. const itemsByDid = new Map(this.itemsByDid.value); const tombstones = new Set(this.tombstones.value); const myTombItems = new Set(this.myTombstoneItems.value); const me_inner = new Map(itemsByDid.get(me) || []); for (const e of getQueue()) { if (e.op !== "put") continue; if (e.collection === ITEM_NSID && e.record?.listId === listRkey) { me_inner.set(e.rkey, e.record); } else if (e.collection === TOMBSTONE_NSID && e.record?.item) { tombstones.add(e.record.item); myTombItems.add(e.record.item); } } itemsByDid.set(me, me_inner);
this.itemsByDid.value = itemsByDid; this.tombstones.value = tombstones; this.myTombstoneItems.value = myTombItems; this.itemsLoaded.value = true;
this.unsubscribe = await subscribe({ dids, collections: [LIST_NSID, ITEM_NSID, TOMBSTONE_NSID], onCommit: this.onCommit, }); devtoolsSubscription.value = { pause: () => { this.unsubscribe?.(); this.unsubscribe = null; setForcedOffline(true); }, resume: async () => { setForcedOffline(false); await flushQueue(); await this.fetchDidsData(this.dids); this.unsubscribe?.(); this.unsubscribe = subscribe({ dids: this.dids, collections: [LIST_NSID, ITEM_NSID, TOMBSTONE_NSID], onCommit: this.onCommit, }); }, };
// Replicate tombstones we're missing and write merged state into our // own repo for any items where we're missing peer updates. await this.replicateAll();
// Convert any legacy numeric indices to string keys (one-time, idempotent). this.migrateIndices().catch((err) => console.error("migrateIndices", err)); } catch (err) { this.loadError.value = err.message; } }
// Fetch items + tombstones for the given dids and merge them into the // existing cache. Safe to call repeatedly for an already-known did. async fetchDidsData(dids) { const me = session.value.did; const parsed = this.parsed(); const itemsByDid = new Map(this.itemsByDid.value); const tombstones = new Set(this.tombstones.value); const myTombItems = new Set(this.myTombstoneItems.value); const rawByNsid = { [LIST_NSID]: { ...(devtoolsRecords.value[LIST_NSID] ?? {}) }, [ITEM_NSID]: { ...(devtoolsRecords.value[ITEM_NSID] ?? {}) }, [TOMBSTONE_NSID]: { ...(devtoolsRecords.value[TOMBSTONE_NSID] ?? {}) }, };
await Promise.all( dids.map(async (did) => { const [items, tombs, lists] = await Promise.all([ listRecords(did, ITEM_NSID).catch(() => []), listRecords(did, TOMBSTONE_NSID).catch(() => []), listRecords(did, LIST_NSID).catch(() => []), ]); const map = new Map(itemsByDid.get(did) || []); for (const r of items) { if (r.value.listId !== parsed.rkey) continue; const rkey = r.uri.split("/").pop(); map.set(rkey, r.value); if (did === me) setCid(ITEM_NSID, rkey, r.cid); } itemsByDid.set(did, map); for (const t of tombs) { if (t.value.item) { tombstones.add(t.value.item); if (did === me) myTombItems.add(t.value.item); } } rawByNsid[LIST_NSID][did] = lists.map((r) => ({ uri: r.uri, value: r.value })); rawByNsid[ITEM_NSID][did] = items.map((r) => ({ uri: r.uri, value: r.value })); rawByNsid[TOMBSTONE_NSID][did] = tombs.map((r) => ({ uri: r.uri, value: r.value })); }) );
this.itemsByDid.value = itemsByDid; this.tombstones.value = tombstones; this.myTombstoneItems.value = myTombItems; devtoolsRecords.value = rawByNsid; }
// Replicate tombstones we're missing and write merged state into our own // repo for any items where we're missing peer updates. Idempotent. Only // editors/owners mirror peer data into their own repo; everyone else reads // the merged view without writing anything. async replicateAll() { if (!this.canEdit()) return; const me = session.value.did; const tombstones = this.tombstones.value; const myTombItems = new Set(this.myTombstoneItems.value); for (const rkey of tombstones) { if (!myTombItems.has(rkey)) { myTombItems.add(rkey); safePut(TOMBSTONE_NSID, tid(), { item: rkey }).catch(() => {}); } } this.myTombstoneItems.value = myTombItems;
for (const rkey of new Set( [...this.itemsByDid.value.values()].flatMap((m) => [...m.keys()]) )) { if (tombstones.has(rkey)) { if (this.itemsByDid.value.get(me)?.has(rkey)) safeDelete(ITEM_NSID, rkey).catch(() => {}); continue; } this.syncOwnRecord(rkey); } }
// Called when the list's `editors` array changes (locally or via a remote // commit) while we're already on the page. Picks up any newly added // editor's data and replicates the full list into our own repo, and // restarts the live subscription so future edits from them stream in too. async onEditorsChanged(nextDids) { const added = nextDids.filter((did) => !this.dids.includes(did)); this.dids = nextDids;
// Our own did is already in `this.dids` even as a read-only viewer, so // `added` can be empty when we're the one newly granted editor access. // Always replicate (it no-ops if canEdit() is still false) and only // refetch/resubscribe when there's actually a new did to pull in. if (added.length > 0) { await this.fetchDidsData(added);
if (this.unsubscribe) this.unsubscribe(); devtoolsDids.value = nextDids; this.unsubscribe = await subscribe({ dids: nextDids, collections: [LIST_NSID, ITEM_NSID, TOMBSTONE_NSID], onCommit: this.onCommit, }); }
await this.replicateAll(); }
// Rewrite legacy numeric `index` values as fractional string keys, preserving // current order. Runs after load; no-ops once every item already has a string. async migrateIndices() { const items = this.mergedItems(); if (items.every((it) => typeof it.index === "string")) return; let prev = null; for (let i = 0; i < items.length; i++) { const it = items[i]; if (typeof it.index === "string") { prev = it.index; continue; } let next = null; for (let j = i + 1; j < items.length; j++) { if (typeof items[j].index === "string") { next = items[j].index; break; } } const key = generateKeyBetween(prev, next); prev = key; await this.onUpdate(it.rkey, { index: key }); } }
onCommit = ({ did, collection, rkey, cid, action, record }) => { const parsed = this.parsed(); if (!parsed) return;
if (did === session.value.did) { setCid(collection, rkey, action === "delete" ? null : cid); }
// Keep devtools records in sync with the live subscription. const uri = `at://${did}/${collection}/${rkey}`; const outer = { ...devtoolsRecords.value }; if (!outer[collection]) outer[collection] = {}; const inner = [...(outer[collection][did] ?? [])]; if (action === "delete") { outer[collection] = { ...outer[collection], [did]: inner.filter((r) => r.uri !== uri) }; } else if (record) { const idx = inner.findIndex((r) => r.uri === uri); const updated = idx >= 0 ? inner.with(idx, { uri, value: record }) : [...inner, { uri, value: record }]; outer[collection] = { ...outer[collection], [did]: updated }; } devtoolsRecords.value = outer;
if (collection === LIST_NSID) { if (did === parsed.did && rkey === parsed.rkey) { this.listRecord.value = action === "delete" ? null : record; if (action !== "delete") { const editors = record.editors || []; listEditors.value = editors; const me = session.value.did; const nextDids = Array.from(new Set([parsed.did, ...editors, me])); this.onEditorsChanged(nextDids).catch((err) => console.error("onEditorsChanged", err) ); } } return; } if (collection === ITEM_NSID) { const next = new Map(this.itemsByDid.value); const inner = new Map(next.get(did) || []); if (action === "delete") inner.delete(rkey); else if (record && record.listId === parsed.rkey) { // Merge with whatever's already in the cache so an out-of-order or // delayed firehose echo can't regress our state to an older clock. const current = inner.get(rkey); inner.set(rkey, current ? mergeRecords([current, record]) : record); } else inner.delete(rkey); next.set(did, inner); this.itemsByDid.value = next; if ( did !== session.value.did && action !== "delete" && !this.tombstones.value.has(rkey) && this.canEdit() ) this.syncOwnRecord(rkey); return; } if (collection === TOMBSTONE_NSID) { if (action !== "delete" && record?.item) { const ts = new Set(this.tombstones.value); ts.add(record.item); this.tombstones.value = ts; if (!this.canEdit()) return; const me = session.value.did; if (this.itemsByDid.value.get(me)?.has(record.item)) { safeDelete(ITEM_NSID, record.item).catch(() => {}); } if (did !== session.value.did && !this.myTombstoneItems.value.has(record.item)) { this.myTombstoneItems.value = new Set([...this.myTombstoneItems.value, record.item]); safePut(TOMBSTONE_NSID, tid(), { item: record.item }).catch(() => {}); } } } };
mergedFor(rkey) { const versions = []; for (const m of this.itemsByDid.value.values()) { const r = m.get(rkey); if (r) versions.push(r); } return mergeRecords(versions); }
mergedItems() { const rkeys = new Set(); for (const m of this.itemsByDid.value.values()) for (const k of m.keys()) rkeys.add(k); const tomb = this.tombstones.value; const out = []; for (const rkey of rkeys) { if (tomb.has(rkey)) continue; const merged = this.mergedFor(rkey); if (merged) out.push({ rkey, ...merged }); } // rkey breaks index ties so every editor renders the same order. out.sort((a, b) => cmpIndex(a.index, b.index) || a.rkey.localeCompare(b.rkey)); return out; }
// If the merged state for this rkey is ahead of our own record, write it // back so every editor's repo converges to the same data. async syncOwnRecord(rkey) { const me = session.value.did; const merged = this.mergedFor(rkey); if (!merged) return; const mine = this.itemsByDid.value.get(me)?.get(rkey); if (mine && crdtEqual(mine.$crdt, merged.$crdt)) return; this.applyLocal(rkey, merged); try { await safePut(ITEM_NSID, rkey, merged); } catch (err) { console.error("syncOwnRecord", rkey, err); } }
// Optimistically update our local cache so the UI reacts immediately while // the network request settles; the firehose event will reconcile later. applyLocal(rkey, record) { const next = new Map(this.itemsByDid.value); const me = session.value.did; const inner = new Map(next.get(me) || []); inner.set(rkey, record); next.set(me, inner); this.itemsByDid.value = next; }
onAdd = async (e) => { e.preventDefault(); if (!this.canEdit()) return; const text = this.newItemText.value.trim(); if (!text) return; this.newItemText.value = ""; const parsed = this.parsed(); const me = session.value.did; const items = this.mergedItems(); const last = items[items.length - 1]; const lastKey = typeof last?.index === "string" ? last.index : null; const record = applyUpdates( null, { listId: parsed.rkey, text, done: false, index: generateKeyBetween(lastKey, null) }, me ); const rkey = tid(); this.applyLocal(rkey, record); try { await safePut(ITEM_NSID, rkey, record); } catch (err) { this.loadError.value = err.message; } };
onUpdate = async (rkey, updates) => { if (!this.canEdit()) return; const me = session.value.did; const merged = this.mergedFor(rkey); if (!merged) return; const record = applyUpdates(merged, updates, me); this.applyLocal(rkey, record); try { await safePut(ITEM_NSID, rkey, record); } catch (err) { this.loadError.value = err.message; } };
onDragStart = (rkey) => { this.dragRkey.value = rkey; };
onDragOver = (rkey, after) => { this.dragOverRkey.value = rkey; this.dragOverAfter.value = after; };
onDragEnd = () => { this.dragRkey.value = null; this.dragOverRkey.value = null; };
onDrop = async () => { const source = this.dragRkey.value; const target = this.dragOverRkey.value; const after = this.dragOverAfter.value; this.onDragEnd(); if (!source || !target || source === target) return;
// Find where the dragged item lands among its peers, ignoring its own // current slot, then index it between the new neighbors. const order = this.mergedItems().filter((i) => i.rkey !== source); const targetPos = order.findIndex((i) => i.rkey === target); if (targetPos === -1) return; const insertAt = after ? targetPos + 1 : targetPos; const before = order[insertAt - 1]; const next = order[insertAt]; if (before?.rkey === source || next?.rkey === source) return; const a = typeof before?.index === "string" ? before.index : null; const b = typeof next?.index === "string" ? next.index : null; await this.onUpdate(source, { index: generateKeyBetween(a, b) }); };
onDeleteItem = async (rkey) => { if (!this.canEdit()) return; const me = session.value.did; const ts = new Set(this.tombstones.value); ts.add(rkey); this.tombstones.value = ts; try { if (this.itemsByDid.value.get(me)?.has(rkey)) { await safeDelete(ITEM_NSID, rkey).catch(() => {}); } await safePut(TOMBSTONE_NSID, tid(), { item: rkey }); } catch (err) { this.loadError.value = err.message; } };
onSaveEditors = async (editors) => { const parsed = this.parsed(); if (session.value.did !== parsed.did) return; const next = { ...this.listRecord.value, editors }; await safePut(LIST_NSID, parsed.rkey, next); this.listRecord.value = next; listEditors.value = editors; };
render() { if (this.loadError.value) return html`<p class="error">${this.loadError.value}</p>`; if (!this.listRecord.value) return html`<p>Loading…</p>`;
const items = this.mergedItems(); const editable = this.canEdit();
return html` ${editable ? html`<form onSubmit=${this.onAdd}> <input placeholder="New item" value=${this.newItemText.value} onInput=${(e) => (this.newItemText.value = e.target.value)} /> <button>Add</button> </form>` : html`<p class="empty">Read-only — you're not an editor of this list.</p>`} ${!this.itemsLoaded.value ? html`<p class="empty">Loading…</p>` : items.length === 0 ? html`<p class="empty">No items yet.</p>` : html`<ul class="items"> ${items.map( (item) => html`<${ItemRow} key=${item.rkey} item=${item} editable=${editable} onUpdate=${this.onUpdate} onDelete=${this.onDeleteItem} onDragStart=${this.onDragStart} onDragOver=${this.onDragOver} onDrop=${this.onDrop} onDragEnd=${this.onDragEnd} dragging=${this.dragRkey.value === item.rkey} drop=${this.dragOverRkey.value === item.rkey ? this.dragOverAfter.value ? "after" : "before" : null} />` )} </ul>`} ${showEditorsModal.value && html`<${EditorsModal} editors=${this.listRecord.value.editors || []} onSave=${this.onSaveEditors} onClose=${() => (showEditorsModal.value = false)} />`} `; }}