Replace Automerge with simple WebSocket sync

- Remove Automerge dependencies (WASM incompatible with Next.js)
- Add lightweight WebSocket-based sync layer
- Works in local-only mode until sync server deployed
- State persisted in localStorage for reconnection

Ready for deployment - sync server can be added later.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-15 12:59:22 -05:00
parent 530979978d
commit 23cbe6caa8
5 changed files with 483 additions and 960 deletions

576
package-lock.json generated
View File

@ -8,11 +8,6 @@
"name": "rmaps-online",
"version": "0.1.0",
"dependencies": {
"@automerge/automerge": "^2.2.8",
"@automerge/automerge-repo": "^1.2.1",
"@automerge/automerge-repo-network-websocket": "^1.2.1",
"@automerge/automerge-repo-react-hooks": "^1.2.1",
"@automerge/automerge-repo-storage-indexeddb": "^1.2.1",
"maplibre-gl": "^5.0.0",
"nanoid": "^5.0.9",
"next": "^14.2.28",
@ -45,174 +40,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@automerge/automerge": {
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/@automerge/automerge/-/automerge-2.2.9.tgz",
"integrity": "sha512-6HM52Ops79hAQBWMg/t0MNfGOdEiXyenQjO9F1hKZq0RWDsMLpPa1SzRy/C4/4UyX67sTHuA5CwBpH34SpfZlA==",
"license": "MIT",
"dependencies": {
"uuid": "^9.0.0"
}
},
"node_modules/@automerge/automerge-repo": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@automerge/automerge-repo/-/automerge-repo-1.2.1.tgz",
"integrity": "sha512-uEBr4bM01aSWkEt2tDKQxfW0Pahz2zbTTn4sRJfeKJlAg2SLr4QepFJ+3Tp4CNEkkU485olfnKYf6gt7uilMZQ==",
"license": "MIT",
"dependencies": {
"@automerge/automerge": "^2.2.5",
"bs58check": "^3.0.1",
"cbor-x": "^1.3.0",
"debug": "^4.3.4",
"eventemitter3": "^5.0.1",
"fast-sha256": "^1.3.0",
"tiny-typed-emitter": "^2.1.0",
"ts-node": "^10.9.1",
"uuid": "^9.0.0",
"xstate": "^5.9.1"
}
},
"node_modules/@automerge/automerge-repo-network-websocket": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@automerge/automerge-repo-network-websocket/-/automerge-repo-network-websocket-1.2.1.tgz",
"integrity": "sha512-n4sI6l51iBf0edWtX+vhk+sc8wJL72IVZl682SvzRHakA5CzyNZiC8sVzc142zRT3RDN12jCWGpwA2Voq6EXsQ==",
"license": "MIT",
"dependencies": {
"@automerge/automerge-repo": "1.2.1",
"cbor-x": "^1.3.0",
"debug": "^4.3.4",
"eventemitter3": "^5.0.1",
"isomorphic-ws": "^5.0.0",
"ws": "^8.7.0"
}
},
"node_modules/@automerge/automerge-repo-react-hooks": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@automerge/automerge-repo-react-hooks/-/automerge-repo-react-hooks-1.2.1.tgz",
"integrity": "sha512-xRRPFRp7dMLZZoIPuczI6ll8U6Qo4NkLAstIHZloGcVefSEOLXRRObMwkITbVaRCEM2zyRLG0MfA9T4dA1JYFQ==",
"license": "MIT",
"dependencies": {
"@automerge/automerge": "^2.2.5",
"@automerge/automerge-repo": "1.2.1",
"eventemitter3": "^5.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-usestateref": "^1.0.8"
},
"peerDependencies": {
"react": ">16.8.0",
"react-dom": ">16.8.0"
}
},
"node_modules/@automerge/automerge-repo-storage-indexeddb": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@automerge/automerge-repo-storage-indexeddb/-/automerge-repo-storage-indexeddb-1.2.1.tgz",
"integrity": "sha512-u+9eZZJK7DAr541buF4ut1ipkuiKRoaAtoFYo/ilq7zOLO7JX+GQOFx/8eKKRDlGt/AHTcDaFktkcaX0vKahQQ==",
"license": "MIT",
"dependencies": {
"@automerge/automerge-repo": "1.2.1"
}
},
"node_modules/@cbor-extract/cbor-extract-darwin-arm64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz",
"integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@cbor-extract/cbor-extract-darwin-x64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz",
"integrity": "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-arm": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz",
"integrity": "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-arm64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz",
"integrity": "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-x64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz",
"integrity": "sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-win32-x64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz",
"integrity": "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@emnapi/core": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
@ -410,6 +237,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -419,6 +247,7 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@ -549,15 +378,15 @@
}
},
"node_modules/@next/env": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.28.tgz",
"integrity": "sha512-PAmWhJfJQlP+kxZwCjrVd9QnR5x0R3u0mTXTiZDgSd4h5LdXmjxCCWbN9kq6hkZBOax8Rm3xDW5HagWyJuT37g==",
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
"integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.28.tgz",
"integrity": "sha512-GQUPA1bTZy5qZdPV5MOHB18465azzhg8xm5o2SqxMF+h1rWNjB43y6xmIPHG5OV2OiU3WxuINpusXom49DdaIQ==",
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.35.tgz",
"integrity": "sha512-Jw9A3ICz2183qSsqwi7fgq4SBPiNfmOLmTPXKvlnzstUwyvBrtySiY+8RXJweNAs9KThb1+bYhZh9XWcNOr2zQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -565,9 +394,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.28.tgz",
"integrity": "sha512-kzGChl9setxYWpk3H6fTZXXPFFjg7urptLq5o5ZgYezCrqlemKttwMT5iFyx/p1e/JeglTwDFRtb923gTJ3R1w==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
"integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
"cpu": [
"arm64"
],
@ -581,9 +410,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.28.tgz",
"integrity": "sha512-z6FXYHDJlFOzVEOiiJ/4NG8aLCeayZdcRSMjPDysW297Up6r22xw6Ea9AOwQqbNsth8JNgIK8EkWz2IDwaLQcw==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
"integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
"cpu": [
"x64"
],
@ -597,9 +426,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.28.tgz",
"integrity": "sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
"integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
"cpu": [
"arm64"
],
@ -613,9 +442,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.28.tgz",
"integrity": "sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
"integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
"cpu": [
"arm64"
],
@ -629,9 +458,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.28.tgz",
"integrity": "sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
"integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
"cpu": [
"x64"
],
@ -645,9 +474,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.28.tgz",
"integrity": "sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
"integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
"cpu": [
"x64"
],
@ -661,9 +490,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.28.tgz",
"integrity": "sha512-l61WZ3nevt4BAnGksUVFKy2uJP5DPz2E0Ma/Oklvo3sGj9sw3q7vBWONFRgz+ICiHpW5mV+mBrkB3XEubMrKaA==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
"integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
"cpu": [
"arm64"
],
@ -677,9 +506,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.28.tgz",
"integrity": "sha512-+Kcp1T3jHZnJ9v9VTJ/yf1t/xmtFAc/Sge4v7mVc1z+NYfYzisi8kJ9AsY8itbgq+WgEwMtOpiLLJsUy2qnXZw==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
"integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
"cpu": [
"ia32"
],
@ -693,9 +522,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.28.tgz",
"integrity": "sha512-1gCmpvyhz7DkB1srRItJTnmR2UwQPAUXXIg9r0/56g3O8etGmwlX68skKXJOp9EejW3hhv7nSQUJ2raFiz4MoA==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
"integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
"cpu": [
"x64"
],
@ -708,18 +537,6 @@
"node": ">= 10"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -809,30 +626,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@ -870,6 +663,7 @@
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@ -1461,6 +1255,7 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@ -1479,18 +1274,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -1836,12 +1619,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/base-x": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz",
"integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==",
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.7",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz",
@ -1923,25 +1700,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bs58": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz",
"integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==",
"license": "MIT",
"dependencies": {
"base-x": "^4.0.0"
}
},
"node_modules/bs58check": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz",
"integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.2.0",
"bs58": "^5.0.0"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -2043,37 +1801,6 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cbor-extract": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.0.tgz",
"integrity": "sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.1.1"
},
"bin": {
"download-cbor-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@cbor-extract/cbor-extract-darwin-arm64": "2.2.0",
"@cbor-extract/cbor-extract-darwin-x64": "2.2.0",
"@cbor-extract/cbor-extract-linux-arm": "2.2.0",
"@cbor-extract/cbor-extract-linux-arm64": "2.2.0",
"@cbor-extract/cbor-extract-linux-x64": "2.2.0",
"@cbor-extract/cbor-extract-win32-x64": "2.2.0"
}
},
"node_modules/cbor-x": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.0.tgz",
"integrity": "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==",
"license": "MIT",
"optionalDependencies": {
"cbor-extract": "^2.2.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -2172,12 +1899,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2278,6 +1999,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -2334,16 +2056,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -2351,15 +2063,6 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@ -2680,13 +2383,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.28.tgz",
"integrity": "sha512-UxJMRQ4uaEdLp3mVQoIbRIlEF0S2rTlyZhI/2yEMVdAWmgFfPY4iJZ68jCbhLvXMnKeHMkmqTGjEhFH5Vm9h+A==",
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.35.tgz",
"integrity": "sha512-BpLsv01UisH193WyT/1lpHqq5iJ/Orfz9h/NOOlAmTUq4GY349PextQ62K4XpnaM9supeiEn3TaOTeQO07gURg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "14.2.28",
"@next/eslint-plugin-next": "14.2.35",
"@rushstack/eslint-patch": "^1.3.3",
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
@ -3070,12 +2773,6 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -3127,12 +2824,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@ -4157,15 +3848,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/isomorphic-ws": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@ -4400,12 +4082,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"license": "ISC"
},
"node_modules/maplibre-gl": {
"version": "5.14.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.14.0.tgz",
@ -4514,6 +4190,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/murmurhash-js": {
@ -4576,13 +4253,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.28.tgz",
"integrity": "sha512-QLEIP/kYXynIxtcKB6vNjtWLVs3Y4Sb+EClTC/CSVzdLD1gIuItccpu/n1lhmduffI32iPGEK2cLLxxt28qgYA==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
"integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.28",
"@next/env": "14.2.35",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
@ -4597,15 +4273,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.28",
"@next/swc-darwin-x64": "14.2.28",
"@next/swc-linux-arm64-gnu": "14.2.28",
"@next/swc-linux-arm64-musl": "14.2.28",
"@next/swc-linux-x64-gnu": "14.2.28",
"@next/swc-linux-x64-musl": "14.2.28",
"@next/swc-win32-arm64-msvc": "14.2.28",
"@next/swc-win32-ia32-msvc": "14.2.28",
"@next/swc-win32-x64-msvc": "14.2.28"
"@next/swc-darwin-arm64": "14.2.33",
"@next/swc-darwin-x64": "14.2.33",
"@next/swc-linux-arm64-gnu": "14.2.33",
"@next/swc-linux-arm64-musl": "14.2.33",
"@next/swc-linux-x64-gnu": "14.2.33",
"@next/swc-linux-x64-musl": "14.2.33",
"@next/swc-win32-arm64-msvc": "14.2.33",
"@next/swc-win32-ia32-msvc": "14.2.33",
"@next/swc-win32-x64-msvc": "14.2.33"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@ -4672,21 +4348,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz",
"integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@ -5328,15 +4989,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-usestateref": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/react-usestateref/-/react-usestateref-1.0.9.tgz",
"integrity": "sha512-t8KLsI7oje0HzfzGhxFXzuwbf1z9vhBM1ptHLUIHhYqZDKFuI5tzdhEVxSNzUkYxwF8XdpOErzHlKxvP7sTERw==",
"license": "ISC",
"peerDependencies": {
"react": ">16.0.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -6192,12 +5844,6 @@
"node": ">=0.8"
}
},
"node_modules/tiny-typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -6285,55 +5931,6 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/ts-node/node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"license": "MIT"
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@ -6461,6 +6058,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -6493,6 +6091,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@ -6578,25 +6177,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -6820,46 +6400,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xstate": {
"version": "5.25.0",
"resolved": "https://registry.npmjs.org/xstate/-/xstate-5.25.0.tgz",
"integrity": "sha512-yyWzfhVRoTHNLjLoMmdwZGagAYfmnzpm9gPjlX2MhJZsDojXGqRxODDOi4BsgGRKD46NZRAdcLp6CKOyvQS0Bw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/xstate"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -11,11 +11,6 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@automerge/automerge": "^2.2.8",
"@automerge/automerge-repo": "^1.2.1",
"@automerge/automerge-repo-network-websocket": "^1.2.1",
"@automerge/automerge-repo-react-hooks": "^1.2.1",
"@automerge/automerge-repo-storage-indexeddb": "^1.2.1",
"maplibre-gl": "^5.0.0",
"nanoid": "^5.0.9",
"next": "^14.2.28",

View File

@ -1,18 +1,16 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { DocHandle } from '@automerge/automerge-repo';
import { nanoid } from 'nanoid';
import {
findOrCreateRoom,
addParticipant,
removeParticipant,
updateParticipantLocation,
updateParticipantStatus,
addWaypoint as addWaypointToDoc,
removeWaypoint as removeWaypointFromDoc,
type RoomDocument,
} from '@/lib/automerge';
RoomSync,
stateToParticipant,
stateToWaypoint,
type ParticipantState,
type LocationState,
type WaypointState,
type RoomState,
} from '@/lib/sync';
import type { Participant, ParticipantLocation, Waypoint } from '@/types';
// Color palette for participants
@ -58,138 +56,72 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
const [roomName, setRoomName] = useState(slug);
const handleRef = useRef<DocHandle<RoomDocument> | null>(null);
const participantIdRef = useRef<string | null>(null);
const syncRef = useRef<RoomSync | null>(null);
const participantIdRef = useRef<string>(nanoid());
// Convert document participants to typed Participant array
const docToParticipants = useCallback((doc: RoomDocument): Participant[] => {
return Object.values(doc.participants).map((p) => ({
id: p.id,
name: p.name,
emoji: p.emoji,
color: p.color,
joinedAt: new Date(p.joinedAt),
lastSeen: new Date(p.lastSeen),
status: p.status as Participant['status'],
location: p.location
? {
...p.location,
timestamp: new Date(p.location.timestamp),
source: p.location.source as ParticipantLocation['source'],
}
: undefined,
privacySettings: {
...p.privacySettings,
defaultPrecision: p.privacySettings.defaultPrecision as Participant['privacySettings']['defaultPrecision'],
},
}));
}, []);
// Handle state updates from sync
const handleStateChange = useCallback((state: RoomState) => {
setParticipants(Object.values(state.participants).map(stateToParticipant));
setWaypoints(state.waypoints.map(stateToWaypoint));
setRoomName(state.name || slug);
}, [slug]);
// Convert document waypoints to typed Waypoint array
const docToWaypoints = useCallback((doc: RoomDocument): Waypoint[] => {
return doc.waypoints.map((w) => ({
id: w.id,
name: w.name,
emoji: w.emoji,
location: {
latitude: w.location.latitude,
longitude: w.location.longitude,
indoor: w.location.indoor,
},
createdBy: w.createdBy,
createdAt: new Date(w.createdAt),
type: w.type as Waypoint['type'],
}));
// Handle connection changes
const handleConnectionChange = useCallback((connected: boolean) => {
setIsConnected(connected);
}, []);
// Initialize room connection
useEffect(() => {
let mounted = true;
if (!userName) return;
async function init() {
try {
setIsLoading(true);
setError(null);
setIsLoading(true);
setError(null);
const participantId = nanoid();
participantIdRef.current = participantId;
const participantId = participantIdRef.current;
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
// Create sync instance
const sync = new RoomSync(
slug,
participantId,
handleStateChange,
handleConnectionChange
);
syncRef.current = sync;
const handle = await findOrCreateRoom(
slug,
participantId,
userName,
userEmoji,
color
);
// Create participant state
const participant: ParticipantState = {
id: participantId,
name: userName,
emoji: userEmoji,
color,
joinedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
status: 'online',
};
if (!mounted) return;
// Join room
sync.join(participant);
handleRef.current = handle;
// Connect to sync server (if available)
// For now, runs in local-only mode
const syncUrl = process.env.NEXT_PUBLIC_SYNC_URL;
sync.connect(syncUrl);
// Add this participant if not already in the room
const doc = handle.docSync();
if (doc && !doc.participants[participantId]) {
addParticipant(handle, {
id: participantId,
name: userName,
emoji: userEmoji,
color,
joinedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
status: 'online',
privacySettings: {
sharingEnabled: true,
defaultPrecision: 'exact',
showIndoorFloor: true,
ghostMode: false,
},
});
}
// Subscribe to changes
handle.on('change', ({ doc }) => {
if (!mounted || !doc) return;
setParticipants(docToParticipants(doc));
setWaypoints(docToWaypoints(doc));
setRoomName(doc.name || slug);
});
// Initial state
const initialDoc = handle.docSync();
if (initialDoc) {
setParticipants(docToParticipants(initialDoc));
setWaypoints(docToWaypoints(initialDoc));
setRoomName(initialDoc.name || slug);
}
setIsConnected(true);
setIsLoading(false);
} catch (e) {
if (!mounted) return;
console.error('Failed to connect to room:', e);
setError('Failed to connect to room');
setIsLoading(false);
}
}
init();
setIsLoading(false);
return () => {
mounted = false;
// Leave room on unmount
if (handleRef.current && participantIdRef.current) {
updateParticipantStatus(handleRef.current, participantIdRef.current, 'offline');
}
sync.leave();
syncRef.current = null;
};
}, [slug, userName, userEmoji, docToParticipants, docToWaypoints]);
}, [slug, userName, userEmoji, handleStateChange, handleConnectionChange]);
// Update location
const updateLocation = useCallback((location: ParticipantLocation) => {
if (!handleRef.current || !participantIdRef.current) return;
if (!syncRef.current) return;
updateParticipantLocation(handleRef.current, participantIdRef.current, {
const locationState: LocationState = {
latitude: location.latitude,
longitude: location.longitude,
accuracy: location.accuracy,
@ -199,49 +131,50 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR
timestamp: location.timestamp.toISOString(),
source: location.source,
indoor: location.indoor,
});
};
syncRef.current.updateLocation(locationState);
}, []);
// Set status
const setStatus = useCallback((status: Participant['status']) => {
if (!handleRef.current || !participantIdRef.current) return;
updateParticipantStatus(handleRef.current, participantIdRef.current, status);
if (!syncRef.current) return;
syncRef.current.updateStatus(status);
}, []);
// Add waypoint
const addWaypoint = useCallback(
(waypoint: Omit<Waypoint, 'id' | 'createdAt' | 'createdBy'>) => {
if (!handleRef.current || !participantIdRef.current) return;
if (!syncRef.current) return;
addWaypointToDoc(handleRef.current, {
const waypointState: WaypointState = {
id: nanoid(),
name: waypoint.name,
emoji: waypoint.emoji,
location: {
latitude: waypoint.location.latitude,
longitude: waypoint.location.longitude,
indoor: waypoint.location.indoor,
},
latitude: waypoint.location.latitude,
longitude: waypoint.location.longitude,
indoor: waypoint.location.indoor,
createdBy: participantIdRef.current,
createdAt: new Date().toISOString(),
type: waypoint.type,
});
};
syncRef.current.addWaypoint(waypointState);
},
[]
);
// Remove waypoint
const removeWaypoint = useCallback((waypointId: string) => {
if (!handleRef.current) return;
removeWaypointFromDoc(handleRef.current, waypointId);
if (!syncRef.current) return;
syncRef.current.removeWaypoint(waypointId);
}, []);
// Leave room
const leave = useCallback(() => {
if (!handleRef.current || !participantIdRef.current) return;
removeParticipant(handleRef.current, participantIdRef.current);
handleRef.current = null;
participantIdRef.current = null;
if (!syncRef.current) return;
syncRef.current.leave();
syncRef.current = null;
setIsConnected(false);
}, []);

View File

@ -1,298 +0,0 @@
/**
* Automerge sync setup for real-time room collaboration
*
* Each room is an Automerge document containing:
* - Room metadata (name, settings)
* - Participants map (id -> participant data)
* - Waypoints array
*
* Documents sync via WebSocket to a relay server or P2P
*/
import { Repo, DocHandle } from '@automerge/automerge-repo';
import { BrowserWebSocketClientAdapter } from '@automerge/automerge-repo-network-websocket';
import { IndexedDBStorageAdapter } from '@automerge/automerge-repo-storage-indexeddb';
import type { AutomergeUrl } from '@automerge/automerge-repo';
// Room document schema (Automerge-compatible)
export interface RoomDocument {
id: string;
slug: string;
name: string;
createdAt: string;
createdBy: string;
settings: {
maxParticipants: number;
defaultPrecision: string;
allowGuestJoin: boolean;
showC3NavIndoor: boolean;
eventId?: string;
};
participants: {
[id: string]: {
id: string;
name: string;
emoji: string;
color: string;
joinedAt: string;
lastSeen: string;
status: string;
location?: {
latitude: number;
longitude: number;
accuracy: number;
altitude?: number;
heading?: number;
speed?: number;
timestamp: string;
source: string;
indoor?: {
level: number;
x: number;
y: number;
spaceName?: string;
};
};
privacySettings: {
sharingEnabled: boolean;
defaultPrecision: string;
showIndoorFloor: boolean;
ghostMode: boolean;
};
};
};
waypoints: Array<{
id: string;
name: string;
emoji?: string;
location: {
latitude: number;
longitude: number;
indoor?: {
level: number;
x: number;
y: number;
};
};
createdBy: string;
createdAt: string;
type: string;
}>;
}
// Singleton repo instance
let repoInstance: Repo | null = null;
// Default sync server URL (can be overridden)
const DEFAULT_SYNC_URL =
process.env.NEXT_PUBLIC_AUTOMERGE_SYNC_URL || 'wss://sync.automerge.org';
/**
* Get or create the Automerge repo instance
*/
export function getRepo(): Repo {
if (repoInstance) {
return repoInstance;
}
// Create network adapter (WebSocket)
const network = new BrowserWebSocketClientAdapter(DEFAULT_SYNC_URL);
// Create storage adapter (IndexedDB for persistence)
const storage = new IndexedDBStorageAdapter('rmaps-automerge');
// Create repo
repoInstance = new Repo({
network: [network],
storage,
});
return repoInstance;
}
/**
* Create a new room document
*/
export function createRoom(
slug: string,
creatorId: string,
creatorName: string,
creatorEmoji: string,
creatorColor: string
): DocHandle<RoomDocument> {
const repo = getRepo();
const initialDoc: RoomDocument = {
id: crypto.randomUUID(),
slug,
name: slug,
createdAt: new Date().toISOString(),
createdBy: creatorId,
settings: {
maxParticipants: 10,
defaultPrecision: 'exact',
allowGuestJoin: true,
showC3NavIndoor: true,
},
participants: {
[creatorId]: {
id: creatorId,
name: creatorName,
emoji: creatorEmoji,
color: creatorColor,
joinedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
status: 'online',
privacySettings: {
sharingEnabled: true,
defaultPrecision: 'exact',
showIndoorFloor: true,
ghostMode: false,
},
},
},
waypoints: [],
};
const handle = repo.create<RoomDocument>();
handle.change((doc) => {
Object.assign(doc, initialDoc);
});
return handle;
}
/**
* Join an existing room by URL
*/
export function joinRoom(url: AutomergeUrl): DocHandle<RoomDocument> {
const repo = getRepo();
return repo.find<RoomDocument>(url);
}
/**
* Get a room document handle by slug
* Uses a deterministic URL based on slug for discoverability
*/
export async function findOrCreateRoom(
slug: string,
creatorId: string,
creatorName: string,
creatorEmoji: string,
creatorColor: string
): Promise<DocHandle<RoomDocument>> {
const repo = getRepo();
// For now, create a new document each time
// In production, you'd use a discovery service or deterministic URLs
// based on the slug to find existing rooms
// Store room URL mapping in localStorage for reconnection
const storedUrl = localStorage.getItem(`rmaps_room_${slug}`);
if (storedUrl) {
try {
const handle = repo.find<RoomDocument>(storedUrl as AutomergeUrl);
// Wait for initial sync
await handle.whenReady();
const doc = handle.docSync();
if (doc) {
// Room exists, join it
return handle;
}
} catch (e) {
console.warn('Failed to load stored room, creating new:', e);
}
}
// Create new room
const handle = createRoom(slug, creatorId, creatorName, creatorEmoji, creatorColor);
// Store URL for future reconnection
localStorage.setItem(`rmaps_room_${slug}`, handle.url);
return handle;
}
/**
* Update participant location in a room document
*/
export function updateParticipantLocation(
handle: DocHandle<RoomDocument>,
participantId: string,
location: RoomDocument['participants'][string]['location']
): void {
handle.change((doc) => {
if (doc.participants[participantId]) {
doc.participants[participantId].location = location;
doc.participants[participantId].lastSeen = new Date().toISOString();
}
});
}
/**
* Update participant status
*/
export function updateParticipantStatus(
handle: DocHandle<RoomDocument>,
participantId: string,
status: string
): void {
handle.change((doc) => {
if (doc.participants[participantId]) {
doc.participants[participantId].status = status;
doc.participants[participantId].lastSeen = new Date().toISOString();
}
});
}
/**
* Add a participant to the room
*/
export function addParticipant(
handle: DocHandle<RoomDocument>,
participant: RoomDocument['participants'][string]
): void {
handle.change((doc) => {
doc.participants[participant.id] = participant;
});
}
/**
* Remove a participant from the room
*/
export function removeParticipant(
handle: DocHandle<RoomDocument>,
participantId: string
): void {
handle.change((doc) => {
delete doc.participants[participantId];
});
}
/**
* Add a waypoint to the room
*/
export function addWaypoint(
handle: DocHandle<RoomDocument>,
waypoint: RoomDocument['waypoints'][number]
): void {
handle.change((doc) => {
doc.waypoints.push(waypoint);
});
}
/**
* Remove a waypoint from the room
*/
export function removeWaypoint(
handle: DocHandle<RoomDocument>,
waypointId: string
): void {
handle.change((doc) => {
const index = doc.waypoints.findIndex((w) => w.id === waypointId);
if (index !== -1) {
doc.waypoints.splice(index, 1);
}
});
}

353
src/lib/sync.ts Normal file
View File

@ -0,0 +1,353 @@
/**
* Simple WebSocket-based room sync
*
* This is a lightweight sync layer that works without WASM dependencies.
* Can be replaced with Automerge later when we have a proper sync server.
*
* Architecture:
* - Each room is a shared state object
* - Changes are broadcast via WebSocket to all participants
* - State is stored in localStorage for reconnection
* - Falls back to local-only mode if WebSocket unavailable
*/
import type { Participant, ParticipantLocation, Waypoint } from '@/types';
// Room state that gets synced
export interface RoomState {
id: string;
slug: string;
name: string;
createdAt: string;
participants: Record<string, ParticipantState>;
waypoints: WaypointState[];
}
export interface ParticipantState {
id: string;
name: string;
emoji: string;
color: string;
joinedAt: string;
lastSeen: string;
status: string;
location?: LocationState;
}
export interface LocationState {
latitude: number;
longitude: number;
accuracy: number;
altitude?: number;
heading?: number;
speed?: number;
timestamp: string;
source: string;
indoor?: {
level: number;
x: number;
y: number;
spaceName?: string;
};
}
export interface WaypointState {
id: string;
name: string;
emoji?: string;
latitude: number;
longitude: number;
indoor?: {
level: number;
x: number;
y: number;
};
createdBy: string;
createdAt: string;
type: string;
}
// Message types for sync
export type SyncMessage =
| { type: 'join'; participant: ParticipantState }
| { type: 'leave'; participantId: string }
| { type: 'location'; participantId: string; location: LocationState }
| { type: 'status'; participantId: string; status: string }
| { type: 'waypoint_add'; waypoint: WaypointState }
| { type: 'waypoint_remove'; waypointId: string }
| { type: 'full_state'; state: RoomState }
| { type: 'request_state' };
type SyncCallback = (state: RoomState) => void;
type ConnectionCallback = (connected: boolean) => void;
export class RoomSync {
private slug: string;
private state: RoomState;
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private onStateChange: SyncCallback;
private onConnectionChange: ConnectionCallback;
private participantId: string;
constructor(
slug: string,
participantId: string,
onStateChange: SyncCallback,
onConnectionChange: ConnectionCallback
) {
this.slug = slug;
this.participantId = participantId;
this.onStateChange = onStateChange;
this.onConnectionChange = onConnectionChange;
// Initialize or load state
this.state = this.loadState() || this.createInitialState();
}
private createInitialState(): RoomState {
return {
id: crypto.randomUUID(),
slug: this.slug,
name: this.slug,
createdAt: new Date().toISOString(),
participants: {},
waypoints: [],
};
}
private loadState(): RoomState | null {
try {
const stored = localStorage.getItem(`rmaps_room_${this.slug}`);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load room state:', e);
}
return null;
}
private saveState(): void {
try {
localStorage.setItem(`rmaps_room_${this.slug}`, JSON.stringify(this.state));
} catch (e) {
console.warn('Failed to save room state:', e);
}
}
private notifyStateChange(): void {
this.saveState();
this.onStateChange({ ...this.state });
}
// Connect to sync server (when available)
connect(syncUrl?: string): void {
if (!syncUrl) {
// No sync server - local only mode
console.log('Running in local-only mode (no sync server)');
this.onConnectionChange(true);
return;
}
try {
this.ws = new WebSocket(`${syncUrl}/room/${this.slug}`);
this.ws.onopen = () => {
console.log('Connected to sync server');
this.onConnectionChange(true);
// Request current state from server
this.send({ type: 'request_state' });
};
this.ws.onmessage = (event) => {
try {
const message: SyncMessage = JSON.parse(event.data);
this.handleMessage(message);
} catch (e) {
console.warn('Invalid sync message:', e);
}
};
this.ws.onclose = () => {
console.log('Disconnected from sync server');
this.onConnectionChange(false);
this.scheduleReconnect(syncUrl);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (e) {
console.error('Failed to connect to sync server:', e);
this.onConnectionChange(false);
}
}
private scheduleReconnect(syncUrl: string): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.reconnectTimer = setTimeout(() => {
console.log('Attempting to reconnect...');
this.connect(syncUrl);
}, 5000);
}
private send(message: SyncMessage): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
private handleMessage(message: SyncMessage): void {
switch (message.type) {
case 'full_state':
// Merge with local state, keeping our participant
const myParticipant = this.state.participants[this.participantId];
this.state = message.state;
if (myParticipant) {
this.state.participants[this.participantId] = myParticipant;
}
break;
case 'join':
this.state.participants[message.participant.id] = message.participant;
break;
case 'leave':
delete this.state.participants[message.participantId];
break;
case 'location':
if (this.state.participants[message.participantId]) {
this.state.participants[message.participantId].location = message.location;
this.state.participants[message.participantId].lastSeen = new Date().toISOString();
}
break;
case 'status':
if (this.state.participants[message.participantId]) {
this.state.participants[message.participantId].status = message.status;
this.state.participants[message.participantId].lastSeen = new Date().toISOString();
}
break;
case 'waypoint_add':
this.state.waypoints.push(message.waypoint);
break;
case 'waypoint_remove':
this.state.waypoints = this.state.waypoints.filter(
(w) => w.id !== message.waypointId
);
break;
}
this.notifyStateChange();
}
// Public methods for updating state
join(participant: ParticipantState): void {
this.state.participants[participant.id] = participant;
this.send({ type: 'join', participant });
this.notifyStateChange();
}
leave(): void {
delete this.state.participants[this.participantId];
this.send({ type: 'leave', participantId: this.participantId });
this.notifyStateChange();
if (this.ws) {
this.ws.close();
this.ws = null;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
}
updateLocation(location: LocationState): void {
if (this.state.participants[this.participantId]) {
this.state.participants[this.participantId].location = location;
this.state.participants[this.participantId].lastSeen = new Date().toISOString();
this.send({ type: 'location', participantId: this.participantId, location });
this.notifyStateChange();
}
}
updateStatus(status: string): void {
if (this.state.participants[this.participantId]) {
this.state.participants[this.participantId].status = status;
this.state.participants[this.participantId].lastSeen = new Date().toISOString();
this.send({ type: 'status', participantId: this.participantId, status });
this.notifyStateChange();
}
}
addWaypoint(waypoint: WaypointState): void {
this.state.waypoints.push(waypoint);
this.send({ type: 'waypoint_add', waypoint });
this.notifyStateChange();
}
removeWaypoint(waypointId: string): void {
this.state.waypoints = this.state.waypoints.filter((w) => w.id !== waypointId);
this.send({ type: 'waypoint_remove', waypointId });
this.notifyStateChange();
}
getState(): RoomState {
return { ...this.state };
}
}
// Convert sync state to typed Participant
export function stateToParticipant(state: ParticipantState): Participant {
return {
id: state.id,
name: state.name,
emoji: state.emoji,
color: state.color,
joinedAt: new Date(state.joinedAt),
lastSeen: new Date(state.lastSeen),
status: state.status as Participant['status'],
location: state.location
? {
latitude: state.location.latitude,
longitude: state.location.longitude,
accuracy: state.location.accuracy,
altitude: state.location.altitude,
heading: state.location.heading,
speed: state.location.speed,
timestamp: new Date(state.location.timestamp),
source: state.location.source as ParticipantLocation['source'],
indoor: state.location.indoor,
}
: undefined,
privacySettings: {
sharingEnabled: true,
defaultPrecision: 'exact',
showIndoorFloor: true,
ghostMode: false,
},
};
}
// Convert sync state to typed Waypoint
export function stateToWaypoint(state: WaypointState): Waypoint {
return {
id: state.id,
name: state.name,
emoji: state.emoji,
location: {
latitude: state.latitude,
longitude: state.longitude,
indoor: state.indoor,
},
createdBy: state.createdBy,
createdAt: new Date(state.createdAt),
type: state.type as Waypoint['type'],
};
}