feat: integrate read-only mode for board permissions

- Add permission fetching and state management in Board.tsx
- Fetch user's permission level when board loads
- Set tldraw to read-only mode when user has 'view' permission
- Show AnonymousViewerBanner for unauthenticated users
- Banner prompts CryptID sign-up with your specified messaging
- Update permission state when user authenticates
- Wire up permission API routes in worker/worker.ts
  - GET /boards/:boardId/permission
  - GET /boards/:boardId/permissions (admin)
  - POST /boards/:boardId/permissions (admin)
  - DELETE /boards/:boardId/permissions/:userId (admin)
  - PATCH /boards/:boardId (admin)
- Add X-CryptID-PublicKey to CORS allowed headers
- Add PUT, PATCH, DELETE to CORS allowed methods

🤖 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-05 22:45:31 -08:00
parent 9f5befc729
commit 30608dfdc8
17 changed files with 3310 additions and 52 deletions

293
package-lock.json generated
View File

@ -24,6 +24,7 @@
"@tldraw/assets": "^3.15.4", "@tldraw/assets": "^3.15.4",
"@tldraw/tldraw": "^3.15.4", "@tldraw/tldraw": "^3.15.4",
"@tldraw/tlschema": "^3.15.4", "@tldraw/tlschema": "^3.15.4",
"@types/d3": "^7.4.3",
"@types/markdown-it": "^14.1.1", "@types/markdown-it": "^14.1.1",
"@types/marked": "^5.0.2", "@types/marked": "^5.0.2",
"@uiw/react-md-editor": "^4.0.5", "@uiw/react-md-editor": "^4.0.5",
@ -34,6 +35,7 @@
"ajv": "^8.17.1", "ajv": "^8.17.1",
"cherry-markdown": "^0.8.57", "cherry-markdown": "^0.8.57",
"cloudflare-workers-unfurl": "^0.0.7", "cloudflare-workers-unfurl": "^0.0.7",
"d3": "^7.9.0",
"fathom-typescript": "^0.0.36", "fathom-typescript": "^0.0.36",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"gun": "^0.2020.1241", "gun": "^0.2020.1241",
@ -6770,6 +6772,259 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/debug": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@ -8310,7 +8565,6 @@
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@ -8595,7 +8849,6 @@
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-array": "3", "d3-array": "3",
"d3-axis": "3", "d3-axis": "3",
@ -8637,7 +8890,6 @@
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"internmap": "1 - 2" "internmap": "1 - 2"
}, },
@ -8650,7 +8902,6 @@
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC", "license": "ISC",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -8660,7 +8911,6 @@
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-dispatch": "1 - 3", "d3-dispatch": "1 - 3",
"d3-drag": "2 - 3", "d3-drag": "2 - 3",
@ -8677,7 +8927,6 @@
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-path": "1 - 3" "d3-path": "1 - 3"
}, },
@ -8690,7 +8939,6 @@
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC", "license": "ISC",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -8700,7 +8948,6 @@
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-array": "^3.2.0" "d3-array": "^3.2.0"
}, },
@ -8713,7 +8960,6 @@
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"delaunator": "5" "delaunator": "5"
}, },
@ -8726,7 +8972,6 @@
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC", "license": "ISC",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -8736,7 +8981,6 @@
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-dispatch": "1 - 3", "d3-dispatch": "1 - 3",
"d3-selection": "3" "d3-selection": "3"
@ -8750,7 +8994,6 @@
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"commander": "7", "commander": "7",
"iconv-lite": "0.6", "iconv-lite": "0.6",
@ -8776,7 +9019,6 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
}, },
@ -8789,7 +9031,6 @@
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -8799,7 +9040,6 @@
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-dsv": "1 - 3" "d3-dsv": "1 - 3"
}, },
@ -8812,7 +9052,6 @@
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-dispatch": "1 - 3", "d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3", "d3-quadtree": "1 - 3",
@ -8827,7 +9066,6 @@
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC", "license": "ISC",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -8837,7 +9075,6 @@
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-array": "2.5.0 - 3" "d3-array": "2.5.0 - 3"
}, },
@ -8850,7 +9087,6 @@
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC", "license": "ISC",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -8860,7 +9096,6 @@
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-color": "1 - 3" "d3-color": "1 - 3"
}, },
@ -8873,7 +9108,6 @@
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC", "license": "ISC",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -8883,7 +9117,6 @@
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC", "license": "ISC",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -8893,7 +9126,6 @@
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC", "license": "ISC",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -8903,7 +9135,6 @@
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC", "license": "ISC",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -8913,7 +9144,6 @@
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-array": "2.10.0 - 3", "d3-array": "2.10.0 - 3",
"d3-format": "1 - 3", "d3-format": "1 - 3",
@ -8930,7 +9160,6 @@
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-color": "1 - 3", "d3-color": "1 - 3",
"d3-interpolate": "1 - 3" "d3-interpolate": "1 - 3"
@ -8944,7 +9173,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -8954,7 +9182,6 @@
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-path": "^3.1.0" "d3-path": "^3.1.0"
}, },
@ -8967,7 +9194,6 @@
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-array": "2 - 3" "d3-array": "2 - 3"
}, },
@ -8980,7 +9206,6 @@
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-time": "1 - 3" "d3-time": "1 - 3"
}, },
@ -8993,7 +9218,6 @@
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC", "license": "ISC",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -9003,7 +9227,6 @@
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-color": "1 - 3", "d3-color": "1 - 3",
"d3-dispatch": "1 - 3", "d3-dispatch": "1 - 3",
@ -9023,7 +9246,6 @@
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"d3-dispatch": "1 - 3", "d3-dispatch": "1 - 3",
"d3-drag": "2 - 3", "d3-drag": "2 - 3",
@ -9256,7 +9478,6 @@
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"license": "ISC", "license": "ISC",
"optional": true,
"dependencies": { "dependencies": {
"robust-predicates": "^3.0.2" "robust-predicates": "^3.0.2"
} }
@ -11170,7 +11391,6 @@
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC", "license": "ISC",
"optional": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -15581,8 +15801,7 @@
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense", "license": "Unlicense"
"optional": true
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.53.3", "version": "4.53.3",

View File

@ -41,6 +41,7 @@
"@tldraw/assets": "^3.15.4", "@tldraw/assets": "^3.15.4",
"@tldraw/tldraw": "^3.15.4", "@tldraw/tldraw": "^3.15.4",
"@tldraw/tlschema": "^3.15.4", "@tldraw/tlschema": "^3.15.4",
"@types/d3": "^7.4.3",
"@types/markdown-it": "^14.1.1", "@types/markdown-it": "^14.1.1",
"@types/marked": "^5.0.2", "@types/marked": "^5.0.2",
"@uiw/react-md-editor": "^4.0.5", "@uiw/react-md-editor": "^4.0.5",
@ -51,6 +52,7 @@
"ajv": "^8.17.1", "ajv": "^8.17.1",
"cherry-markdown": "^0.8.57", "cherry-markdown": "^0.8.57",
"cloudflare-workers-unfurl": "^0.0.7", "cloudflare-workers-unfurl": "^0.0.7",
"d3": "^7.9.0",
"fathom-typescript": "^0.0.36", "fathom-typescript": "^0.0.36",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"gun": "^0.2020.1241", "gun": "^0.2020.1241",

View File

@ -417,21 +417,40 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0 const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
// Merge server data with local data // Merge server data with local data
// Automerge handles conflict resolution automatically via CRDT // Strategy:
// 1. If local is EMPTY, use server data (bootstrap from R2)
// 2. If local HAS data, only add server records that don't exist locally
// (preserve offline changes, let Automerge CRDT sync handle conflicts)
if (serverDoc.store && serverRecordCount > 0) { if (serverDoc.store && serverRecordCount > 0) {
handle.change((doc: any) => { handle.change((doc: any) => {
// Initialize store if it doesn't exist // Initialize store if it doesn't exist
if (!doc.store) { if (!doc.store) {
doc.store = {} doc.store = {}
} }
// Merge server records - Automerge will handle conflicts
const localIsEmpty = Object.keys(doc.store).length === 0
let addedFromServer = 0
let skippedExisting = 0
Object.entries(serverDoc.store).forEach(([id, record]) => { Object.entries(serverDoc.store).forEach(([id, record]) => {
// Only add if not already present locally (local changes take precedence) if (localIsEmpty) {
// This is a simple merge strategy - Automerge's CRDT will handle deeper conflicts // Local is empty - bootstrap everything from server
if (!doc.store[id]) {
doc.store[id] = record doc.store[id] = record
addedFromServer++
} else if (!doc.store[id]) {
// Local has data but missing this record - add from server
// This handles: shapes created on another device and synced to R2
doc.store[id] = record
addedFromServer++
} else {
// Record exists locally - preserve local version
// The Automerge binary sync will handle merging conflicts via CRDT
// This preserves offline edits to existing shapes
skippedExisting++
} }
}) })
console.log(`📥 Merge strategy: local was ${localIsEmpty ? 'EMPTY' : 'populated'}, added ${addedFromServer} from server, preserved ${skippedExisting} local records`)
}) })
const finalDoc = handle.doc() const finalDoc = handle.doc()

View File

@ -0,0 +1,442 @@
/**
* NetworkGraphMinimap Component
*
* A 2D force-directed graph visualization in the bottom-right corner.
* Shows:
* - User's full network in grey
* - Room participants in their presence colors
* - Connections as edges between nodes
* - Mutual connections as thicker lines
*
* Features:
* - Click node to view profile / connect
* - Click edge to edit metadata
* - Hover for tooltips
* - Expand button to open full 3D view
*/
import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as d3 from 'd3';
import { type GraphNode, type GraphEdge, type TrustLevel, TRUST_LEVEL_COLORS } from '../../lib/networking';
import { UserSearchModal } from './UserSearchModal';
// =============================================================================
// Types
// =============================================================================
interface NetworkGraphMinimapProps {
nodes: GraphNode[];
edges: GraphEdge[];
myConnections: string[];
currentUserId?: string;
onConnect: (userId: string) => Promise<void>;
onDisconnect?: (connectionId: string) => Promise<void>;
onNodeClick?: (node: GraphNode) => void;
onEdgeClick?: (edge: GraphEdge) => void;
onExpandClick?: () => void;
width?: number;
height?: number;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
}
interface SimulationNode extends d3.SimulationNodeDatum, GraphNode {}
interface SimulationLink extends d3.SimulationLinkDatum<SimulationNode> {
id: string;
isMutual: boolean;
}
// =============================================================================
// Styles
// =============================================================================
const styles = {
container: {
position: 'fixed' as const,
bottom: '60px',
right: '10px',
zIndex: 1000,
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'flex-end',
gap: '8px',
},
panel: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderRadius: '12px',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.15)',
overflow: 'hidden',
transition: 'all 0.2s ease',
},
panelCollapsed: {
width: '48px',
height: '48px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderBottom: '1px solid rgba(0, 0, 0, 0.1)',
backgroundColor: 'rgba(0, 0, 0, 0.02)',
},
title: {
fontSize: '12px',
fontWeight: 600,
color: '#1a1a2e',
margin: 0,
},
headerButtons: {
display: 'flex',
gap: '4px',
},
iconButton: {
width: '28px',
height: '28px',
border: 'none',
background: 'none',
borderRadius: '6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
color: '#666',
transition: 'background-color 0.15s',
},
canvas: {
display: 'block',
},
tooltip: {
position: 'absolute' as const,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
color: '#fff',
padding: '6px 10px',
borderRadius: '6px',
fontSize: '12px',
pointerEvents: 'none' as const,
whiteSpace: 'nowrap' as const,
zIndex: 1001,
transform: 'translate(-50%, -100%)',
marginTop: '-8px',
},
collapsedIcon: {
fontSize: '20px',
},
stats: {
display: 'flex',
gap: '12px',
padding: '6px 12px',
borderTop: '1px solid rgba(0, 0, 0, 0.1)',
fontSize: '11px',
color: '#666',
},
stat: {
display: 'flex',
alignItems: 'center',
gap: '4px',
},
statDot: {
width: '8px',
height: '8px',
borderRadius: '50%',
},
};
// =============================================================================
// Component
// =============================================================================
export function NetworkGraphMinimap({
nodes,
edges,
myConnections,
currentUserId,
onConnect,
onDisconnect,
onNodeClick,
onEdgeClick,
onExpandClick,
width = 240,
height = 180,
isCollapsed = false,
onToggleCollapse,
}: NetworkGraphMinimapProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const simulationRef = useRef<d3.Simulation<SimulationNode, SimulationLink> | null>(null);
// Count stats
const inRoomCount = nodes.filter(n => n.isInRoom).length;
const trustedCount = nodes.filter(n => n.trustLevelTo === 'trusted').length;
const connectedCount = nodes.filter(n => n.trustLevelTo === 'connected').length;
const unconnectedCount = nodes.filter(n => !n.trustLevelTo && !n.isCurrentUser).length;
// Initialize and update the D3 simulation
useEffect(() => {
if (!svgRef.current || isCollapsed || nodes.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
// Create simulation nodes and links
const simNodes: SimulationNode[] = nodes.map(n => ({ ...n }));
const nodeMap = new Map(simNodes.map(n => [n.id, n]));
const simLinks: SimulationLink[] = edges
.filter(e => nodeMap.has(e.source) && nodeMap.has(e.target))
.map(e => ({
source: nodeMap.get(e.source)!,
target: nodeMap.get(e.target)!,
id: e.id,
isMutual: e.isMutual,
}));
// Create the simulation
const simulation = d3.forceSimulation<SimulationNode>(simNodes)
.force('link', d3.forceLink<SimulationNode, SimulationLink>(simLinks)
.id(d => d.id)
.distance(40))
.force('charge', d3.forceManyBody().strength(-80))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(12));
simulationRef.current = simulation;
// Create container group
const g = svg.append('g');
// Helper to get edge color based on trust level
const getEdgeColor = (d: SimulationLink) => {
const edge = edges.find(e => e.id === d.id);
if (!edge) return 'rgba(0, 0, 0, 0.15)';
// Use effective trust level for mutual connections, otherwise the edge's trust level
const level = edge.effectiveTrustLevel || edge.trustLevel;
if (level === 'trusted') {
return 'rgba(34, 197, 94, 0.6)'; // green
} else if (level === 'connected') {
return 'rgba(234, 179, 8, 0.6)'; // yellow
}
return 'rgba(0, 0, 0, 0.15)';
};
// Create edges
const link = g.append('g')
.attr('class', 'links')
.selectAll('line')
.data(simLinks)
.join('line')
.attr('stroke', d => getEdgeColor(d))
.attr('stroke-width', d => d.isMutual ? 2.5 : 1.5)
.style('cursor', 'pointer')
.on('click', (event, d) => {
event.stopPropagation();
const edge = edges.find(e => e.id === d.id);
if (edge && onEdgeClick) {
onEdgeClick(edge);
}
});
// Helper to get node color based on trust level and room status
const getNodeColor = (d: SimulationNode) => {
if (d.isCurrentUser) {
return '#4f46e5'; // Current user is always purple
}
// If in room, use presence color
if (d.isInRoom && d.roomPresenceColor) {
return d.roomPresenceColor;
}
// Otherwise use trust level color
if (d.trustLevelTo) {
return TRUST_LEVEL_COLORS[d.trustLevelTo];
}
// Unconnected
return TRUST_LEVEL_COLORS.unconnected;
};
// Create nodes
const node = g.append('g')
.attr('class', 'nodes')
.selectAll('circle')
.data(simNodes)
.join('circle')
.attr('r', d => d.isCurrentUser ? 8 : 6)
.attr('fill', d => getNodeColor(d))
.attr('stroke', d => d.isCurrentUser ? '#fff' : 'none')
.attr('stroke-width', d => d.isCurrentUser ? 2 : 0)
.style('cursor', 'pointer')
.on('mouseenter', (event, d) => {
const rect = svgRef.current!.getBoundingClientRect();
setTooltip({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
text: d.displayName || d.username,
});
})
.on('mouseleave', () => {
setTooltip(null);
})
.on('click', (event, d) => {
event.stopPropagation();
if (onNodeClick) {
onNodeClick(d);
}
})
.call(d3.drag<SVGCircleElement, SimulationNode>()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}) as any);
// Update positions on tick
simulation.on('tick', () => {
link
.attr('x1', d => (d.source as SimulationNode).x!)
.attr('y1', d => (d.source as SimulationNode).y!)
.attr('x2', d => (d.target as SimulationNode).x!)
.attr('y2', d => (d.target as SimulationNode).y!);
node
.attr('cx', d => Math.max(8, Math.min(width - 8, d.x!)))
.attr('cy', d => Math.max(8, Math.min(height - 8, d.y!)));
});
return () => {
simulation.stop();
};
}, [nodes, edges, width, height, isCollapsed, onNodeClick, onEdgeClick]);
// Handle collapsed state click
const handleCollapsedClick = useCallback(() => {
if (onToggleCollapse) {
onToggleCollapse();
}
}, [onToggleCollapse]);
if (isCollapsed) {
return (
<div style={styles.container}>
<div
style={{ ...styles.panel, ...styles.panelCollapsed }}
onClick={handleCollapsedClick}
title="Show network graph"
>
<span style={styles.collapsedIcon}>🕸</span>
</div>
</div>
);
}
return (
<div style={styles.container}>
<div style={styles.panel}>
<div style={styles.header}>
<h3 style={styles.title}>Network</h3>
<div style={styles.headerButtons}>
<button
style={styles.iconButton}
onClick={() => setIsSearchOpen(true)}
title="Find people"
>
🔍
</button>
{onExpandClick && (
<button
style={styles.iconButton}
onClick={onExpandClick}
title="Open full view"
>
</button>
)}
{onToggleCollapse && (
<button
style={styles.iconButton}
onClick={onToggleCollapse}
title="Collapse"
>
</button>
)}
</div>
</div>
<div style={{ position: 'relative' }}>
<svg
ref={svgRef}
width={width}
height={height}
style={styles.canvas}
/>
{tooltip && (
<div
style={{
...styles.tooltip,
left: tooltip.x,
top: tooltip.y,
}}
>
{tooltip.text}
</div>
)}
</div>
<div style={styles.stats}>
<div style={styles.stat} title="Users in this room">
<div style={{ ...styles.statDot, backgroundColor: '#4f46e5' }} />
<span>{inRoomCount}</span>
</div>
<div style={styles.stat} title="Trusted (edit access)">
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.trusted }} />
<span>{trustedCount}</span>
</div>
<div style={styles.stat} title="Connected (view access)">
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.connected }} />
<span>{connectedCount}</span>
</div>
<div style={styles.stat} title="Unconnected (no access)">
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.unconnected }} />
<span>{unconnectedCount}</span>
</div>
</div>
</div>
<UserSearchModal
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
onConnect={onConnect}
onDisconnect={onDisconnect ? (userId) => {
// Find the connection ID for this user
const edge = edges.find(e =>
(e.source === currentUserId && e.target === userId) ||
(e.target === currentUserId && e.source === userId)
);
if (edge && onDisconnect) {
return onDisconnect(edge.id);
}
return Promise.resolve();
} : undefined}
currentUserId={currentUserId}
/>
</div>
);
}
export default NetworkGraphMinimap;

View File

@ -0,0 +1,154 @@
/**
* NetworkGraphPanel Component
*
* Wrapper that integrates the NetworkGraphMinimap with tldraw.
* Extracts room participants from the editor and provides connection actions.
*/
import React, { useState, useCallback, useMemo } from 'react';
import { useEditor, useValue } from 'tldraw';
import { NetworkGraphMinimap } from './NetworkGraphMinimap';
import { useNetworkGraph } from './useNetworkGraph';
import { useSession } from '../../context/AuthContext';
import type { GraphEdge, TrustLevel } from '../../lib/networking';
// =============================================================================
// Types
// =============================================================================
interface NetworkGraphPanelProps {
onExpand?: () => void;
}
// =============================================================================
// Component
// =============================================================================
export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
const editor = useEditor();
const { session } = useSession();
const [isCollapsed, setIsCollapsed] = useState(false);
const [selectedEdge, setSelectedEdge] = useState<GraphEdge | null>(null);
// Get collaborators from tldraw
const collaborators = useValue(
'collaborators',
() => editor.getCollaborators(),
[editor]
);
const myColor = useValue('myColor', () => editor.user.getColor(), [editor]);
const myName = useValue('myName', () => editor.user.getName() || 'Anonymous', [editor]);
// Convert collaborators to room participants format
const roomParticipants = useMemo(() => {
// Add current user
const participants = [
{
id: session.username || 'me', // Use CryptID username if available
username: myName,
color: myColor,
},
];
// Add collaborators
collaborators.forEach((c: any) => {
participants.push({
id: c.id || c.userId || c.instanceId,
username: c.userName || 'Anonymous',
color: c.color,
});
});
return participants;
}, [session.username, myName, myColor, collaborators]);
// Use the network graph hook
const {
nodes,
edges,
myConnections,
isLoading,
error,
connect,
disconnect,
} = useNetworkGraph({
roomParticipants,
refreshInterval: 30000, // Refresh every 30 seconds
useCache: true,
});
// Handle connect with default trust level
const handleConnect = useCallback(async (userId: string) => {
await connect(userId);
}, [connect]);
// Handle disconnect
const handleDisconnect = useCallback(async (connectionId: string) => {
await disconnect(connectionId);
}, [disconnect]);
// Handle node click
const handleNodeClick = useCallback((node: any) => {
// Could open a profile modal or navigate to user
console.log('Node clicked:', node);
}, []);
// Handle edge click
const handleEdgeClick = useCallback((edge: GraphEdge) => {
setSelectedEdge(edge);
// Could open an edge metadata editor modal
console.log('Edge clicked:', edge);
}, []);
// Handle expand to full 3D view
const handleExpand = useCallback(() => {
if (onExpand) {
onExpand();
} else {
// Default: open in new tab
window.open('/graph', '_blank');
}
}, [onExpand]);
// Don't render if not authenticated
if (!session.authed) {
return null;
}
// Show loading state briefly
if (isLoading && nodes.length === 0) {
return (
<div style={{
position: 'fixed',
bottom: '60px',
right: '10px',
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.15)',
}}>
Loading network...
</div>
);
}
return (
<NetworkGraphMinimap
nodes={nodes}
edges={edges}
myConnections={myConnections}
currentUserId={session.username}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onExpandClick={handleExpand}
isCollapsed={isCollapsed}
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
/>
);
}
export default NetworkGraphPanel;

View File

@ -0,0 +1,374 @@
/**
* UserSearchModal Component
*
* Modal for searching and connecting with other users.
* Features:
* - Fuzzy search by username/display name
* - Shows connection status
* - One-click connect/disconnect
* - Shows mutual connections count
*/
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { searchUsers, type UserSearchResult } from '../../lib/networking';
// =============================================================================
// Types
// =============================================================================
interface UserSearchModalProps {
isOpen: boolean;
onClose: () => void;
onConnect: (userId: string) => Promise<void>;
onDisconnect?: (userId: string) => Promise<void>;
currentUserId?: string;
}
// =============================================================================
// Styles
// =============================================================================
const styles = {
overlay: {
position: 'fixed' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
},
modal: {
backgroundColor: 'var(--color-background, #fff)',
borderRadius: '12px',
width: '90%',
maxWidth: '480px',
maxHeight: '70vh',
display: 'flex',
flexDirection: 'column' as const,
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.2)',
overflow: 'hidden',
},
header: {
padding: '16px 20px',
borderBottom: '1px solid var(--color-border, #e0e0e0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
fontSize: '18px',
fontWeight: 600,
margin: 0,
color: 'var(--color-text, #1a1a2e)',
},
closeButton: {
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: 'var(--color-text-secondary, #666)',
padding: '4px',
lineHeight: 1,
},
searchContainer: {
padding: '16px 20px',
borderBottom: '1px solid var(--color-border, #e0e0e0)',
},
searchInput: {
width: '100%',
padding: '12px 16px',
fontSize: '16px',
border: '1px solid var(--color-border, #e0e0e0)',
borderRadius: '8px',
outline: 'none',
backgroundColor: 'var(--color-surface, #f5f5f5)',
color: 'var(--color-text, #1a1a2e)',
},
results: {
flex: 1,
overflowY: 'auto' as const,
padding: '8px 0',
},
resultItem: {
display: 'flex',
alignItems: 'center',
padding: '12px 20px',
gap: '12px',
cursor: 'pointer',
transition: 'background-color 0.15s',
},
resultItemHover: {
backgroundColor: 'var(--color-surface, #f5f5f5)',
},
avatar: {
width: '40px',
height: '40px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '16px',
fontWeight: 600,
color: '#fff',
flexShrink: 0,
},
userInfo: {
flex: 1,
minWidth: 0,
},
username: {
fontSize: '15px',
fontWeight: 500,
color: 'var(--color-text, #1a1a2e)',
whiteSpace: 'nowrap' as const,
overflow: 'hidden',
textOverflow: 'ellipsis',
},
displayName: {
fontSize: '13px',
color: 'var(--color-text-secondary, #666)',
whiteSpace: 'nowrap' as const,
overflow: 'hidden',
textOverflow: 'ellipsis',
},
mutualBadge: {
fontSize: '11px',
color: 'var(--color-text-tertiary, #999)',
marginTop: '2px',
},
connectButton: {
padding: '8px 16px',
fontSize: '13px',
fontWeight: 500,
borderRadius: '6px',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s',
flexShrink: 0,
},
connectButtonConnect: {
backgroundColor: 'var(--color-primary, #4f46e5)',
color: '#fff',
},
connectButtonConnected: {
backgroundColor: 'var(--color-success, #22c55e)',
color: '#fff',
},
connectButtonMutual: {
backgroundColor: 'var(--color-accent, #8b5cf6)',
color: '#fff',
},
emptyState: {
padding: '40px 20px',
textAlign: 'center' as const,
color: 'var(--color-text-secondary, #666)',
},
loadingState: {
padding: '40px 20px',
textAlign: 'center' as const,
color: 'var(--color-text-secondary, #666)',
},
};
// =============================================================================
// Component
// =============================================================================
export function UserSearchModal({
isOpen,
onClose,
onConnect,
onDisconnect,
currentUserId,
}: UserSearchModalProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<UserSearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [connectingId, setConnectingId] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Focus input when modal opens
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
// Debounced search
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
if (query.length < 2) {
setResults([]);
return;
}
searchTimeoutRef.current = setTimeout(async () => {
setIsLoading(true);
try {
const users = await searchUsers(query);
// Filter out current user
setResults(users.filter(u => u.id !== currentUserId));
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsLoading(false);
}
}, 300);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [query, currentUserId]);
// Handle connect/disconnect
const handleConnect = useCallback(async (user: UserSearchResult) => {
setConnectingId(user.id);
try {
if (user.isConnected && onDisconnect) {
await onDisconnect(user.id);
// Update local state
setResults(prev => prev.map(u =>
u.id === user.id ? { ...u, isConnected: false } : u
));
} else {
await onConnect(user.id);
// Update local state
setResults(prev => prev.map(u =>
u.id === user.id ? { ...u, isConnected: true } : u
));
}
} catch (error) {
console.error('Connection action failed:', error);
} finally {
setConnectingId(null);
}
}, [onConnect, onDisconnect]);
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, onClose]);
if (!isOpen) return null;
const getButtonStyle = (user: UserSearchResult) => {
if (user.isConnected && user.isConnectedBack) {
return { ...styles.connectButton, ...styles.connectButtonMutual };
}
if (user.isConnected) {
return { ...styles.connectButton, ...styles.connectButtonConnected };
}
return { ...styles.connectButton, ...styles.connectButtonConnect };
};
const getButtonText = (user: UserSearchResult) => {
if (connectingId === user.id) return '...';
if (user.isConnected && user.isConnectedBack) return 'Mutual';
if (user.isConnected) return 'Connected';
return 'Connect';
};
return (
<div style={styles.overlay} onClick={onClose}>
<div style={styles.modal} onClick={e => e.stopPropagation()}>
<div style={styles.header}>
<h2 style={styles.title}>Find People</h2>
<button style={styles.closeButton} onClick={onClose}>
&times;
</button>
</div>
<div style={styles.searchContainer}>
<input
ref={inputRef}
type="text"
placeholder="Search by username..."
value={query}
onChange={e => setQuery(e.target.value)}
style={styles.searchInput}
/>
</div>
<div style={styles.results}>
{isLoading ? (
<div style={styles.loadingState}>Searching...</div>
) : results.length === 0 ? (
<div style={styles.emptyState}>
{query.length < 2
? 'Type at least 2 characters to search'
: 'No users found'
}
</div>
) : (
results.map(user => (
<div
key={user.id}
style={{
...styles.resultItem,
...(hoveredId === user.id ? styles.resultItemHover : {}),
}}
onMouseEnter={() => setHoveredId(user.id)}
onMouseLeave={() => setHoveredId(null)}
>
<div
style={{
...styles.avatar,
backgroundColor: user.avatarColor || '#6366f1',
}}
>
{(user.displayName || user.username).charAt(0).toUpperCase()}
</div>
<div style={styles.userInfo}>
<div style={styles.username}>@{user.username}</div>
{user.displayName && user.displayName !== user.username && (
<div style={styles.displayName}>{user.displayName}</div>
)}
{user.isConnectedBack && !user.isConnected && (
<div style={styles.mutualBadge}>Follows you</div>
)}
{user.mutualConnections > 0 && (
<div style={styles.mutualBadge}>
{user.mutualConnections} mutual connection{user.mutualConnections !== 1 ? 's' : ''}
</div>
)}
</div>
<button
style={getButtonStyle(user)}
onClick={() => handleConnect(user)}
disabled={connectingId === user.id}
>
{getButtonText(user)}
</button>
</div>
))
)}
</div>
</div>
</div>
);
}
export default UserSearchModal;

View File

@ -0,0 +1,11 @@
/**
* Networking Components
*
* UI components for user networking and social graph visualization.
*/
export { NetworkGraphMinimap } from './NetworkGraphMinimap';
export { NetworkGraphPanel } from './NetworkGraphPanel';
export { UserSearchModal } from './UserSearchModal';
export { useNetworkGraph, useRoomParticipantsFromEditor } from './useNetworkGraph';
export type { RoomParticipant, NetworkGraphState, UseNetworkGraphOptions, UseNetworkGraphReturn } from './useNetworkGraph';

View File

@ -0,0 +1,321 @@
/**
* useNetworkGraph Hook
*
* Manages the network graph state for visualization:
* - Fetches user's network from the API
* - Integrates with room presence to mark active participants
* - Provides real-time updates when connections change
* - Caches graph for fast loading
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useSession } from '../../context/AuthContext';
import {
getMyNetworkGraph,
getRoomNetworkGraph,
createConnection,
removeConnection,
getCachedGraph,
setCachedGraph,
clearGraphCache,
type NetworkGraph,
type GraphNode,
type GraphEdge,
} from '../../lib/networking';
// =============================================================================
// Types
// =============================================================================
export interface RoomParticipant {
id: string;
username: string;
color: string; // Presence color from tldraw
}
export interface NetworkGraphState {
nodes: GraphNode[];
edges: GraphEdge[];
myConnections: string[];
isLoading: boolean;
error: string | null;
}
export interface UseNetworkGraphOptions {
// Room participants to highlight (from tldraw presence)
roomParticipants?: RoomParticipant[];
// Auto-refresh interval (ms), 0 to disable
refreshInterval?: number;
// Whether to use cached data initially
useCache?: boolean;
}
export interface UseNetworkGraphReturn extends NetworkGraphState {
// Refresh the graph from the server
refresh: () => Promise<void>;
// Connect to a user
connect: (userId: string) => Promise<void>;
// Disconnect from a user
disconnect: (connectionId: string) => Promise<void>;
// Check if connected to a user
isConnectedTo: (userId: string) => boolean;
// Get node by ID
getNode: (userId: string) => GraphNode | undefined;
// Get edges for a node
getEdgesForNode: (userId: string) => GraphEdge[];
// Nodes that are in the current room
roomNodes: GraphNode[];
// Nodes that are not in the room (shown in grey)
networkNodes: GraphNode[];
}
// =============================================================================
// Hook Implementation
// =============================================================================
export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetworkGraphReturn {
const {
roomParticipants = [],
refreshInterval = 0,
useCache = true,
} = options;
const { session } = useSession();
const [state, setState] = useState<NetworkGraphState>({
nodes: [],
edges: [],
myConnections: [],
isLoading: true,
error: null,
});
// Create a map of room participant IDs to their colors
const participantColorMap = useMemo(() => {
const map = new Map<string, string>();
roomParticipants.forEach(p => map.set(p.id, p.color));
return map;
}, [roomParticipants]);
const participantIds = useMemo(() =>
roomParticipants.map(p => p.id),
[roomParticipants]
);
// Fetch the network graph
const fetchGraph = useCallback(async (skipCache = false) => {
if (!session.authed || !session.username) {
setState(prev => ({
...prev,
isLoading: false,
error: 'Not authenticated',
}));
return;
}
// Try cache first
if (useCache && !skipCache) {
const cached = getCachedGraph();
if (cached) {
setState(prev => ({
...prev,
nodes: cached.nodes.map(n => ({
...n,
isInRoom: participantIds.includes(n.id),
roomPresenceColor: participantColorMap.get(n.id),
isCurrentUser: n.username === session.username,
})),
edges: cached.edges,
myConnections: (cached as any).myConnections || [],
isLoading: false,
error: null,
}));
// Still fetch in background to update
}
}
try {
setState(prev => ({ ...prev, isLoading: !prev.nodes.length }));
// Fetch graph, optionally scoped to room
let graph: NetworkGraph;
if (participantIds.length > 0) {
graph = await getRoomNetworkGraph(participantIds);
} else {
graph = await getMyNetworkGraph();
}
// Enrich nodes with room status and current user flag
const enrichedNodes = graph.nodes.map(node => ({
...node,
isInRoom: participantIds.includes(node.id),
roomPresenceColor: participantColorMap.get(node.id),
isCurrentUser: node.username === session.username,
}));
setState({
nodes: enrichedNodes,
edges: graph.edges,
myConnections: graph.myConnections,
isLoading: false,
error: null,
});
// Cache the result
setCachedGraph(graph);
} catch (error) {
setState(prev => ({
...prev,
isLoading: false,
error: (error as Error).message,
}));
}
}, [session.authed, session.username, participantIds, participantColorMap, useCache]);
// Initial fetch
useEffect(() => {
fetchGraph();
}, [fetchGraph]);
// Refresh interval
useEffect(() => {
if (refreshInterval > 0) {
const interval = setInterval(() => fetchGraph(true), refreshInterval);
return () => clearInterval(interval);
}
}, [refreshInterval, fetchGraph]);
// Update room status when participants change
useEffect(() => {
setState(prev => ({
...prev,
nodes: prev.nodes.map(node => ({
...node,
isInRoom: participantIds.includes(node.id),
roomPresenceColor: participantColorMap.get(node.id),
})),
}));
}, [participantIds, participantColorMap]);
// Connect to a user
const connect = useCallback(async (userId: string) => {
try {
await createConnection(userId);
// Refresh the graph to get updated state
await fetchGraph(true);
clearGraphCache();
} catch (error) {
setState(prev => ({
...prev,
error: (error as Error).message,
}));
throw error;
}
}, [fetchGraph]);
// Disconnect from a user
const disconnect = useCallback(async (connectionId: string) => {
try {
await removeConnection(connectionId);
// Refresh the graph to get updated state
await fetchGraph(true);
clearGraphCache();
} catch (error) {
setState(prev => ({
...prev,
error: (error as Error).message,
}));
throw error;
}
}, [fetchGraph]);
// Check if connected to a user
const isConnectedTo = useCallback((userId: string) => {
return state.myConnections.includes(userId);
}, [state.myConnections]);
// Get node by ID
const getNode = useCallback((userId: string) => {
return state.nodes.find(n => n.id === userId);
}, [state.nodes]);
// Get edges for a node
const getEdgesForNode = useCallback((userId: string) => {
return state.edges.filter(e => e.source === userId || e.target === userId);
}, [state.edges]);
// Split nodes into room vs network
const roomNodes = useMemo(() =>
state.nodes.filter(n => n.isInRoom),
[state.nodes]
);
const networkNodes = useMemo(() =>
state.nodes.filter(n => !n.isInRoom),
[state.nodes]
);
return {
...state,
refresh: () => fetchGraph(true),
connect,
disconnect,
isConnectedTo,
getNode,
getEdgesForNode,
roomNodes,
networkNodes,
};
}
// =============================================================================
// Helper Hook: Extract room participants from tldraw editor
// =============================================================================
/**
* Extract room participants from tldraw collaborators
* Use this to get the roomParticipants for useNetworkGraph
*/
export function useRoomParticipantsFromEditor(editor: any): RoomParticipant[] {
const [participants, setParticipants] = useState<RoomParticipant[]>([]);
useEffect(() => {
if (!editor) return;
const updateParticipants = () => {
try {
const collaborators = editor.getCollaborators();
const currentUser = editor.user;
const ps: RoomParticipant[] = [
// Add current user
{
id: currentUser.getId(),
username: currentUser.getName(),
color: currentUser.getColor(),
},
// Add collaborators
...collaborators.map((c: any) => ({
id: c.id || c.instanceId,
username: c.userName || 'Anonymous',
color: c.color,
})),
];
setParticipants(ps);
} catch (e) {
console.warn('Failed to get collaborators:', e);
}
};
// Initial update
updateParticipants();
// Listen for changes
// Note: tldraw doesn't have a great event for this, so we poll
const interval = setInterval(updateParticipants, 2000);
return () => clearInterval(interval);
}, [editor]);
return participants;
}

View File

@ -0,0 +1,334 @@
/**
* Connection Service
*
* Client-side API for user networking features:
* - User search
* - Connection management (follow/unfollow)
* - Edge metadata (labels, notes, colors)
* - Network graph retrieval
*/
import type {
UserProfile,
UserSearchResult,
Connection,
ConnectionWithMetadata,
EdgeMetadata,
NetworkGraph,
GraphNode,
GraphEdge,
TrustLevel,
} from './types';
// =============================================================================
// Configuration
// =============================================================================
const API_BASE = '/api/networking';
// =============================================================================
// Helper Functions
// =============================================================================
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
function generateId(): string {
return crypto.randomUUID();
}
// =============================================================================
// User Search
// =============================================================================
/**
* Search for users by username or display name
*/
export async function searchUsers(
query: string,
limit: number = 20
): Promise<UserSearchResult[]> {
const params = new URLSearchParams({ q: query, limit: String(limit) });
return fetchJson<UserSearchResult[]>(`${API_BASE}/users/search?${params}`);
}
/**
* Get a user's public profile
*/
export async function getUserProfile(userId: string): Promise<UserProfile | null> {
try {
return await fetchJson<UserProfile>(`${API_BASE}/users/${userId}`);
} catch (error) {
if ((error as Error).message.includes('404')) {
return null;
}
throw error;
}
}
/**
* Update current user's profile
*/
export async function updateMyProfile(updates: Partial<{
displayName: string;
bio: string;
avatarColor: string;
}>): Promise<UserProfile> {
return fetchJson<UserProfile>(`${API_BASE}/users/me`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
// =============================================================================
// Connection Management
// =============================================================================
/**
* Create a connection (follow a user)
* @param toUserId - The user to connect to
* @param trustLevel - 'connected' (yellow, view) or 'trusted' (green, edit)
*/
export async function createConnection(
toUserId: string,
trustLevel: TrustLevel = 'connected'
): Promise<Connection> {
return fetchJson<Connection>(`${API_BASE}/connections`, {
method: 'POST',
body: JSON.stringify({ toUserId, trustLevel }),
});
}
/**
* Update trust level for an existing connection
*/
export async function updateTrustLevel(
connectionId: string,
trustLevel: TrustLevel
): Promise<Connection> {
return fetchJson<Connection>(`${API_BASE}/connections/${connectionId}/trust`, {
method: 'PUT',
body: JSON.stringify({ trustLevel }),
});
}
/**
* Remove a connection (unfollow a user)
*/
export async function removeConnection(connectionId: string): Promise<void> {
await fetch(`${API_BASE}/connections/${connectionId}`, {
method: 'DELETE',
});
}
/**
* Get a specific connection by ID
*/
export async function getConnection(connectionId: string): Promise<ConnectionWithMetadata | null> {
try {
return await fetchJson<ConnectionWithMetadata>(`${API_BASE}/connections/${connectionId}`);
} catch (error) {
if ((error as Error).message.includes('404')) {
return null;
}
throw error;
}
}
/**
* Get all connections for current user
*/
export async function getMyConnections(): Promise<ConnectionWithMetadata[]> {
return fetchJson<ConnectionWithMetadata[]>(`${API_BASE}/connections`);
}
/**
* Get users who are connected to current user (followers)
*/
export async function getMyFollowers(): Promise<Connection[]> {
return fetchJson<Connection[]>(`${API_BASE}/connections/followers`);
}
/**
* Check if current user is connected to a specific user
*/
export async function isConnectedTo(userId: string): Promise<boolean> {
try {
const result = await fetchJson<{ connected: boolean }>(
`${API_BASE}/connections/check/${userId}`
);
return result.connected;
} catch {
return false;
}
}
// =============================================================================
// Edge Metadata
// =============================================================================
/**
* Update metadata for a connection edge
*/
export async function updateEdgeMetadata(
connectionId: string,
metadata: Partial<EdgeMetadata>
): Promise<EdgeMetadata> {
return fetchJson<EdgeMetadata>(`${API_BASE}/connections/${connectionId}/metadata`, {
method: 'PUT',
body: JSON.stringify(metadata),
});
}
/**
* Get metadata for a connection edge
*/
export async function getEdgeMetadata(connectionId: string): Promise<EdgeMetadata | null> {
try {
return await fetchJson<EdgeMetadata>(`${API_BASE}/connections/${connectionId}/metadata`);
} catch (error) {
if ((error as Error).message.includes('404')) {
return null;
}
throw error;
}
}
// =============================================================================
// Network Graph
// =============================================================================
/**
* Get the full network graph for current user
*/
export async function getMyNetworkGraph(): Promise<NetworkGraph> {
return fetchJson<NetworkGraph>(`${API_BASE}/graph`);
}
/**
* Get network graph scoped to room participants
* Returns full network in grey, room participants colored
*/
export async function getRoomNetworkGraph(
roomParticipants: string[]
): Promise<NetworkGraph> {
return fetchJson<NetworkGraph>(`${API_BASE}/graph/room`, {
method: 'POST',
body: JSON.stringify({ participants: roomParticipants }),
});
}
/**
* Get mutual connections between current user and another user
*/
export async function getMutualConnections(userId: string): Promise<UserProfile[]> {
return fetchJson<UserProfile[]>(`${API_BASE}/connections/mutual/${userId}`);
}
// =============================================================================
// Graph Building Helpers (Client-side)
// =============================================================================
/**
* Build a GraphNode from a UserProfile and room state
*/
export function buildGraphNode(
profile: UserProfile,
options: {
isInRoom: boolean;
roomPresenceColor?: string;
isCurrentUser: boolean;
}
): GraphNode {
return {
id: profile.id,
username: profile.username,
displayName: profile.displayName,
avatarColor: profile.avatarColor,
isInRoom: options.isInRoom,
roomPresenceColor: options.roomPresenceColor,
isCurrentUser: options.isCurrentUser,
};
}
/**
* Build a GraphEdge from a Connection
*/
export function buildGraphEdge(
connection: ConnectionWithMetadata,
currentUserId: string
): GraphEdge {
const isOnEdge = connection.fromUserId === currentUserId || connection.toUserId === currentUserId;
return {
id: connection.id,
source: connection.fromUserId,
target: connection.toUserId,
isMutual: connection.isMutual,
metadata: isOnEdge ? connection.metadata : undefined,
isVisible: true,
};
}
// =============================================================================
// Local Storage Cache (for offline/fast loading)
// =============================================================================
const CACHE_KEY = 'network_graph_cache';
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
interface CachedGraph {
graph: NetworkGraph;
timestamp: number;
}
export function getCachedGraph(): NetworkGraph | null {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return null;
const { graph, timestamp }: CachedGraph = JSON.parse(cached);
if (Date.now() - timestamp > CACHE_TTL) {
localStorage.removeItem(CACHE_KEY);
return null;
}
return graph;
} catch {
return null;
}
}
export function setCachedGraph(graph: NetworkGraph): void {
try {
const cached: CachedGraph = {
graph,
timestamp: Date.now(),
};
localStorage.setItem(CACHE_KEY, JSON.stringify(cached));
} catch {
// Ignore storage errors
}
}
export function clearGraphCache(): void {
try {
localStorage.removeItem(CACHE_KEY);
} catch {
// Ignore
}
}

View File

@ -0,0 +1,58 @@
/**
* User Networking Module
*
* Provides social graph functionality for the canvas:
* - User search by username
* - One-way connections (following)
* - Private edge metadata (labels, notes, colors)
* - Network graph visualization
*/
// Types
export type {
UserProfile,
UserSearchResult,
Connection,
ConnectionWithMetadata,
EdgeMetadata,
GraphNode,
GraphEdge,
NetworkGraph,
RoomNetworkGraph,
NetworkEvent,
NetworkEventType,
TrustLevel,
} from './types';
// Constants
export { TRUST_LEVEL_COLORS, TRUST_LEVEL_PERMISSIONS } from './types';
// Connection Service API
export {
// User search
searchUsers,
getUserProfile,
updateMyProfile,
// Connection management
createConnection,
updateTrustLevel,
removeConnection,
getConnection,
getMyConnections,
getMyFollowers,
isConnectedTo,
// Edge metadata
updateEdgeMetadata,
getEdgeMetadata,
// Network graph
getMyNetworkGraph,
getRoomNetworkGraph,
getMutualConnections,
// Graph building helpers
buildGraphNode,
buildGraphEdge,
// Cache
getCachedGraph,
setCachedGraph,
clearGraphCache,
} from './connectionService';

234
src/lib/networking/types.ts Normal file
View File

@ -0,0 +1,234 @@
/**
* User Networking / Social Graph Types
*
* These types are used for the client-side networking features:
* - User search and profiles
* - One-way connections (following)
* - Edge metadata (private labels/notes on connections)
* - Network graph visualization
*/
// =============================================================================
// User Profile Types
// =============================================================================
/**
* Public user profile for search results and graph nodes
*/
export interface UserProfile {
id: string;
username: string;
displayName: string | null;
avatarColor: string | null;
bio: string | null;
}
/**
* Extended profile with connection status (for search results)
*/
export interface UserSearchResult extends UserProfile {
isConnected: boolean; // Am I following them?
isConnectedBack: boolean; // Are they following me?
mutualConnections: number; // Count of shared connections
}
// =============================================================================
// Trust Levels
// =============================================================================
/**
* Trust levels for connections:
* - 'connected': Yellow, grants view permission on shared data
* - 'trusted': Green, grants edit permission on shared data
*
* Unconnected users (grey) have no permissions.
* The user themselves has admin access.
*/
export type TrustLevel = 'connected' | 'trusted';
/**
* Color mapping for trust levels
*/
export const TRUST_LEVEL_COLORS: Record<TrustLevel | 'unconnected', string> = {
unconnected: '#9ca3af', // grey
connected: '#eab308', // yellow
trusted: '#22c55e', // green
};
/**
* Permission mapping for trust levels
*/
export const TRUST_LEVEL_PERMISSIONS: Record<TrustLevel | 'unconnected', 'none' | 'view' | 'edit'> = {
unconnected: 'none',
connected: 'view',
trusted: 'edit',
};
// =============================================================================
// Connection Types
// =============================================================================
/**
* A one-way connection from one user to another
*/
export interface Connection {
id: string;
fromUserId: string;
toUserId: string;
trustLevel: TrustLevel;
createdAt: string;
isMutual: boolean; // True if both users follow each other
// The highest trust level between both directions (if mutual)
effectiveTrustLevel: TrustLevel | null;
}
/**
* Private metadata for a connection edge
* Each party on an edge can have their own metadata
*/
export interface EdgeMetadata {
label: string | null; // Short label (e.g., "Met at ETHDenver")
notes: string | null; // Private notes
color: string | null; // Custom edge color (hex)
strength: number; // 1-10 connection strength
}
/**
* Full connection with optional metadata
*/
export interface ConnectionWithMetadata extends Connection {
metadata?: EdgeMetadata;
}
// =============================================================================
// Graph Types (for visualization)
// =============================================================================
/**
* Node in the network graph
*/
export interface GraphNode {
id: string;
username: string;
displayName: string | null;
avatarColor: string | null;
// Connection state (from current user's perspective)
trustLevelTo?: TrustLevel; // Trust level I've granted to this user
trustLevelFrom?: TrustLevel; // Trust level they've granted to me
// Visualization state
isInRoom: boolean; // Currently in the same room
roomPresenceColor?: string; // Color from room presence (if in room)
isCurrentUser: boolean; // Is this the logged-in user
}
/**
* Edge in the network graph
*/
export interface GraphEdge {
id: string;
source: string; // from user ID
target: string; // to user ID
trustLevel: TrustLevel; // Trust level of this direction
isMutual: boolean; // Both directions exist
// The highest trust level between both directions (if mutual)
effectiveTrustLevel: TrustLevel | null;
// Only included if current user is on this edge
metadata?: EdgeMetadata;
// Visualization state
isVisible: boolean; // Should be rendered (based on privacy)
}
/**
* Complete network graph for visualization
*/
export interface NetworkGraph {
nodes: GraphNode[];
edges: GraphEdge[];
}
/**
* Room-scoped graph (subset of network that's in current room)
*/
export interface RoomNetworkGraph extends NetworkGraph {
roomId: string;
// All room participants (even if not in your network)
roomParticipants: string[];
}
// =============================================================================
// API Request/Response Types
// =============================================================================
export interface SearchUsersRequest {
query: string;
limit?: number;
}
export interface SearchUsersResponse {
users: UserSearchResult[];
total: number;
}
export interface CreateConnectionRequest {
toUserId: string;
}
export interface CreateConnectionResponse {
connection: Connection;
}
export interface UpdateEdgeMetadataRequest {
connectionId: string;
metadata: Partial<EdgeMetadata>;
}
export interface GetNetworkGraphRequest {
userId?: string; // If not provided, returns current user's network
roomParticipants?: string[]; // If provided, marks which nodes are in room
}
export interface GetNetworkGraphResponse {
graph: NetworkGraph;
myConnections: string[]; // User IDs I'm connected to
}
// =============================================================================
// Real-time Events
// =============================================================================
export type NetworkEventType =
| 'connection:created'
| 'connection:removed'
| 'metadata:updated'
| 'user:joined_room'
| 'user:left_room';
export interface NetworkEvent {
type: NetworkEventType;
payload: unknown;
timestamp: number;
}
export interface ConnectionCreatedEvent {
type: 'connection:created';
payload: {
connection: Connection;
};
}
export interface ConnectionRemovedEvent {
type: 'connection:removed';
payload: {
connectionId: string;
fromUserId: string;
toUserId: string;
};
}
export interface MetadataUpdatedEvent {
type: 'metadata:updated';
payload: {
connectionId: string;
userId: string; // Who updated their metadata
};
}

View File

@ -72,7 +72,9 @@ import { GestureTool } from "@/GestureTool"
import { CmdK } from "@/CmdK" import { CmdK } from "@/CmdK"
import { setupMultiPasteHandler } from "@/utils/multiPasteHandler" import { setupMultiPasteHandler } from "@/utils/multiPasteHandler"
import { ConnectionStatusIndicator } from "@/components/ConnectionStatusIndicator" import { ConnectionStatusIndicator } from "@/components/ConnectionStatusIndicator"
import AnonymousViewerBanner from "@/components/auth/AnonymousViewerBanner"
import { PermissionLevel } from "@/lib/auth/types"
import "@/css/anonymous-banner.css"
import "react-cmdk/dist/cmdk.css" import "react-cmdk/dist/cmdk.css"
import "@/css/style.css" import "@/css/style.css"
@ -272,7 +274,62 @@ export function Board() {
} }
}, []) }, [])
const roomId = slug || "mycofi33" const roomId = slug || "mycofi33"
const { session } = useAuth() const { session, fetchBoardPermission, canEdit } = useAuth()
// Permission state
const [permission, setPermission] = useState<PermissionLevel | null>(null)
const [permissionLoading, setPermissionLoading] = useState(true)
const [showEditPrompt, setShowEditPrompt] = useState(false)
// Fetch permission when board loads
useEffect(() => {
let mounted = true
const loadPermission = async () => {
setPermissionLoading(true)
try {
const perm = await fetchBoardPermission(roomId)
if (mounted) {
setPermission(perm)
}
} catch (error) {
console.error('Failed to fetch permission:', error)
// Default to view for unauthenticated, edit for authenticated
if (mounted) {
setPermission(session.authed ? 'edit' : 'view')
}
} finally {
if (mounted) {
setPermissionLoading(false)
}
}
}
loadPermission()
return () => {
mounted = false
}
}, [roomId, fetchBoardPermission, session.authed])
// Check if user can edit (either has edit/admin permission, or is authenticated with default edit access)
const isReadOnly = permission === 'view' || (!session.authed && !permission)
// Handler for when user tries to edit in read-only mode
const handleEditAttempt = () => {
if (isReadOnly) {
setShowEditPrompt(true)
}
}
// Handler for successful authentication from banner
const handleAuthenticated = () => {
setShowEditPrompt(false)
// Re-fetch permission after authentication
fetchBoardPermission(roomId).then(perm => {
setPermission(perm)
})
}
// Store roomId in localStorage for VideoChatShapeUtil to access // Store roomId in localStorage for VideoChatShapeUtil to access
useEffect(() => { useEffect(() => {
@ -396,6 +453,19 @@ export function Board() {
const { connectionState, isNetworkOnline } = storeWithHandle const { connectionState, isNetworkOnline } = storeWithHandle
const [editor, setEditor] = useState<Editor | null>(null) const [editor, setEditor] = useState<Editor | null>(null)
// Update read-only state when permission changes after editor is mounted
useEffect(() => {
if (!editor) return
if (isReadOnly) {
editor.updateInstanceState({ isReadonly: true })
console.log('🔒 Permission changed: Board is now read-only')
} else {
editor.updateInstanceState({ isReadonly: false })
console.log('🔓 Permission changed: Board is now editable')
}
}, [editor, isReadOnly])
useEffect(() => { useEffect(() => {
const value = localStorage.getItem("makereal_settings_2") const value = localStorage.getItem("makereal_settings_2")
if (value) { if (value) {
@ -1114,6 +1184,12 @@ export function Board() {
// Note: User presence is configured through the useAutomergeSync hook above // Note: User presence is configured through the useAutomergeSync hook above
// The authenticated username should appear in the people section // The authenticated username should appear in the people section
// MycelialIntelligence is now a permanent UI bar - no shape creation needed // MycelialIntelligence is now a permanent UI bar - no shape creation needed
// Set read-only mode based on permission
if (isReadOnly) {
editor.updateInstanceState({ isReadonly: true })
console.log('🔒 Board is in read-only mode for this user')
}
}} }}
> >
<CmdK /> <CmdK />
@ -1124,6 +1200,13 @@ export function Board() {
connectionState={connectionState} connectionState={connectionState}
isNetworkOnline={isNetworkOnline} isNetworkOnline={isNetworkOnline}
/> />
{/* Anonymous viewer banner - show for unauthenticated users or when edit was attempted */}
{(!session.authed || showEditPrompt) && (
<AnonymousViewerBanner
onAuthenticated={handleAuthenticated}
triggeredByEdit={showEditPrompt}
/>
)}
</div> </div>
</AutomergeHandleProvider> </AutomergeHandleProvider>
) )

View File

@ -7,6 +7,7 @@ import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar"
import { CommandPalette } from "./CommandPalette" import { CommandPalette } from "./CommandPalette"
import { UserSettingsModal } from "./UserSettingsModal" import { UserSettingsModal } from "./UserSettingsModal"
import { GoogleExportBrowser } from "../components/GoogleExportBrowser" import { GoogleExportBrowser } from "../components/GoogleExportBrowser"
import { NetworkGraphPanel } from "../components/networking"
import { import {
DefaultKeyboardShortcutsDialog, DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent, DefaultKeyboardShortcutsDialogContent,
@ -635,6 +636,7 @@ function CustomInFrontOfCanvas() {
<MycelialIntelligenceBar /> <MycelialIntelligenceBar />
<FocusLockIndicator /> <FocusLockIndicator />
<CommandPalette /> <CommandPalette />
<NetworkGraphPanel />
</> </>
) )
} }

832
worker/networkingApi.ts Normal file
View File

@ -0,0 +1,832 @@
/**
* User Networking API Routes
*
* Handles:
* - User search by username
* - Connection management (follow/unfollow)
* - Edge metadata (labels, notes, colors)
* - Network graph retrieval
*/
import { IRequest } from 'itty-router';
import { Environment, UserProfile, UserConnection, ConnectionMetadata, UserNode, GraphEdge, NetworkGraph } from './types';
// =============================================================================
// Helper Functions
// =============================================================================
function generateId(): string {
return crypto.randomUUID();
}
function jsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function errorResponse(message: string, status = 400): Response {
return jsonResponse({ error: message }, status);
}
// Extract user ID from request (from auth header or session)
// For now, we'll use a simple header-based approach
function getUserIdFromRequest(request: IRequest): string | null {
// Check for X-User-Id header (set by client after CryptID auth)
const userId = request.headers.get('X-User-Id');
return userId;
}
// =============================================================================
// User Search Routes
// =============================================================================
/**
* GET /api/networking/users/search?q=query&limit=20
* Search users by username or display name
*/
export async function searchUsers(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const url = new URL(request.url);
const query = url.searchParams.get('q') || '';
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
if (!query || query.length < 2) {
return errorResponse('Query must be at least 2 characters');
}
const currentUserId = getUserIdFromRequest(request);
try {
// Search users by username or display name
const searchPattern = `%${query}%`;
const users = await db.prepare(`
SELECT
u.id,
u.cryptid_username as username,
COALESCE(p.display_name, u.cryptid_username) as displayName,
p.avatar_color as avatarColor,
p.bio
FROM users u
LEFT JOIN user_profiles p ON u.id = p.user_id
WHERE (
u.cryptid_username LIKE ?1
OR p.display_name LIKE ?1
)
AND (p.is_searchable = 1 OR p.is_searchable IS NULL)
LIMIT ?2
`).bind(searchPattern, limit).all();
// If we have a current user, add connection status
let results = users.results || [];
if (currentUserId && results.length > 0) {
const userIds = results.map((u: any) => u.id);
// Get connections from current user
const myConnections = await db.prepare(`
SELECT to_user_id FROM user_connections WHERE from_user_id = ?
`).bind(currentUserId).all();
const connectedIds = new Set((myConnections.results || []).map((c: any) => c.to_user_id));
// Get connections to current user
const theirConnections = await db.prepare(`
SELECT from_user_id FROM user_connections WHERE to_user_id = ?
`).bind(currentUserId).all();
const connectedBackIds = new Set((theirConnections.results || []).map((c: any) => c.from_user_id));
results = results.map((user: any) => ({
...user,
isConnected: connectedIds.has(user.id),
isConnectedBack: connectedBackIds.has(user.id),
mutualConnections: 0, // TODO: Calculate mutual connections
}));
}
return jsonResponse(results);
} catch (error) {
console.error('User search error:', error);
return errorResponse('Search failed', 500);
}
}
/**
* GET /api/networking/users/:userId
* Get a user's public profile
*/
export async function getUserProfile(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const { userId } = request.params;
try {
const result = await db.prepare(`
SELECT
u.id,
u.cryptid_username as username,
COALESCE(p.display_name, u.cryptid_username) as displayName,
p.avatar_color as avatarColor,
p.bio
FROM users u
LEFT JOIN user_profiles p ON u.id = p.user_id
WHERE u.id = ?
`).bind(userId).first();
if (!result) {
return errorResponse('User not found', 404);
}
return jsonResponse(result);
} catch (error) {
console.error('Get profile error:', error);
return errorResponse('Failed to get profile', 500);
}
}
/**
* PUT /api/networking/users/me
* Update current user's profile
*/
export async function updateMyProfile(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
try {
const body = await request.json() as {
displayName?: string;
bio?: string;
avatarColor?: string;
};
// Upsert profile
await db.prepare(`
INSERT INTO user_profiles (user_id, display_name, bio, avatar_color, updated_at)
VALUES (?1, ?2, ?3, ?4, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
display_name = COALESCE(?2, display_name),
bio = COALESCE(?3, bio),
avatar_color = COALESCE(?4, avatar_color),
updated_at = datetime('now')
`).bind(userId, body.displayName || null, body.bio || null, body.avatarColor || null).run();
// Return updated profile
return getUserProfile({ ...request, params: { userId } } as IRequest, env);
} catch (error) {
console.error('Update profile error:', error);
return errorResponse('Failed to update profile', 500);
}
}
// =============================================================================
// Connection Management Routes
// =============================================================================
/**
* POST /api/networking/connections
* Create a connection (follow a user)
* Body: { toUserId: string, trustLevel?: 'connected' | 'trusted' }
*/
export async function createConnection(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const fromUserId = getUserIdFromRequest(request);
if (!fromUserId) {
return errorResponse('Unauthorized', 401);
}
try {
const body = await request.json() as { toUserId: string; trustLevel?: 'connected' | 'trusted' };
const { toUserId, trustLevel = 'connected' } = body;
if (!toUserId) {
return errorResponse('toUserId is required');
}
if (fromUserId === toUserId) {
return errorResponse('Cannot connect to yourself');
}
if (trustLevel !== 'connected' && trustLevel !== 'trusted') {
return errorResponse('trustLevel must be "connected" or "trusted"');
}
// Check if target user exists
const targetUser = await db.prepare('SELECT id FROM users WHERE id = ?').bind(toUserId).first();
if (!targetUser) {
return errorResponse('User not found', 404);
}
// Check if connection already exists
const existing = await db.prepare(`
SELECT id FROM user_connections WHERE from_user_id = ? AND to_user_id = ?
`).bind(fromUserId, toUserId).first();
if (existing) {
return errorResponse('Already connected');
}
// Create connection
const connectionId = generateId();
await db.prepare(`
INSERT INTO user_connections (id, from_user_id, to_user_id, trust_level)
VALUES (?, ?, ?, ?)
`).bind(connectionId, fromUserId, toUserId, trustLevel).run();
// Check if mutual and get their trust level
const reverseConnection = await db.prepare(`
SELECT id, trust_level FROM user_connections WHERE from_user_id = ? AND to_user_id = ?
`).bind(toUserId, fromUserId).first() as { id: string; trust_level: string } | null;
// Calculate effective trust level (highest of both directions)
let effectiveTrustLevel = null;
if (reverseConnection) {
const theirLevel = reverseConnection.trust_level;
effectiveTrustLevel = (trustLevel === 'trusted' || theirLevel === 'trusted') ? 'trusted' : 'connected';
}
const connection = {
id: connectionId,
fromUserId,
toUserId,
trustLevel,
createdAt: new Date().toISOString(),
isMutual: !!reverseConnection,
effectiveTrustLevel,
};
return jsonResponse(connection, 201);
} catch (error) {
console.error('Create connection error:', error);
return errorResponse('Failed to create connection', 500);
}
}
/**
* PUT /api/networking/connections/:connectionId/trust
* Update trust level for a connection
*/
export async function updateConnectionTrust(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
const { connectionId } = request.params;
try {
// Verify ownership
const connection = await db.prepare(`
SELECT id, from_user_id, to_user_id FROM user_connections WHERE id = ? AND from_user_id = ?
`).bind(connectionId, userId).first() as { id: string; from_user_id: string; to_user_id: string } | null;
if (!connection) {
return errorResponse('Connection not found or not owned by you', 404);
}
const body = await request.json() as { trustLevel: 'connected' | 'trusted' };
const { trustLevel } = body;
if (trustLevel !== 'connected' && trustLevel !== 'trusted') {
return errorResponse('trustLevel must be "connected" or "trusted"');
}
// Update trust level
await db.prepare(`
UPDATE user_connections SET trust_level = ?, updated_at = datetime('now') WHERE id = ?
`).bind(trustLevel, connectionId).run();
// Check if mutual and get their trust level
const reverseConnection = await db.prepare(`
SELECT trust_level FROM user_connections WHERE from_user_id = ? AND to_user_id = ?
`).bind(connection.to_user_id, connection.from_user_id).first() as { trust_level: string } | null;
let effectiveTrustLevel = null;
if (reverseConnection) {
const theirLevel = reverseConnection.trust_level;
effectiveTrustLevel = (trustLevel === 'trusted' || theirLevel === 'trusted') ? 'trusted' : 'connected';
}
return jsonResponse({
id: connectionId,
fromUserId: connection.from_user_id,
toUserId: connection.to_user_id,
trustLevel,
isMutual: !!reverseConnection,
effectiveTrustLevel,
});
} catch (error) {
console.error('Update trust level error:', error);
return errorResponse('Failed to update trust level', 500);
}
}
/**
* DELETE /api/networking/connections/:connectionId
* Remove a connection (unfollow)
*/
export async function removeConnection(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
const { connectionId } = request.params;
try {
// Verify ownership
const connection = await db.prepare(`
SELECT id FROM user_connections WHERE id = ? AND from_user_id = ?
`).bind(connectionId, userId).first();
if (!connection) {
return errorResponse('Connection not found or not owned by you', 404);
}
// Delete connection and its metadata
await db.prepare('DELETE FROM connection_metadata WHERE connection_id = ?').bind(connectionId).run();
await db.prepare('DELETE FROM user_connections WHERE id = ?').bind(connectionId).run();
return new Response(null, { status: 204 });
} catch (error) {
console.error('Remove connection error:', error);
return errorResponse('Failed to remove connection', 500);
}
}
/**
* GET /api/networking/connections
* Get current user's connections (people they follow)
*/
export async function getMyConnections(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
try {
const connections = await db.prepare(`
SELECT
c.id,
c.from_user_id as fromUserId,
c.to_user_id as toUserId,
c.created_at as createdAt,
m.label,
m.notes,
m.color,
m.strength,
EXISTS(
SELECT 1 FROM user_connections r
WHERE r.from_user_id = c.to_user_id AND r.to_user_id = c.from_user_id
) as isMutual
FROM user_connections c
LEFT JOIN connection_metadata m ON c.id = m.connection_id AND m.user_id = ?
WHERE c.from_user_id = ?
`).bind(userId, userId).all();
const results = (connections.results || []).map((c: any) => ({
id: c.id,
fromUserId: c.fromUserId,
toUserId: c.toUserId,
createdAt: c.createdAt,
isMutual: !!c.isMutual,
metadata: c.label || c.notes || c.color || c.strength ? {
label: c.label,
notes: c.notes,
color: c.color,
strength: c.strength || 5,
} : undefined,
}));
return jsonResponse(results);
} catch (error) {
console.error('Get connections error:', error);
return errorResponse('Failed to get connections', 500);
}
}
/**
* GET /api/networking/connections/followers
* Get users who follow the current user
*/
export async function getMyFollowers(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
try {
const connections = await db.prepare(`
SELECT
c.id,
c.from_user_id as fromUserId,
c.to_user_id as toUserId,
c.created_at as createdAt,
EXISTS(
SELECT 1 FROM user_connections r
WHERE r.from_user_id = c.to_user_id AND r.to_user_id = c.from_user_id
) as isMutual
FROM user_connections c
WHERE c.to_user_id = ?
`).bind(userId).all();
const results = (connections.results || []).map((c: any) => ({
id: c.id,
fromUserId: c.fromUserId,
toUserId: c.toUserId,
createdAt: c.createdAt,
isMutual: !!c.isMutual,
}));
return jsonResponse(results);
} catch (error) {
console.error('Get followers error:', error);
return errorResponse('Failed to get followers', 500);
}
}
/**
* GET /api/networking/connections/check/:userId
* Check if current user is connected to a specific user
*/
export async function checkConnection(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const currentUserId = getUserIdFromRequest(request);
if (!currentUserId) {
return errorResponse('Unauthorized', 401);
}
const { userId } = request.params;
try {
const connection = await db.prepare(`
SELECT id FROM user_connections WHERE from_user_id = ? AND to_user_id = ?
`).bind(currentUserId, userId).first();
return jsonResponse({ connected: !!connection });
} catch (error) {
console.error('Check connection error:', error);
return errorResponse('Failed to check connection', 500);
}
}
// =============================================================================
// Edge Metadata Routes
// =============================================================================
/**
* PUT /api/networking/connections/:connectionId/metadata
* Update edge metadata for a connection
*/
export async function updateEdgeMetadata(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
const { connectionId } = request.params;
try {
// Verify user is on this connection
const connection = await db.prepare(`
SELECT id, from_user_id, to_user_id FROM user_connections WHERE id = ?
`).bind(connectionId).first() as { id: string; from_user_id: string; to_user_id: string } | null;
if (!connection) {
return errorResponse('Connection not found', 404);
}
if (connection.from_user_id !== userId && connection.to_user_id !== userId) {
return errorResponse('Not authorized to edit this connection', 403);
}
const body = await request.json() as {
label?: string;
notes?: string;
color?: string;
strength?: number;
};
// Validate strength
if (body.strength !== undefined && (body.strength < 1 || body.strength > 10)) {
return errorResponse('Strength must be between 1 and 10');
}
// Upsert metadata
const metadataId = generateId();
await db.prepare(`
INSERT INTO connection_metadata (id, connection_id, user_id, label, notes, color, strength, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'))
ON CONFLICT(connection_id, user_id) DO UPDATE SET
label = COALESCE(?4, label),
notes = COALESCE(?5, notes),
color = COALESCE(?6, color),
strength = COALESCE(?7, strength),
updated_at = datetime('now')
`).bind(
metadataId,
connectionId,
userId,
body.label || null,
body.notes || null,
body.color || null,
body.strength || null
).run();
// Return updated metadata
const metadata = await db.prepare(`
SELECT label, notes, color, strength FROM connection_metadata
WHERE connection_id = ? AND user_id = ?
`).bind(connectionId, userId).first();
return jsonResponse(metadata || { label: null, notes: null, color: null, strength: 5 });
} catch (error) {
console.error('Update metadata error:', error);
return errorResponse('Failed to update metadata', 500);
}
}
/**
* GET /api/networking/connections/:connectionId/metadata
* Get edge metadata for a connection
*/
export async function getEdgeMetadata(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
const { connectionId } = request.params;
try {
// Verify user is on this connection
const connection = await db.prepare(`
SELECT id, from_user_id, to_user_id FROM user_connections WHERE id = ?
`).bind(connectionId).first() as { id: string; from_user_id: string; to_user_id: string } | null;
if (!connection) {
return errorResponse('Connection not found', 404);
}
if (connection.from_user_id !== userId && connection.to_user_id !== userId) {
return errorResponse('Not authorized to view this connection', 403);
}
const metadata = await db.prepare(`
SELECT label, notes, color, strength FROM connection_metadata
WHERE connection_id = ? AND user_id = ?
`).bind(connectionId, userId).first();
return jsonResponse(metadata || { label: null, notes: null, color: null, strength: 5 });
} catch (error) {
console.error('Get metadata error:', error);
return errorResponse('Failed to get metadata', 500);
}
}
// =============================================================================
// Network Graph Routes
// =============================================================================
/**
* GET /api/networking/graph
* Get the full network graph for current user
*/
export async function getNetworkGraph(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
try {
// Get all users connected to/from current user
const connections = await db.prepare(`
SELECT DISTINCT user_id FROM (
SELECT to_user_id as user_id FROM user_connections WHERE from_user_id = ?
UNION
SELECT from_user_id as user_id FROM user_connections WHERE to_user_id = ?
)
`).bind(userId, userId).all();
const connectedUserIds = (connections.results || []).map((c: any) => c.user_id);
connectedUserIds.push(userId); // Include self
// Get user profiles for all connected users
const placeholders = connectedUserIds.map(() => '?').join(',');
const users = await db.prepare(`
SELECT
u.id,
u.cryptid_username as username,
COALESCE(p.display_name, u.cryptid_username) as displayName,
p.avatar_color as avatarColor,
p.bio
FROM users u
LEFT JOIN user_profiles p ON u.id = p.user_id
WHERE u.id IN (${placeholders})
`).bind(...connectedUserIds).all();
// Build nodes
const nodes: UserNode[] = (users.results || []).map((u: any) => ({
id: u.id,
username: u.username,
displayName: u.displayName,
avatarColor: u.avatarColor,
bio: u.bio,
}));
// Get all edges between these users
const edges = await db.prepare(`
SELECT
c.id,
c.from_user_id as fromUserId,
c.to_user_id as toUserId,
c.created_at as createdAt,
m.label,
m.notes,
m.color,
m.strength,
EXISTS(
SELECT 1 FROM user_connections r
WHERE r.from_user_id = c.to_user_id AND r.to_user_id = c.from_user_id
) as isMutual
FROM user_connections c
LEFT JOIN connection_metadata m ON c.id = m.connection_id AND m.user_id = ?
WHERE c.from_user_id IN (${placeholders}) AND c.to_user_id IN (${placeholders})
`).bind(userId, ...connectedUserIds, ...connectedUserIds).all();
const graphEdges: GraphEdge[] = (edges.results || []).map((e: any) => ({
id: e.id,
fromUserId: e.fromUserId,
toUserId: e.toUserId,
createdAt: e.createdAt,
isMutual: !!e.isMutual,
metadata: (e.fromUserId === userId || e.toUserId === userId) && (e.label || e.notes || e.color || e.strength) ? {
label: e.label,
notes: e.notes,
color: e.color,
strength: e.strength || 5,
} : undefined,
}));
// Get list of users current user is connected to
const myConnections = await db.prepare(`
SELECT to_user_id FROM user_connections WHERE from_user_id = ?
`).bind(userId).all();
const graph: NetworkGraph = {
nodes,
edges: graphEdges,
myConnections: (myConnections.results || []).map((c: any) => c.to_user_id),
};
return jsonResponse(graph);
} catch (error) {
console.error('Get network graph error:', error);
return errorResponse('Failed to get network graph', 500);
}
}
/**
* POST /api/networking/graph/room
* Get network graph scoped to room participants
* Body: { participants: string[] }
*/
export async function getRoomNetworkGraph(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const userId = getUserIdFromRequest(request);
if (!userId) {
return errorResponse('Unauthorized', 401);
}
try {
const body = await request.json() as { participants: string[] };
const { participants } = body;
if (!participants || !Array.isArray(participants)) {
return errorResponse('participants array is required');
}
// First get the full network graph
const graphResponse = await getNetworkGraph(request, env);
const graph = await graphResponse.json() as NetworkGraph;
// Mark which nodes are in the room
const participantSet = new Set(participants);
const nodesWithRoomStatus = graph.nodes.map(node => ({
...node,
isInRoom: participantSet.has(node.id),
}));
return jsonResponse({
...graph,
nodes: nodesWithRoomStatus,
roomParticipants: participants,
});
} catch (error) {
console.error('Get room network graph error:', error);
return errorResponse('Failed to get room network graph', 500);
}
}
/**
* GET /api/networking/connections/mutual/:userId
* Get mutual connections between current user and another user
*/
export async function getMutualConnections(request: IRequest, env: Environment): Promise<Response> {
const db = env.CRYPTID_DB;
if (!db) {
return errorResponse('Database not configured', 500);
}
const currentUserId = getUserIdFromRequest(request);
if (!currentUserId) {
return errorResponse('Unauthorized', 401);
}
const { userId } = request.params;
try {
// Find users that both current user and target user are connected to
const mutuals = await db.prepare(`
SELECT
u.id,
u.cryptid_username as username,
COALESCE(p.display_name, u.cryptid_username) as displayName,
p.avatar_color as avatarColor,
p.bio
FROM users u
LEFT JOIN user_profiles p ON u.id = p.user_id
WHERE u.id IN (
SELECT c1.to_user_id
FROM user_connections c1
INNER JOIN user_connections c2 ON c1.to_user_id = c2.to_user_id
WHERE c1.from_user_id = ? AND c2.from_user_id = ?
)
`).bind(currentUserId, userId).all();
return jsonResponse(mutuals.results || []);
} catch (error) {
console.error('Get mutual connections error:', error);
return errorResponse('Failed to get mutual connections', 500);
}
}

View File

@ -105,13 +105,16 @@ CREATE TABLE IF NOT EXISTS user_profiles (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
-- User connections (one-way following) -- User connections (one-way following with trust levels)
-- from_user follows to_user (asymmetric) -- from_user follows to_user (asymmetric)
-- Trust levels: 'connected' (yellow, view) or 'trusted' (green, edit)
CREATE TABLE IF NOT EXISTS user_connections ( CREATE TABLE IF NOT EXISTS user_connections (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
from_user_id TEXT NOT NULL, -- User who initiated the connection from_user_id TEXT NOT NULL, -- User who initiated the connection
to_user_id TEXT NOT NULL, -- User being connected to to_user_id TEXT NOT NULL, -- User being connected to
trust_level TEXT DEFAULT 'connected' CHECK (trust_level IN ('connected', 'trusted')),
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(from_user_id, to_user_id) -- Can only connect once UNIQUE(from_user_id, to_user_id) -- Can only connect once

View File

@ -95,4 +95,97 @@ export interface PermissionCheckResult {
permission: PermissionLevel; permission: PermissionLevel;
isOwner: boolean; isOwner: boolean;
boardExists: boolean; boardExists: boolean;
}
// =============================================================================
// User Networking / Social Graph Types
// =============================================================================
/**
* User profile record in the database
*/
export interface UserProfile {
user_id: string;
display_name: string | null;
bio: string | null;
avatar_color: string | null;
is_searchable: number; // SQLite boolean (0 or 1)
created_at: string;
updated_at: string;
}
/**
* Trust levels for connections:
* - 'connected': Yellow, grants view permission on shared data
* - 'trusted': Green, grants edit permission on shared data
*/
export type TrustLevel = 'connected' | 'trusted';
/**
* User connection record (one-way follow with trust level)
*/
export interface UserConnection {
id: string;
from_user_id: string;
to_user_id: string;
trust_level: TrustLevel;
created_at: string;
updated_at: string;
}
/**
* Edge metadata for a connection (private to each party)
*/
export interface ConnectionMetadata {
id: string;
connection_id: string;
user_id: string;
label: string | null;
notes: string | null;
color: string | null;
strength: number; // 1-10
updated_at: string;
}
/**
* Combined user info for search results and graph nodes
*/
export interface UserNode {
id: string;
username: string;
displayName: string | null;
avatarColor: string | null;
bio: string | null;
}
/**
* Graph edge with connection and optional metadata
*/
export interface GraphEdge {
id: string;
fromUserId: string;
toUserId: string;
trustLevel: TrustLevel;
createdAt: string;
// Metadata is only included for the requesting user's edges
metadata?: {
label: string | null;
notes: string | null;
color: string | null;
strength: number;
};
// Indicates if this is a mutual connection (both follow each other)
isMutual: boolean;
// The highest trust level between both directions (if mutual)
effectiveTrustLevel: TrustLevel | null;
}
/**
* Full network graph response
*/
export interface NetworkGraph {
nodes: UserNode[];
edges: GraphEdge[];
// Current user's connections (for filtering)
myConnections: string[]; // User IDs I'm connected to
} }

View File

@ -1,6 +1,29 @@
import { AutoRouter, cors, error, IRequest } from "itty-router" import { AutoRouter, cors, error, IRequest } from "itty-router"
import { handleAssetDownload, handleAssetUpload } from "./assetUploads" import { handleAssetDownload, handleAssetUpload } from "./assetUploads"
import { Environment } from "./types" import { Environment } from "./types"
import {
searchUsers,
getUserProfile,
updateMyProfile,
createConnection,
updateConnectionTrust,
removeConnection,
getMyConnections,
getMyFollowers,
checkConnection,
updateEdgeMetadata,
getEdgeMetadata,
getNetworkGraph,
getRoomNetworkGraph,
getMutualConnections,
} from "./networkingApi"
import {
handleGetPermission,
handleListPermissions,
handleGrantPermission,
handleRevokePermission,
handleUpdateBoard,
} from "./boardPermissions"
// make sure our sync durable objects are made available to cloudflare // make sure our sync durable objects are made available to cloudflare
export { AutomergeDurableObject } from "./AutomergeDurableObject" export { AutomergeDurableObject } from "./AutomergeDurableObject"
@ -81,7 +104,7 @@ const { preflight, corsify } = cors({
// If no match found, return * to allow all origins // If no match found, return * to allow all origins
return "*" return "*"
}, },
allowMethods: ["GET", "POST", "HEAD", "OPTIONS", "UPGRADE"], allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "UPGRADE"],
allowHeaders: [ allowHeaders: [
"Content-Type", "Content-Type",
"Authorization", "Authorization",
@ -96,6 +119,9 @@ const { preflight, corsify } = cors({
"Range", "Range",
"If-None-Match", "If-None-Match",
"If-Modified-Since", "If-Modified-Since",
"X-CryptID-PublicKey", // CryptID authentication header
"X-User-Id", // User ID header for networking API
"X-Api-Key", // API key header for external services
"*" "*"
], ],
maxAge: 86400, maxAge: 86400,
@ -761,10 +787,10 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
.post("/fathom/webhook", async (req) => { .post("/fathom/webhook", async (req) => {
try { try {
const body = await req.json() const body = await req.json()
// Log the webhook for debugging // Log the webhook for debugging
console.log('Fathom webhook received:', JSON.stringify(body, null, 2)) console.log('Fathom webhook received:', JSON.stringify(body, null, 2))
// TODO: Verify webhook signature for security // TODO: Verify webhook signature for security
// For now, we'll accept all webhooks. In production, you should: // For now, we'll accept all webhooks. In production, you should:
// 1. Get the webhook secret from Fathom // 1. Get the webhook secret from Fathom
@ -777,17 +803,17 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
// headers: { 'Content-Type': 'application/json' } // headers: { 'Content-Type': 'application/json' }
// }) // })
// } // }
// Process the meeting data // Process the meeting data
const meetingData = body as any const meetingData = body as any
// Store meeting data for later retrieval // Store meeting data for later retrieval
// This could be stored in R2 or Durable Object storage // This could be stored in R2 or Durable Object storage
console.log('Processing meeting:', meetingData.meeting_id) console.log('Processing meeting:', meetingData.meeting_id)
// TODO: Store meeting data in R2 or send to connected clients // TODO: Store meeting data in R2 or send to connected clients
// For now, just log it // For now, just log it
return new Response(JSON.stringify({ success: true }), { return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}) })
@ -800,6 +826,57 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
} }
}) })
// =============================================================================
// User Networking / Social Graph API
// =============================================================================
// User search and profiles
.get("/api/networking/users/search", searchUsers)
.get("/api/networking/users/me", (req, env) => getUserProfile({ ...req, params: { userId: req.headers.get('X-User-Id') || '' } } as IRequest, env))
.put("/api/networking/users/me", updateMyProfile)
.get("/api/networking/users/:userId", getUserProfile)
// Connection management
.post("/api/networking/connections", createConnection)
.get("/api/networking/connections", getMyConnections)
.get("/api/networking/connections/followers", getMyFollowers)
.get("/api/networking/connections/check/:userId", checkConnection)
.get("/api/networking/connections/mutual/:userId", getMutualConnections)
.put("/api/networking/connections/:connectionId/trust", updateConnectionTrust)
.delete("/api/networking/connections/:connectionId", removeConnection)
// Edge metadata
.put("/api/networking/connections/:connectionId/metadata", updateEdgeMetadata)
.get("/api/networking/connections/:connectionId/metadata", getEdgeMetadata)
// Network graph
.get("/api/networking/graph", getNetworkGraph)
.post("/api/networking/graph/room", getRoomNetworkGraph)
// =============================================================================
// Board Permissions API
// =============================================================================
// Get current user's permission for a board
.get("/boards/:boardId/permission", (req, env) =>
handleGetPermission(req.params.boardId, req, env))
// List all permissions for a board (admin only)
.get("/boards/:boardId/permissions", (req, env) =>
handleListPermissions(req.params.boardId, req, env))
// Grant permission to a user (admin only)
.post("/boards/:boardId/permissions", (req, env) =>
handleGrantPermission(req.params.boardId, req, env))
// Revoke a user's permission (admin only)
.delete("/boards/:boardId/permissions/:userId", (req, env) =>
handleRevokePermission(req.params.boardId, req.params.userId, req, env))
// Update board settings (admin only)
.patch("/boards/:boardId", (req, env) =>
handleUpdateBoard(req.params.boardId, req, env))
async function backupAllBoards(env: Environment) { async function backupAllBoards(env: Environment) {
try { try {
// List all room files from TLDRAW_BUCKET // List all room files from TLDRAW_BUCKET