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
633dfcb294
commit
5af19bbbb2
|
|
@ -24,6 +24,7 @@
|
|||
"@tldraw/assets": "^3.15.4",
|
||||
"@tldraw/tldraw": "^3.15.4",
|
||||
"@tldraw/tlschema": "^3.15.4",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/markdown-it": "^14.1.1",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@uiw/react-md-editor": "^4.0.5",
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
"ajv": "^8.17.1",
|
||||
"cherry-markdown": "^0.8.57",
|
||||
"cloudflare-workers-unfurl": "^0.0.7",
|
||||
"d3": "^7.9.0",
|
||||
"fathom-typescript": "^0.0.36",
|
||||
"gray-matter": "^4.0.3",
|
||||
"gun": "^0.2020.1241",
|
||||
|
|
@ -6770,6 +6772,259 @@
|
|||
"@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": {
|
||||
"version": "4.1.12",
|
||||
"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",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
|
|
@ -8595,7 +8849,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
|
|
@ -8637,7 +8890,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
|
|
@ -8650,7 +8902,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8660,7 +8911,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
|
|
@ -8677,7 +8927,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-path": "1 - 3"
|
||||
},
|
||||
|
|
@ -8690,7 +8939,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8700,7 +8948,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0"
|
||||
},
|
||||
|
|
@ -8713,7 +8960,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
|
|
@ -8726,7 +8972,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8736,7 +8981,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
|
|
@ -8750,7 +8994,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
|
|
@ -8776,7 +9019,6 @@
|
|||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"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",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8799,7 +9040,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-dsv": "1 - 3"
|
||||
},
|
||||
|
|
@ -8812,7 +9052,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
|
|
@ -8827,7 +9066,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8837,7 +9075,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
|
|
@ -8850,7 +9087,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8860,7 +9096,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
|
|
@ -8873,7 +9108,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8883,7 +9117,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8893,7 +9126,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8903,7 +9135,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8913,7 +9144,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
|
|
@ -8930,7 +9160,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
|
|
@ -8944,7 +9173,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -8954,7 +9182,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
|
|
@ -8967,7 +9194,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
|
|
@ -8980,7 +9206,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
|
|
@ -8993,7 +9218,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -9003,7 +9227,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
|
|
@ -9023,7 +9246,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
|
|
@ -9256,7 +9478,6 @@
|
|||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
||||
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
|
|
@ -11170,7 +11391,6 @@
|
|||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -15581,8 +15801,7 @@
|
|||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
|
||||
"license": "Unlicense",
|
||||
"optional": true
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.3",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
"@tldraw/assets": "^3.15.4",
|
||||
"@tldraw/tldraw": "^3.15.4",
|
||||
"@tldraw/tlschema": "^3.15.4",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/markdown-it": "^14.1.1",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@uiw/react-md-editor": "^4.0.5",
|
||||
|
|
@ -51,6 +52,7 @@
|
|||
"ajv": "^8.17.1",
|
||||
"cherry-markdown": "^0.8.57",
|
||||
"cloudflare-workers-unfurl": "^0.0.7",
|
||||
"d3": "^7.9.0",
|
||||
"fathom-typescript": "^0.0.36",
|
||||
"gray-matter": "^4.0.3",
|
||||
"gun": "^0.2020.1241",
|
||||
|
|
|
|||
|
|
@ -417,21 +417,40 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
|
||||
|
||||
// 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) {
|
||||
handle.change((doc: any) => {
|
||||
// Initialize store if it doesn't exist
|
||||
if (!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]) => {
|
||||
// Only add if not already present locally (local changes take precedence)
|
||||
// This is a simple merge strategy - Automerge's CRDT will handle deeper conflicts
|
||||
if (!doc.store[id]) {
|
||||
if (localIsEmpty) {
|
||||
// Local is empty - bootstrap everything from server
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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 { setupMultiPasteHandler } from "@/utils/multiPasteHandler"
|
||||
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 "@/css/style.css"
|
||||
|
|
@ -272,7 +274,62 @@ export function Board() {
|
|||
}
|
||||
}, [])
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -396,6 +453,19 @@ export function Board() {
|
|||
const { connectionState, isNetworkOnline } = storeWithHandle
|
||||
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(() => {
|
||||
const value = localStorage.getItem("makereal_settings_2")
|
||||
if (value) {
|
||||
|
|
@ -1114,6 +1184,12 @@ export function Board() {
|
|||
// Note: User presence is configured through the useAutomergeSync hook above
|
||||
// The authenticated username should appear in the people section
|
||||
// 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 />
|
||||
|
|
@ -1124,6 +1200,13 @@ export function Board() {
|
|||
connectionState={connectionState}
|
||||
isNetworkOnline={isNetworkOnline}
|
||||
/>
|
||||
{/* Anonymous viewer banner - show for unauthenticated users or when edit was attempted */}
|
||||
{(!session.authed || showEditPrompt) && (
|
||||
<AnonymousViewerBanner
|
||||
onAuthenticated={handleAuthenticated}
|
||||
triggeredByEdit={showEditPrompt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AutomergeHandleProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar"
|
|||
import { CommandPalette } from "./CommandPalette"
|
||||
import { UserSettingsModal } from "./UserSettingsModal"
|
||||
import { GoogleExportBrowser } from "../components/GoogleExportBrowser"
|
||||
import { NetworkGraphPanel } from "../components/networking"
|
||||
import {
|
||||
DefaultKeyboardShortcutsDialog,
|
||||
DefaultKeyboardShortcutsDialogContent,
|
||||
|
|
@ -635,6 +636,7 @@ function CustomInFrontOfCanvas() {
|
|||
<MycelialIntelligenceBar />
|
||||
<FocusLockIndicator />
|
||||
<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
|
||||
);
|
||||
|
||||
-- User connections (one-way following)
|
||||
-- User connections (one-way following with trust levels)
|
||||
-- from_user follows to_user (asymmetric)
|
||||
-- Trust levels: 'connected' (yellow, view) or 'trusted' (green, edit)
|
||||
CREATE TABLE IF NOT EXISTS user_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_user_id TEXT NOT NULL, -- User who initiated the connection
|
||||
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')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (from_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
|
||||
|
|
|
|||
|
|
@ -95,4 +95,97 @@ export interface PermissionCheckResult {
|
|||
permission: PermissionLevel;
|
||||
isOwner: 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 { handleAssetDownload, handleAssetUpload } from "./assetUploads"
|
||||
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
|
||||
export { AutomergeDurableObject } from "./AutomergeDurableObject"
|
||||
|
|
@ -81,7 +104,7 @@ const { preflight, corsify } = cors({
|
|||
// If no match found, return * to allow all origins
|
||||
return "*"
|
||||
},
|
||||
allowMethods: ["GET", "POST", "HEAD", "OPTIONS", "UPGRADE"],
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "UPGRADE"],
|
||||
allowHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
|
|
@ -96,6 +119,9 @@ const { preflight, corsify } = cors({
|
|||
"Range",
|
||||
"If-None-Match",
|
||||
"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,
|
||||
|
|
@ -761,10 +787,10 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
.post("/fathom/webhook", async (req) => {
|
||||
try {
|
||||
const body = await req.json()
|
||||
|
||||
|
||||
// Log the webhook for debugging
|
||||
console.log('Fathom webhook received:', JSON.stringify(body, null, 2))
|
||||
|
||||
|
||||
// TODO: Verify webhook signature for security
|
||||
// For now, we'll accept all webhooks. In production, you should:
|
||||
// 1. Get the webhook secret from Fathom
|
||||
|
|
@ -777,17 +803,17 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
// headers: { 'Content-Type': 'application/json' }
|
||||
// })
|
||||
// }
|
||||
|
||||
|
||||
// Process the meeting data
|
||||
const meetingData = body as any
|
||||
|
||||
|
||||
// Store meeting data for later retrieval
|
||||
// This could be stored in R2 or Durable Object storage
|
||||
console.log('Processing meeting:', meetingData.meeting_id)
|
||||
|
||||
|
||||
// TODO: Store meeting data in R2 or send to connected clients
|
||||
// For now, just log it
|
||||
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
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) {
|
||||
try {
|
||||
// List all room files from TLDRAW_BUCKET
|
||||
|
|
|
|||
Loading…
Reference in New Issue