diff --git a/package-lock.json b/package-lock.json index 68225ef..ce4ae0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9dcce16..e7506f4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/automerge/useAutomergeSyncRepo.ts b/src/automerge/useAutomergeSyncRepo.ts index 42f4c00..e427bd7 100644 --- a/src/automerge/useAutomergeSyncRepo.ts +++ b/src/automerge/useAutomergeSyncRepo.ts @@ -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() diff --git a/src/components/networking/NetworkGraphMinimap.tsx b/src/components/networking/NetworkGraphMinimap.tsx new file mode 100644 index 0000000..5c82256 --- /dev/null +++ b/src/components/networking/NetworkGraphMinimap.tsx @@ -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; + onDisconnect?: (connectionId: string) => Promise; + 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 { + 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(null); + const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const simulationRef = useRef | 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(simNodes) + .force('link', d3.forceLink(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() + .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 ( +
+
+ πŸ•ΈοΈ +
+
+ ); + } + + return ( +
+
+
+

Network

+
+ + {onExpandClick && ( + + )} + {onToggleCollapse && ( + + )} +
+
+ +
+ + + {tooltip && ( +
+ {tooltip.text} +
+ )} +
+ +
+
+
+ {inRoomCount} +
+
+
+ {trustedCount} +
+
+
+ {connectedCount} +
+
+
+ {unconnectedCount} +
+
+
+ + 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} + /> +
+ ); +} + +export default NetworkGraphMinimap; diff --git a/src/components/networking/NetworkGraphPanel.tsx b/src/components/networking/NetworkGraphPanel.tsx new file mode 100644 index 0000000..6a4ed53 --- /dev/null +++ b/src/components/networking/NetworkGraphPanel.tsx @@ -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(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 ( +
+ Loading network... +
+ ); + } + + return ( + setIsCollapsed(!isCollapsed)} + /> + ); +} + +export default NetworkGraphPanel; diff --git a/src/components/networking/UserSearchModal.tsx b/src/components/networking/UserSearchModal.tsx new file mode 100644 index 0000000..71ae50a --- /dev/null +++ b/src/components/networking/UserSearchModal.tsx @@ -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; + onDisconnect?: (userId: string) => Promise; + 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([]); + const [isLoading, setIsLoading] = useState(false); + const [hoveredId, setHoveredId] = useState(null); + const [connectingId, setConnectingId] = useState(null); + const inputRef = useRef(null); + const searchTimeoutRef = useRef(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 ( +
+
e.stopPropagation()}> +
+

Find People

+ +
+ +
+ setQuery(e.target.value)} + style={styles.searchInput} + /> +
+ +
+ {isLoading ? ( +
Searching...
+ ) : results.length === 0 ? ( +
+ {query.length < 2 + ? 'Type at least 2 characters to search' + : 'No users found' + } +
+ ) : ( + results.map(user => ( +
setHoveredId(user.id)} + onMouseLeave={() => setHoveredId(null)} + > +
+ {(user.displayName || user.username).charAt(0).toUpperCase()} +
+ +
+
@{user.username}
+ {user.displayName && user.displayName !== user.username && ( +
{user.displayName}
+ )} + {user.isConnectedBack && !user.isConnected && ( +
Follows you
+ )} + {user.mutualConnections > 0 && ( +
+ {user.mutualConnections} mutual connection{user.mutualConnections !== 1 ? 's' : ''} +
+ )} +
+ + +
+ )) + )} +
+
+
+ ); +} + +export default UserSearchModal; diff --git a/src/components/networking/index.ts b/src/components/networking/index.ts new file mode 100644 index 0000000..b2946ee --- /dev/null +++ b/src/components/networking/index.ts @@ -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'; diff --git a/src/components/networking/useNetworkGraph.ts b/src/components/networking/useNetworkGraph.ts new file mode 100644 index 0000000..cd2a9a0 --- /dev/null +++ b/src/components/networking/useNetworkGraph.ts @@ -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; + // Connect to a user + connect: (userId: string) => Promise; + // Disconnect from a user + disconnect: (connectionId: string) => Promise; + // 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({ + nodes: [], + edges: [], + myConnections: [], + isLoading: true, + error: null, + }); + + // Create a map of room participant IDs to their colors + const participantColorMap = useMemo(() => { + const map = new Map(); + 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([]); + + 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; +} diff --git a/src/lib/networking/connectionService.ts b/src/lib/networking/connectionService.ts new file mode 100644 index 0000000..796a0c2 --- /dev/null +++ b/src/lib/networking/connectionService.ts @@ -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(url: string, options?: RequestInit): Promise { + 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 { + const params = new URLSearchParams({ q: query, limit: String(limit) }); + return fetchJson(`${API_BASE}/users/search?${params}`); +} + +/** + * Get a user's public profile + */ +export async function getUserProfile(userId: string): Promise { + try { + return await fetchJson(`${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 { + return fetchJson(`${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 { + return fetchJson(`${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 { + return fetchJson(`${API_BASE}/connections/${connectionId}/trust`, { + method: 'PUT', + body: JSON.stringify({ trustLevel }), + }); +} + +/** + * Remove a connection (unfollow a user) + */ +export async function removeConnection(connectionId: string): Promise { + await fetch(`${API_BASE}/connections/${connectionId}`, { + method: 'DELETE', + }); +} + +/** + * Get a specific connection by ID + */ +export async function getConnection(connectionId: string): Promise { + try { + return await fetchJson(`${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 { + return fetchJson(`${API_BASE}/connections`); +} + +/** + * Get users who are connected to current user (followers) + */ +export async function getMyFollowers(): Promise { + return fetchJson(`${API_BASE}/connections/followers`); +} + +/** + * Check if current user is connected to a specific user + */ +export async function isConnectedTo(userId: string): Promise { + 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 +): Promise { + return fetchJson(`${API_BASE}/connections/${connectionId}/metadata`, { + method: 'PUT', + body: JSON.stringify(metadata), + }); +} + +/** + * Get metadata for a connection edge + */ +export async function getEdgeMetadata(connectionId: string): Promise { + try { + return await fetchJson(`${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 { + return fetchJson(`${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 { + return fetchJson(`${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 { + return fetchJson(`${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 + } +} diff --git a/src/lib/networking/index.ts b/src/lib/networking/index.ts new file mode 100644 index 0000000..bb2b69e --- /dev/null +++ b/src/lib/networking/index.ts @@ -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'; diff --git a/src/lib/networking/types.ts b/src/lib/networking/types.ts new file mode 100644 index 0000000..32f8075 --- /dev/null +++ b/src/lib/networking/types.ts @@ -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 = { + unconnected: '#9ca3af', // grey + connected: '#eab308', // yellow + trusted: '#22c55e', // green +}; + +/** + * Permission mapping for trust levels + */ +export const TRUST_LEVEL_PERMISSIONS: Record = { + 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; +} + +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 + }; +} diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 2f65a63..eae1cb1 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -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(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(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') + } }} > @@ -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) && ( + + )}
) diff --git a/src/ui/components.tsx b/src/ui/components.tsx index 0a6e6f4..9d49a1a 100644 --- a/src/ui/components.tsx +++ b/src/ui/components.tsx @@ -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() { + ) } diff --git a/worker/networkingApi.ts b/worker/networkingApi.ts new file mode 100644 index 0000000..676cada --- /dev/null +++ b/worker/networkingApi.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/worker/schema.sql b/worker/schema.sql index ee4a185..1120d0f 100644 --- a/worker/schema.sql +++ b/worker/schema.sql @@ -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 diff --git a/worker/types.ts b/worker/types.ts index 8f78500..f595aa2 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -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 } \ No newline at end of file diff --git a/worker/worker.ts b/worker/worker.ts index 0a5ed77..b710b9f 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -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({ .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({ // 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({ } }) + // ============================================================================= + // 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