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:
parent
9f5befc729
commit
30608dfdc8
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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}>
|
||||||
|
×
|
||||||
|
</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;
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue