clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/prf-provision-custody
lofi / website / src / components / SyncDemo.tsx
SyncDemo.tsx 7.6K
1import type { ReactNode } from "react";2import { useReducer, useRef, useState } from "react";34// A last-write-wins register per task id, replicated between two simulated5// devices. Offline writes queue in an outbox; reconnect merges. Ported from6// the original vanilla implementation in the lofi-site landing page.78type Task = {9 id: string;10 text: string;11 done: boolean;12 ts: number;13 origin: DeviceId;14};15type DeviceId = "A" | "B";16type Device = { store: Map<string, Task>; outbox: string[] };17type Rig = { seq: number; online: boolean; dev: Record<DeviceId, Device> };1819const OTHER: Record<DeviceId, DeviceId> = { A: "B", B: "A" };2021function seedRig(): Rig {22 const rig: Rig = {23 seq: 0,24 online: true,25 dev: {26 A: { store: new Map(), outbox: [] },27 B: { store: new Map(), outbox: [] },28 },29 };30 const seeds: Array<[string, boolean]> = [31 ["Water the ferns", true],32 ["Repot the monstera", false],33 ["Order more perlite", false],34 ];35 for (const [text, done] of seeds) {36 const task: Task = {37 id: `seed-${rig.seq}`,38 text,39 done,40 ts: rig.seq++,41 origin: "A",42 };43 rig.dev.A.store.set(task.id, { ...task });44 rig.dev.B.store.set(task.id, { ...task });45 }46 return rig;47}4849function DeviceFrame(props: {50 which: DeviceId;51 name: string;52 rig: Rig;53 onAdd: (which: DeviceId, text: string) => void;54 onToggle: (which: DeviceId, task: Task) => void;55}): ReactNode {56 const { which, name, rig, onAdd, onToggle } = props;57 const [draft, setDraft] = useState("");58 const device = rig.dev[which];59 const tasks = [...device.store.values()].sort((a, b) => a.ts - b.ts);6061 const submit = () => {62 const text = draft.trim();63 if (!text) return;64 setDraft("");65 onAdd(which, text);66 };6768 return (69 <div className={`dev dev--${which.toLowerCase()}`}>70 <p className="dev-name">{name}</p>71 <div className="dev-body">72 <div className="add">73 <input74 type="text"75 value={draft}76 placeholder="Add a task…"77 aria-label={`Add a task on ${name.toLowerCase()}`}78 maxLength={42}79 onChange={(event) => setDraft(event.target.value)}80 onKeyDown={(event) => {81 if (event.key === "Enter") {82 event.preventDefault();83 submit();84 }85 }}86 />87 <button88 type="button"89 aria-label={`Add task on ${name.toLowerCase()}`}90 onClick={submit}91 >92 <span>+</span>93 </button>94 </div>95 <ul className="tasks">96 {tasks.length === 0 && (97 <li className="tasks-empty">Nothing here yet.</li>98 )}99 {tasks.map((task) => (100 <li101 key={task.id}102 className={[103 task.done ? "is-done" : "",104 device.outbox.includes(task.id) ? "is-pending" : "",105 ].join(" ").trim()}106 >107 <button108 type="button"109 className="tick"110 aria-pressed={task.done}111 aria-label={`${112 task.done ? "Mark not done" : "Mark done"113 }: ${task.text}`}114 onClick={() => onToggle(which, task)}115 >116 <svg viewBox="0 0 10 10" aria-hidden="true">117 <path118 d="M1 5l2.6 3L9 1.5"119 fill="none"120 stroke="#18231f"121 strokeWidth="2"122 strokeLinecap="round"123 strokeLinejoin="round"124 />125 </svg>126 </button>127 <span className="task-text">{task.text}</span>128 <span className="pending-dot" title="Not yet synced" />129 </li>130 ))}131 </ul>132 </div>133 </div>134 );135}136137export default function SyncDemo(): ReactNode {138 const rigRef = useRef<Rig | null>(null);139 rigRef.current ??= seedRig();140 const rig = rigRef.current;141 const wireRef = useRef<HTMLDivElement>(null);142 const [, rerender] = useReducer((n: number) => n + 1, 0);143144 const stamp = () => Date.now() * 1000 + rig.seq++;145 const uid = () => `t${rig.seq++}-${Math.random().toString(36).slice(2, 7)}`;146147 const apply = (target: DeviceId, rec: Task) => {148 const have = rig.dev[target].store.get(rec.id);149 if (!have || rec.ts > have.ts) {150 rig.dev[target].store.set(rec.id, { ...rec });151 }152 };153154 const pulse = () => {155 const wire = wireRef.current;156 if (!wire) return;157 wire.classList.remove("is-flowing");158 void wire.offsetWidth; /* restart the animation */159 wire.classList.add("is-flowing");160 };161162 const flush = (source: DeviceId) => {163 const ids = rig.dev[source].outbox.splice(0);164 if (ids.length === 0) return;165 const records = ids166 .map((id) => rig.dev[source].store.get(id))167 .filter((rec): rec is Task => rec !== undefined);168 pulse();169 /* let the change visibly travel the wire before the other device shows it */170 setTimeout(() => {171 for (const rec of records) apply(OTHER[source], rec);172 rerender();173 }, 260);174 };175176 const write = (source: DeviceId, rec: Task) => {177 rig.dev[source].store.set(rec.id, { ...rec });178 rig.dev[source].outbox.push(rec.id);179 if (rig.online) flush(source);180 rerender();181 };182183 const onAdd = (which: DeviceId, text: string) => {184 write(which, { id: uid(), text, done: false, ts: stamp(), origin: which });185 };186187 const onToggle = (which: DeviceId, task: Task) => {188 write(which, { ...task, done: !task.done, ts: stamp(), origin: which });189 };190191 const onNetChange = (online: boolean) => {192 rig.online = online;193 if (online) {194 /* both sides push what they buffered; the register converges either way */195 flush("A");196 flush("B");197 }198 rerender();199 };200201 const pending = rig.dev.A.outbox.length + rig.dev.B.outbox.length;202 const statusText = rig.online203 ? "Synced"204 : pending > 0205 ? "Offline — writing locally"206 : "Offline";207208 return (209 <div className={`demo-shell${rig.online ? "" : " is-offline"}`}>210 <div className="demo-bar">211 <p className="demo-status">212 <span className="led" />213 <span>{statusText}</span>214 </p>215 <label className="switch">216 <input217 type="checkbox"218 checked={rig.online}219 onChange={(event) => onNetChange(event.target.checked)}220 />221 <span className="switch-track">222 <span className="switch-thumb" />223 </span>224 <span>Network</span>225 </label>226 </div>227228 <div className="rig">229 <DeviceFrame230 which="A"231 name="This phone"232 rig={rig}233 onAdd={onAdd}234 onToggle={onToggle}235 />236 <div className="wire" ref={wireRef} aria-hidden="true">237 <div className="wire-line" />238 <div className="wire-flow" />239 <div240 className={`wire-badge${241 !rig.online && pending > 0 ? " is-on" : ""242 }`}243 >244 {pending} {pending === 1 ? "change waiting" : "changes waiting"}245 </div>246 </div>247 <DeviceFrame248 which="B"249 name="Your other device"250 rig={rig}251 onAdd={onAdd}252 onToggle={onToggle}253 />254 </div>255256 <p className="demo-foot">257 A simulation, drawn to the same model (not a live Jazz session). For the258 real one, and for the part where you close the laptop and it is all259 still there tomorrow: <code>deno task dev</code>.260 </p>261 </div>262 );263}