diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3954717..b173667 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The Ideon project follows the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format
and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.4.0] - 2026-02-15
+
+### Added
+
+- Emoji reactions on blocks to enable quick feedback during collaboration without editing content
+- Edge labels to clarify relationships between blocks and improve visual structure.
+- Permanent “Empty Trash” option allowing users to fully clear deleted items and remove all related project content in one action.
+
+### Improved
+
+- Performance improvements across the app.
+- UX refinements to make interactions smoother and more responsive.
+- Overall user experience enhancements.
+
+### Fixed
+
+- Fixed project creation failure due to missing ownerId in session by implementing robust token fallback (#42).
+
## [0.3.4] - 2026-02-13
### Fixed
diff --git a/package-lock.json b/package-lock.json
index 1961e9c..efa52ca 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ideon",
- "version": "0.3.4",
+ "version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ideon",
- "version": "0.3.4",
+ "version": "0.4.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@boxyhq/saml-jackson": "^1.52.2",
@@ -20,6 +20,7 @@
"argon2": "^0.44.0",
"better-sqlite3": "^12.6.0",
"chroma-js": "^3.2.0",
+ "emoji-picker-react": "^4.18.0",
"encoding": "^0.1.13",
"html-to-image": "^1.11.11",
"jose": "^5.2.4",
@@ -594,6 +595,7 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.817.0.tgz",
"integrity": "sha512-i6Q2MyktWHG4YG+EmLlnXTgNVjW9/yeNHSKzF55GTho5fjqfU+t9beJfuMWclanRCifamm3N5e5OCm52rVDdTQ==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@aws-sdk/client-cognito-identity": "3.817.0",
"@aws-sdk/core": "3.816.0",
@@ -934,6 +936,7 @@
"resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz",
"integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.10.0",
@@ -995,6 +998,7 @@
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz",
"integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.10.0",
@@ -2305,6 +2309,7 @@
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@floating-ui/core": "^1.7.4",
"@floating-ui/utils": "^0.2.10"
@@ -2882,8 +2887,7 @@
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
"devOptional": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
@@ -3510,6 +3514,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -3832,6 +3837,7 @@
"resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.1.tgz",
"integrity": "sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -3844,6 +3850,7 @@
"resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.1.tgz",
"integrity": "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -3856,6 +3863,7 @@
"resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.1.tgz",
"integrity": "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"prismjs": "^1.30.0"
},
@@ -3871,6 +3879,7 @@
"resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.6.tgz",
"integrity": "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -3929,6 +3938,7 @@
"resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.16.tgz",
"integrity": "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -3965,6 +3975,7 @@
"resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.16.tgz",
"integrity": "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -3977,6 +3988,7 @@
"resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.12.tgz",
"integrity": "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -4001,6 +4013,7 @@
"resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.12.tgz",
"integrity": "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -4013,6 +4026,7 @@
"resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.13.tgz",
"integrity": "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -4040,6 +4054,7 @@
"resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.14.tgz",
"integrity": "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -4151,6 +4166,7 @@
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -4172,6 +4188,7 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz",
"integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@@ -5254,6 +5271,7 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.25"
@@ -5497,6 +5515,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz",
"integrity": "sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -5718,6 +5737,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.19.0.tgz",
"integrity": "sha512-N6nKbFB2VwMsPlCw67RlAtYSK48TAsAUgjnD+vd3ieSlIufdQnLXDFUP6hFKx9mwoUVUgZGz02RA6bkxOdYyTw==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -5836,6 +5856,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.19.0.tgz",
"integrity": "sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -5850,6 +5871,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.19.0.tgz",
"integrity": "sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@@ -6114,6 +6136,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz",
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -6162,6 +6185,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -6171,6 +6195,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6263,6 +6288,7 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@@ -6834,6 +6860,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7153,6 +7180,7 @@
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -7976,6 +8004,7 @@
"integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"env-paths": "^2.2.1",
"import-fresh": "^3.3.0",
@@ -8143,6 +8172,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
+ "peer": true,
"engines": {
"node": ">=12"
}
@@ -8540,6 +8570,21 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/emoji-picker-react": {
+ "version": "4.18.0",
+ "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz",
+ "integrity": "sha512-vLTrLfApXAIciguGE57pXPWs9lPLBspbEpPMiUq03TIli2dHZBiB+aZ0R9/Wat0xmTfcd4AuEzQgSYxEZ8C88Q==",
+ "license": "MIT",
+ "dependencies": {
+ "flairup": "1.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">=16"
+ }
+ },
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
@@ -8551,6 +8596,7 @@
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
@@ -8711,6 +8757,7 @@
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -8779,6 +8826,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9359,6 +9407,12 @@
"rollup": "^4.34.8"
}
},
+ "node_modules/flairup": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
+ "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
+ "license": "MIT"
+ },
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -10890,8 +10944,7 @@
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"devOptional": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
@@ -10904,8 +10957,7 @@
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"devOptional": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
@@ -11477,6 +11529,7 @@
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.16.0.tgz",
"integrity": "sha512-D1PNcdT0y4Grhou5Zi/qgipZOYeWrhLEpk33n3nm6LGtz61jvO88WlrWCK/bigMjpnOdAUKKQwsGIl0NtWMyYw==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@mongodb-js/saslprep": "^1.1.9",
"bson": "^6.10.3",
@@ -11568,6 +11621,7 @@
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.1.tgz",
"integrity": "sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0",
@@ -11897,6 +11951,7 @@
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
+ "peer": true,
"engines": {
"node": ">=6.0.0"
}
@@ -12359,6 +12414,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
@@ -12455,6 +12511,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -12692,6 +12749,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -12946,6 +13004,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -12975,6 +13034,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -13023,6 +13083,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
"integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -13182,6 +13243,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13191,6 +13253,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13321,6 +13384,7 @@
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz",
"integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==",
"license": "MIT",
+ "peer": true,
"workspaces": [
"./packages/*"
],
@@ -13339,7 +13403,6 @@
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=4"
}
@@ -13350,7 +13413,6 @@
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"redis-errors": "^1.0.0"
},
@@ -14058,6 +14120,7 @@
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
@@ -14260,8 +14323,7 @@
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"devOptional": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
@@ -15488,6 +15550,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -16161,6 +16224,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16321,6 +16385,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -17494,6 +17559,7 @@
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz",
"integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"lib0": "^0.2.99"
},
diff --git a/package.json b/package.json
index 7e76443..c1b87c1 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "ideon",
"private": true,
- "version": "0.3.4",
+ "version": "0.4.0",
"description": "The Visual Hub for Everything Your Project Needs",
"license": "AGPL-3.0-or-later",
"repository": {
@@ -31,6 +31,7 @@
"argon2": "^0.44.0",
"better-sqlite3": "^12.6.0",
"chroma-js": "^3.2.0",
+ "emoji-picker-react": "^4.18.0",
"encoding": "^0.1.13",
"html-to-image": "^1.11.11",
"jose": "^5.2.4",
diff --git a/src/app/(main)/management/ManagementClient.tsx b/src/app/(main)/management/ManagementClient.tsx
index 1ae5c18..4b6a7f5 100644
--- a/src/app/(main)/management/ManagementClient.tsx
+++ b/src/app/(main)/management/ManagementClient.tsx
@@ -447,7 +447,7 @@ export function ManagementClient() {
- {(dict.common as Record)[p.key]}
+ {(dict.auth as Record)[p.key]}
-
+
{formatDate(data.updatedAt || "")}
@@ -2039,6 +2089,14 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => {
+
+
{/* Connection Handles */}
{
+ label?: string;
+ isEditing?: boolean;
+ onLabelSubmit?: (id: string, label: string) => void;
+ onLabelCancel?: (id: string) => void;
+ weight?: number;
+}
+
+type CustomEdge = Edge;
export default function CanvasEdge({
sourceX,
@@ -20,8 +34,17 @@ export default function CanvasEdge({
data,
selected,
id,
-}: LinkProps) {
- // console.log("Rendering CanvasEdge:", { sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition });
+ label,
+}: LinkProps) {
+ const [inputValue, setInputValue] = useState(data?.label || "");
+ const isEditing = !!data?.isEditing;
+
+ useEffect(() => {
+ if (isEditing) {
+ setInputValue(data?.label || "");
+ }
+ }, [isEditing, data?.label]);
+
if (
sourceX === undefined ||
sourceY === undefined ||
@@ -40,6 +63,8 @@ export default function CanvasEdge({
pos === Position.Top || pos === Position.Bottom;
let edgePath = "";
+ let labelX = 0;
+ let labelY = 0;
// Use Bezier for strictly linear connections (horizontal-horizontal or vertical-vertical)
// Use SmoothStep for orthogonal/mixed connections (horizontal-vertical)
@@ -66,7 +91,7 @@ export default function CanvasEdge({
const innerSource = getInnerPoint(sourceX, sourceY, sourcePos);
const innerTarget = getInnerPoint(targetX, targetY, targetPos);
- const [bezierPath] = getBezierPath({
+ const [bezierPath, bX, bY] = getBezierPath({
sourceX: innerSource.x,
sourceY: innerSource.y,
sourcePosition: sourcePos,
@@ -75,12 +100,15 @@ export default function CanvasEdge({
targetPosition: targetPos,
});
+ labelX = bX;
+ labelY = bY;
+
// Remove the starting "M x y" from the bezier path to append it to our custom start
const bezierCurveOnly = bezierPath.replace(/^M[^C]+/, "");
edgePath = `M ${sourceX} ${sourceY} L ${innerSource.x} ${innerSource.y}${bezierCurveOnly} L ${targetX} ${targetY}`;
} else {
- [edgePath] = getSmoothStepPath({
+ const [smoothPath, sX, sY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition: sourcePos,
@@ -90,30 +118,80 @@ export default function CanvasEdge({
borderRadius: 16,
offset: 42,
});
+ edgePath = smoothPath;
+ labelX = sX;
+ labelY = sY;
}
const weight = (data?.weight as number) || 0;
const strokeWidth = 1.5 + weight * 1.5;
const opacity = selected ? 1 : Math.min(0.4 + weight * 0.1, 1);
+ const edgeLabel = data?.label || label;
return (
-
- 1
- ? `drop-shadow(0 0 ${weight}px currentColor)`
- : undefined,
- ...style,
- }}
- />
-
+ <>
+
+ 1
+ ? `drop-shadow(0 0 ${weight}px currentColor)`
+ : undefined,
+ ...style,
+ }}
+ />
+
+ {isEditing ? (
+
+
+ setInputValue(e.target.value)}
+ onBlur={() => data?.onLabelSubmit?.(id, inputValue)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ data?.onLabelSubmit?.(id, inputValue);
+ }
+ if (e.key === "Escape") {
+ data?.onLabelCancel?.(id);
+ }
+ }}
+ />
+
+
+ ) : (
+ edgeLabel && (
+
+
+ {edgeLabel}
+
+
+ )
+ )}
+ >
);
}
diff --git a/src/app/components/project/ChecklistBlock.tsx b/src/app/components/project/ChecklistBlock.tsx
index 4af48d3..2443e72 100644
--- a/src/app/components/project/ChecklistBlock.tsx
+++ b/src/app/components/project/ChecklistBlock.tsx
@@ -16,6 +16,8 @@ import {
import { BlockData } from "./CanvasBlock";
import { DEFAULT_BLOCK_WIDTH, DEFAULT_BLOCK_HEIGHT } from "./utils/constants";
import "./checklist-block.css";
+import { BlockReactions } from "./BlockReactions";
+import { useBlockReactions } from "./hooks/useBlockReactions";
type ChecklistBlockProps = NodeProps> & {
isReadOnly?: boolean;
@@ -32,6 +34,27 @@ const ChecklistBlock = memo(({ id, data, selected }: ChecklistBlockProps) => {
const { rippleRef } = useTouch();
const { setNodes, getNode, getEdges } = useReactFlow();
+ const currentUser = data.currentUser;
+ const projectOwnerId = data.projectOwnerId;
+ const ownerId = data.ownerId;
+ const isPreviewMode = data.isPreviewMode;
+ const isLocked = data.isLocked;
+
+ const isProjectOwner = currentUser?.id && projectOwnerId === currentUser.id;
+ const isOwner = currentUser?.id && ownerId === currentUser.id;
+ const isReadOnly =
+ isPreviewMode || (isLocked ? !isOwner && !isProjectOwner : false);
+
+ const { handleReact, handleRemoveReaction } = useBlockReactions({
+ id,
+ data,
+ currentUser,
+ isReadOnly,
+ });
+
+ const isBeingMoved = !!data.movingUserColor;
+ const borderColor = isBeingMoved ? data.movingUserColor : "var(--border)";
+
const [title, setTitle] = useState(data.title || "");
useEffect(() => {
@@ -41,45 +64,22 @@ const ChecklistBlock = memo(({ id, data, selected }: ChecklistBlockProps) => {
}, [data.title]);
const items: ChecklistItem[] = useMemo(() => {
- if (!data.metadata) return [];
try {
const meta =
typeof data.metadata === "string"
- ? JSON.parse(data.metadata)
- : data.metadata;
- return Array.isArray(meta.items) ? meta.items : [];
- } catch {
+ ? JSON.parse(data.metadata || "{}")
+ : data.metadata || {};
+ return meta.items || [];
+ } catch (_) {
return [];
}
}, [data.metadata]);
- const { total, completed, percentage, status } = useMemo(() => {
- const total = items.length;
- const completed = items.filter((item) => item.checked).length;
- const percentage = total > 0 ? (completed / total) * 100 : 0;
-
- let status = "empty";
- if (total === 0) status = "empty";
- else if (percentage === 100) status = "complete";
- else if (percentage > 0) status = "in-progress";
- else status = "not-started";
-
- return { total, completed, percentage, status };
- }, [items]);
-
- const currentUser = data.currentUser;
- const projectOwnerId = data.projectOwnerId;
- const ownerId = data.ownerId;
- const isPreviewMode = data.isPreviewMode;
- const isLocked = data.isLocked;
-
- const isProjectOwner = currentUser?.id && projectOwnerId === currentUser.id;
- const isOwner = currentUser?.id && ownerId === currentUser.id;
- const isReadOnly =
- isPreviewMode || (isLocked ? !isOwner && !isProjectOwner : false);
-
- const isBeingMoved = !!data.movingUserColor;
- const borderColor = isBeingMoved ? data.movingUserColor : "var(--border)";
+ const total = items.length;
+ const completed = items.filter((i) => i.checked).length;
+ const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
+ const status =
+ percentage === 100 ? "complete" : percentage > 0 ? "in-progress" : "idle";
const onLongPress = useCallback((e: React.TouchEvent | TouchEvent) => {
const target = e.target as HTMLElement;
@@ -116,6 +116,7 @@ const ChecklistBlock = memo(({ id, data, selected }: ChecklistBlockProps) => {
editor,
data.metadata,
newTitle,
+ data.reactions,
);
},
[id, data, currentUser, dict],
@@ -141,6 +142,7 @@ const ChecklistBlock = memo(({ id, data, selected }: ChecklistBlockProps) => {
editor,
JSON.stringify({ ...meta, items: newItems }),
title,
+ data.reactions,
);
},
[id, data, currentUser, dict, title],
@@ -297,7 +299,7 @@ const ChecklistBlock = memo(({ id, data, selected }: ChecklistBlockProps) => {
>
{
-
+
{formatDate(data.updatedAt || "")}
@@ -409,6 +411,14 @@ const ChecklistBlock = memo(({ id, data, selected }: ChecklistBlockProps) => {
+
+
{/* Handles - Left Side */}
> & {
isReadOnly?: boolean;
@@ -32,40 +34,6 @@ interface ContactMetadata {
const ContactBlock = memo(({ id, data, selected }: ContactBlockProps) => {
const { dict, lang } = useI18n();
const { setNodes, getNode, getEdges } = useReactFlow();
-
- const initialMetadata = useMemo((): ContactMetadata => {
- const defaultMeta: ContactMetadata = {
- name: "",
- phone: "",
- email: "",
- note: "",
- };
-
- if (!data.metadata) return defaultMeta;
-
- try {
- const parsed =
- typeof data.metadata === "string"
- ? JSON.parse(data.metadata)
- : data.metadata;
-
- if (!parsed || typeof parsed !== "object") return defaultMeta;
-
- return {
- name: parsed.name ?? "",
- phone: parsed.phone ?? "",
- email: parsed.email ?? "",
- note: parsed.note ?? "",
- };
- } catch {
- return defaultMeta;
- }
- }, [data.metadata]);
-
- const [localMeta, setLocalMeta] = useState(initialMetadata);
- const [isEditing, setIsEditing] = useState(false);
- const blockRef = useRef(null);
-
const { rippleRef } = useTouch();
const currentUser = data.currentUser;
@@ -79,6 +47,28 @@ const ContactBlock = memo(({ id, data, selected }: ContactBlockProps) => {
const isReadOnly =
isPreviewMode || (isLocked ? !isOwner && !isProjectOwner : false);
+ const { handleReact, handleRemoveReaction } = useBlockReactions({
+ id,
+ data,
+ currentUser,
+ isReadOnly,
+ });
+
+ const isBeingMoved = !!data.movingUserColor;
+ const borderColor = isBeingMoved ? data.movingUserColor : "var(--border)";
+
+ const initialMetadata = useMemo((): ContactMetadata => {
+ try {
+ return JSON.parse(data.metadata || "{}");
+ } catch {
+ return { name: "", phone: "", email: "", note: "" };
+ }
+ }, [data.metadata]);
+
+ const [localMeta, setLocalMeta] = useState(initialMetadata);
+ const [isEditing, setIsEditing] = useState(false);
+ const blockRef = useRef(null);
+
const onLongPress = useCallback(
(_e: React.TouchEvent | TouchEvent) => {
if (!isReadOnly) {
@@ -108,9 +98,6 @@ const ContactBlock = memo(({ id, data, selected }: ContactBlockProps) => {
}
}, [data.title]);
- const isBeingMoved = !!data.movingUserColor;
- const borderColor = isBeingMoved ? data.movingUserColor : "var(--border)";
-
// Inputs are read-only if the block is locked/preview OR if not in edit mode
const isInputReadOnly = isReadOnly || !isEditing;
@@ -170,6 +157,7 @@ const ContactBlock = memo(({ id, data, selected }: ContactBlockProps) => {
editor,
JSON.stringify(newMeta),
title,
+ data.reactions,
);
},
[id, data, localMeta, isInputReadOnly, currentUser, dict, title],
@@ -192,6 +180,7 @@ const ContactBlock = memo(({ id, data, selected }: ContactBlockProps) => {
editor,
data.metadata,
newTitle,
+ data.reactions,
);
},
[id, data, currentUser, dict],
@@ -301,7 +290,7 @@ const ContactBlock = memo(({ id, data, selected }: ContactBlockProps) => {
>
{
-
+
{formatDate(data.updatedAt || "")}
@@ -403,6 +392,14 @@ const ContactBlock = memo(({ id, data, selected }: ContactBlockProps) => {
+
+
>;
const NoteBlock = memo(({ data, selected, id }: NoteBlockProps) => {
const { dict, lang } = useI18n();
const { getEdges } = useReactFlow();
+
+ const currentUser = data.currentUser;
+ const projectOwnerId = data.projectOwnerId;
+ const ownerId = data.ownerId;
+ const isPreviewMode = data.isPreviewMode;
+ const isLocked = data.isLocked;
+
+ const isProjectOwner = currentUser?.id && projectOwnerId === currentUser.id;
+ const isOwner = currentUser?.id && ownerId === currentUser.id;
+ const isReadOnly =
+ isPreviewMode || (isLocked ? !isOwner && !isProjectOwner : false);
+
+ const { handleReact, handleRemoveReaction } = useBlockReactions({
+ id,
+ data,
+ currentUser,
+ isReadOnly,
+ });
+
const [title, setTitle] = useState(data.title || "");
const edges = getEdges();
@@ -59,17 +80,23 @@ const NoteBlock = memo(({ data, selected, id }: NoteBlockProps) => {
(e: React.ChangeEvent) => {
const newTitle = e.target.value;
setTitle(newTitle);
+ const now = new Date().toISOString();
+ const editor =
+ currentUser?.displayName ||
+ currentUser?.username ||
+ dict.project.anonymous;
data.onContentChange?.(
id,
data.content || "",
- new Date().toISOString(),
- data.lastEditor,
+ now,
+ editor,
data.metadata ? JSON.stringify(data.metadata) : undefined,
newTitle,
+ data.reactions,
);
},
- [id, data],
+ [id, data, currentUser, dict],
);
const formatDate = (isoString: string) => {
@@ -102,6 +129,7 @@ const NoteBlock = memo(({ data, selected, id }: NoteBlockProps) => {
data.lastEditor,
data.metadata ? JSON.stringify(data.metadata) : undefined,
title,
+ data.reactions,
);
},
[
@@ -119,7 +147,7 @@ const NoteBlock = memo(({ data, selected, id }: NoteBlockProps) => {
@@ -157,7 +185,7 @@ const NoteBlock = memo(({ data, selected, id }: NoteBlockProps) => {
/>
-
+
{formatDate(data.updatedAt || "")}
@@ -174,6 +202,14 @@ const NoteBlock = memo(({ data, selected, id }: NoteBlockProps) => {
+
+
{/* Handles for connections - Left Side */}
> & {
isReadOnly?: boolean;
@@ -30,12 +32,6 @@ interface PaletteMetadata {
const PaletteBlock = memo(({ id, data, selected }: PaletteBlockProps) => {
const { dict, lang } = useI18n();
const { setNodes, getNode, getEdges } = useReactFlow();
- const [showPicker, setShowPicker] = useState(false);
- const [pickerPosition, setPickerPosition] = useState<
- { x: number; y: number } | undefined
- >(undefined);
- const [editingIndex, setEditingIndex] = useState(null);
-
const { rippleRef } = useTouch();
const currentUser = data.currentUser;
@@ -46,10 +42,24 @@ const PaletteBlock = memo(({ id, data, selected }: PaletteBlockProps) => {
const isProjectOwner = currentUser?.id && projectOwnerId === currentUser.id;
const isOwner = currentUser?.id && ownerId === currentUser.id;
-
const isReadOnly =
isPreviewMode || (isLocked ? !isOwner && !isProjectOwner : false);
+ const { handleReact, handleRemoveReaction } = useBlockReactions({
+ id,
+ data,
+ currentUser,
+ isReadOnly,
+ });
+
+ const isBeingMoved = !!data.movingUserColor;
+ const borderColor = isBeingMoved ? data.movingUserColor : "var(--border)";
+
+ const [showPicker, setShowPicker] = useState(false);
+ const [pickerPosition, setPickerPosition] = useState<
+ { x: number; y: number } | undefined
+ >(undefined);
+ const [editingIndex, setEditingIndex] = useState(null);
const [title, setTitle] = useState(data.title || "");
const metadata = useMemo(() => {
try {
@@ -79,6 +89,7 @@ const PaletteBlock = memo(({ id, data, selected }: PaletteBlockProps) => {
editor,
JSON.stringify(newMetadata),
title,
+ data.reactions,
);
},
[id, data, currentUser, dict, title],
@@ -180,6 +191,7 @@ const PaletteBlock = memo(({ id, data, selected }: PaletteBlockProps) => {
editor,
data.metadata,
newTitle,
+ data.reactions,
);
},
[id, data, currentUser, dict],
@@ -277,9 +289,6 @@ const PaletteBlock = memo(({ id, data, selected }: PaletteBlockProps) => {
const isBottomTargetConnected = isHandleConnected("bottom-target");
const isBottomSourceConnected = isHandleConnected("bottom");
- const isBeingMoved = data.movingUserColor !== undefined;
- const borderColor = data.movingUserColor || "var(--border)";
-
return (
{
>
{
-
+
{formatDate(data.updatedAt || "")}
@@ -404,6 +413,14 @@ const PaletteBlock = memo(({ id, data, selected }: PaletteBlockProps) => {
+
+
{
+ _setLinks((eds) =>
+ eds.map((edge) => {
+ if (edge.id === edgeId) {
+ return {
+ ...edge,
+ data: { ...edge.data, label, isEditing: false },
+ };
+ }
+ return edge;
+ }),
+ );
+ },
+ [_setLinks],
+ );
+
+ const handleEdgeLabelCancel = useCallback(
+ (edgeId: string) => {
+ _setLinks((eds) =>
+ eds.map((edge) => {
+ if (edge.id === edgeId) {
+ return {
+ ...edge,
+ data: { ...edge.data, isEditing: false },
+ };
+ }
+ return edge;
+ }),
+ );
+ },
+ [_setLinks],
+ );
+
+ const onLinkDoubleClick = useCallback(
+ (event: React.MouseEvent, edge: Edge) => {
+ if (isPreviewMode) return;
+ _setLinks((eds) =>
+ eds.map((e) => {
+ if (e.id === edge.id) {
+ return {
+ ...e,
+ data: {
+ ...e.data,
+ isEditing: true,
+ onLabelSubmit: handleEdgeLabelSubmit,
+ onLabelCancel: handleEdgeLabelCancel,
+ },
+ };
+ }
+ return e;
+ }),
+ );
+ },
+ [isPreviewMode, _setLinks, handleEdgeLabelSubmit, handleEdgeLabelCancel],
+ );
+
const commands = useMemo(() => {
if (isPreviewMode) return [];
@@ -572,9 +629,10 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) {
data: {
...block.data,
isPreviewMode,
+ currentUser: currentUser || undefined,
},
}));
- }, [blocks, isPreviewMode]);
+ }, [blocks, isPreviewMode, currentUser]);
return (
<>
@@ -614,459 +672,468 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) {
)}
-
-
-
+
-
-
-
- {!hasSeenOnboarding && isCoreOnly && !isPreviewMode && (
-
-
-
-
-
-
-
+
+
+
+
+ {!hasSeenOnboarding && isCoreOnly && !isPreviewMode && (
+
+
+
+
+
Magic Paste
+
{dict.project.onboardingHint}
+
-
-
Magic Paste
-
{dict.project.onboardingHint}
+
+ )}
+
+ {!isPreviewMode && (
+
+
+ {dict.project.shareCursor}
+
+ {
+ e.stopPropagation();
+ setShareCursor(e.target.checked);
+ }}
+ />
-
+ )}
- )}
-
- {!isPreviewMode && (
-
-
- {dict.project.shareCursor}
-
- {
- e.stopPropagation();
- setShareCursor(e.target.checked);
- }}
- />
-
- )}
-
-
- {isPreviewMode && (
-
-
- {dict.canvas.previewMode}
-
-
-
- {currentUser?.id === projectOwnerId && (
+
+ {isPreviewMode && (
+
+
+ {dict.canvas.previewMode}
+
+
- )}
+ {currentUser?.id === projectOwnerId && (
+
+ )}
+
-
- )}
+ )}
-
- {!isPreviewMode && (
-
- {activeUsers.map((u) => (
-
+
+ {!isPreviewMode && (
+
+ {activeUsers.map((u) => (
-

-
+
+

+
-
- {u.displayName || u.username}
-
+
+ {u.displayName || u.username}
+
+
-
- ))}
-
- )}
-
-
- {currentUser?.id === projectOwnerId && (
+ ))}
+
+ )}
+
- )}
-
+ {currentUser?.id === projectOwnerId && (
+
+ )}
+
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {contextMenu && (
-
e.stopPropagation()}
- onClick={(e) => e.stopPropagation()}
- >
- {contextMenu.type === "pane" ? (
- <>
- {!isPreviewMode && (
- <>
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {contextMenu && (
+
e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ >
+ {contextMenu.type === "pane" ? (
+ <>
+ {!isPreviewMode && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ ) : contextMenu.type === "block" ? (
+ (() => {
+ const block = contextMenuBlock;
+ if (!block || !currentUser) return null;
+ const isOwner =
+ currentUser.id &&
+ (block.data as BlockData)?.ownerId === currentUser.id;
+ const isProjectOwner =
+ currentUser.id && projectOwnerId === currentUser.id;
+ const canManage = isOwner || isProjectOwner;
+ const isLocked = !!(block.data as BlockData).isLocked;
+
+ return (
+ <>
+ {canManage && (
+ <>
+
+
+
+
+ >
+ )}
+ {!canManage && (
+
+ {dict.blocks.viewOnly || "View Only"}
+
+ )}
+ >
+ );
+ })()
+ ) : contextMenu.type === "edge" ? (
+ (() => {
+ const edgeId = contextMenu.id;
+ if (!edgeId) return null;
+
+ return (
-
- >
- )}
- >
- ) : contextMenu.type === "block" ? (
- (() => {
- const block = contextMenuBlock;
- if (!block || !currentUser) return null;
- const isOwner =
- currentUser.id &&
- (block.data as BlockData)?.ownerId === currentUser.id;
- const isProjectOwner =
- currentUser.id && projectOwnerId === currentUser.id;
- const canManage = isOwner || isProjectOwner;
- const isLocked = !!(block.data as BlockData).isLocked;
-
- return (
- <>
- {canManage && (
- <>
-
-
-
-
- >
- )}
- {!canManage && (
-
- {dict.blocks.viewOnly || "View Only"}
-
- )}
- >
- );
- })()
- ) : contextMenu.type === "edge" ? (
- (() => {
- const edgeId = contextMenu.id;
- if (!edgeId) return null;
-
- return (
-
- );
- })()
- ) : null}
-
- )}
-
+ );
+ })()
+ ) : null}
+
+ )}
+
+
{
+ navigator.clipboard
+ .writeText(errorMessage)
+ .then(() => {
+ toast.success(dict.common.copiedToClipboard);
+ })
+ .catch(() => {
+ toast.error(dict.common.failedToCopy);
+ });
+ };
+
+ return (
+
+
+
+
+ {dict.common.canvasErrorTitle}
+
+
{dict.common.canvasErrorDescription}
+
+
+
+ {dict.common.clickToCopyError}
+
+
+ {errorMessage}
+
+
+
+
+ );
+}
+
export class ProjectCanvasErrorBoundary extends Component {
public state: State = {
hasError: false,
@@ -32,35 +91,8 @@ export class ProjectCanvasErrorBoundary extends Component {
if (this.props.fallback) {
return this.props.fallback;
}
-
- return (
-
-
-
-
- Something went wrong
-
-
- We encountered an error while rendering the canvas. This might be
- due to a temporary glitch or a corrupted block.
-
-
-
- {this.state.error?.message || "Unknown error"}
-
-
-
- );
+ return ;
}
-
return this.props.children;
}
}
diff --git a/src/app/components/project/ProjectCoreBlock.tsx b/src/app/components/project/ProjectCoreBlock.tsx
index 29e828f..1110fe1 100644
--- a/src/app/components/project/ProjectCoreBlock.tsx
+++ b/src/app/components/project/ProjectCoreBlock.tsx
@@ -15,12 +15,34 @@ import { useI18n } from "@providers/I18nProvider";
import { BlockData } from "./CanvasBlock";
import MarkdownEditor from "./MarkdownEditor";
import { CORE_BLOCK_WIDTH, CORE_BLOCK_HEIGHT } from "./utils/constants";
+import { BlockReactions } from "./BlockReactions";
+import { useBlockReactions } from "./hooks/useBlockReactions";
export type ProjectCoreBlockProps = NodeProps>;
const ProjectCoreBlock = memo(
({ data, selected, id }: ProjectCoreBlockProps) => {
const { dict } = useI18n();
+ const { setNodes, getEdges } = useReactFlow();
+
+ const currentUser = data.currentUser;
+ const projectOwnerId = data.projectOwnerId;
+ const ownerId = data.ownerId;
+ const isPreviewMode = data.isPreviewMode;
+ const isLocked = data.isLocked;
+
+ const isProjectOwner = currentUser?.id && projectOwnerId === currentUser.id;
+ const isOwner = currentUser?.id && ownerId === currentUser.id;
+ const isReadOnly =
+ isPreviewMode || (isLocked ? !isOwner && !isProjectOwner : false);
+
+ const { handleReact, handleRemoveReaction } = useBlockReactions({
+ id,
+ data,
+ currentUser,
+ isReadOnly,
+ });
+
const [title, setTitle] = useState(data.content || "");
const [description, setDescription] = useState("");
const lastDimensions = useRef({
@@ -52,10 +74,24 @@ const ProjectCoreBlock = memo(
}
}, [data.content, data.metadata]);
+ const syncToYjs = useCallback(
+ (text: string) => {
+ if (!data.yText) return;
+ if (data.yText.toString() === text) return;
+
+ data.yText.doc?.transact(() => {
+ data.yText?.delete(0, data.yText.length);
+ data.yText?.insert(0, text);
+ }, data.yText.doc.clientID);
+ },
+ [data.yText],
+ );
+
const handleTitleChange = useCallback(
(e: React.ChangeEvent) => {
const newTitle = e.target.value;
setTitle(newTitle);
+ syncToYjs(newTitle);
const meta = { description };
data.onContentChange?.(
@@ -64,9 +100,11 @@ const ProjectCoreBlock = memo(
new Date().toISOString(),
data.lastEditor,
JSON.stringify(meta),
+ undefined,
+ data.reactions,
);
},
- [id, data.onContentChange, data.lastEditor, description],
+ [id, data.onContentChange, data.lastEditor, description, data.reactions],
);
const handleDescriptionChange = useCallback(
@@ -80,13 +118,13 @@ const ProjectCoreBlock = memo(
new Date().toISOString(),
data.lastEditor,
JSON.stringify(meta),
+ undefined,
+ data.reactions,
);
},
- [id, data.onContentChange, data.lastEditor, title],
+ [id, data.onContentChange, data.lastEditor, title, data.reactions],
);
- const { setNodes, getEdges } = useReactFlow();
-
const edges = getEdges();
const isHandleConnected = (handleId: string) =>
edges.some(
@@ -134,7 +172,7 @@ const ProjectCoreBlock = memo(
return (
<>
@@ -184,13 +222,21 @@ const ProjectCoreBlock = memo(
+
+
{/* Handles for connections (2.A) */}
> & {
isReadOnly?: boolean;
@@ -134,13 +136,6 @@ const SketchBlock = memo(({ id, data, selected }: SketchBlockProps) => {
onTouchEnd: stopPropagation,
};
- // Sync title
- useEffect(() => {
- if (data.title !== undefined && data.title !== title) {
- setTitle(data.title);
- }
- }, [data.title]);
-
const currentUser = data.currentUser;
const projectOwnerId = data.projectOwnerId;
const ownerId = data.ownerId;
@@ -152,9 +147,46 @@ const SketchBlock = memo(({ id, data, selected }: SketchBlockProps) => {
const isReadOnly =
isPreviewMode || (isLocked ? !isOwner && !isProjectOwner : false);
+ const { handleReact, handleRemoveReaction } = useBlockReactions({
+ id,
+ data,
+ currentUser,
+ isReadOnly,
+ });
+
const isBeingMoved = !!data.movingUserColor;
const borderColor = isBeingMoved ? data.movingUserColor : "var(--border)";
+ const handleTitleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const newTitle = e.target.value;
+ setTitle(newTitle);
+ const now = new Date().toISOString();
+ const editor =
+ currentUser?.displayName ||
+ currentUser?.username ||
+ dict.project.anonymous;
+
+ data.onContentChange?.(
+ id,
+ data.content,
+ now,
+ editor,
+ JSON.stringify({ strokes }),
+ newTitle,
+ data.reactions,
+ );
+ },
+ [id, data, currentUser, dict, strokes],
+ );
+
+ // Sync title
+ useEffect(() => {
+ if (data.title !== undefined && data.title !== title) {
+ setTitle(data.title);
+ }
+ }, [data.title]);
+
// Initialize from metadata
useEffect(() => {
if (data.metadata && !isLoaded) {
@@ -196,7 +228,6 @@ const SketchBlock = memo(({ id, data, selected }: SketchBlockProps) => {
}
}, [data.metadata, isLoaded]);
- // Persistence
const save = useCallback(
(newStrokes: Stroke[]) => {
try {
@@ -213,6 +244,7 @@ const SketchBlock = memo(({ id, data, selected }: SketchBlockProps) => {
editorName,
JSON.stringify({ strokes: newStrokes }),
title,
+ data.reactions,
);
} catch (e) {
console.error("Failed to save sketch", e);
@@ -322,29 +354,6 @@ const SketchBlock = memo(({ id, data, selected }: SketchBlockProps) => {
triggerSave([]);
};
- const handleTitleChange = useCallback(
- (e: React.ChangeEvent) => {
- const newTitle = e.target.value;
- setTitle(newTitle);
-
- const now = new Date().toISOString();
- const editorName =
- currentUser?.displayName ||
- currentUser?.username ||
- dict.project.anonymous;
-
- data.onContentChange?.(
- id,
- data.content,
- now,
- editorName,
- JSON.stringify({ strokes }),
- newTitle,
- );
- },
- [data, id, currentUser, dict, strokes],
- );
-
const formatDate = (isoString: string) => {
if (!isoString) return "";
const date = new Date(isoString);
@@ -425,14 +434,7 @@ const SketchBlock = memo(({ id, data, selected }: SketchBlockProps) => {
);
return (
-
+ <>
{
onResize={handleResize}
onResizeEnd={handleResizeEnd}
/>
+
+
+ {/* Header */}
+
+
+
+
+ {dict.blocks.blockTypeSketch || "Sketch"}
+
+
+
+
+
+
- {/* Header */}
-
-
-
-
- {dict.blocks.blockTypeSketch || "Sketch"}
-
-
-
-
-
-
-
- {!isReadOnly && (
-
- {/* Pen Tool */}
-
-
-
+
{formatDate(data.updatedAt || "")}
@@ -473,6 +485,14 @@ const SnippetBlock = memo(({ id, data, selected }: SnippetBlockProps) => {
+
+
{/* Handles - Left Side */}
UserPresence | undefined;
+}
+
+const UserMapContext = createContext({
+ resolveUser: () => undefined,
+});
+
+export const useUserMap = () => useContext(UserMapContext);
+
+export const UserMapProvider = ({
+ children,
+ activeUsers,
+}: {
+ children: ReactNode;
+ activeUsers: UserPresence[];
+}) => {
+ const userMap = useMemo(() => {
+ const map = new Map();
+ activeUsers.forEach((u) => map.set(u.id, u));
+ return map;
+ }, [activeUsers]);
+
+ const resolveUser = (userId: string) => {
+ return userMap.get(userId);
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/app/components/project/VideoBlock.tsx b/src/app/components/project/VideoBlock.tsx
index 1c06442..3975388 100644
--- a/src/app/components/project/VideoBlock.tsx
+++ b/src/app/components/project/VideoBlock.tsx
@@ -16,6 +16,8 @@ import {
} from "@xyflow/react";
import { BlockData } from "./CanvasBlock";
import { DEFAULT_BLOCK_WIDTH, DEFAULT_BLOCK_HEIGHT } from "./utils/constants";
+import { BlockReactions } from "./BlockReactions";
+import { useBlockReactions } from "./hooks/useBlockReactions";
type VideoBlockProps = NodeProps> & {
isReadOnly?: boolean;
@@ -25,6 +27,28 @@ const VideoBlock = memo(({ id, data, selected }: VideoBlockProps) => {
const { dict, lang } = useI18n();
const { rippleRef } = useTouch();
const { setNodes, getNode, getEdges } = useReactFlow();
+
+ const currentUser = data.currentUser;
+ const projectOwnerId = data.projectOwnerId;
+ const ownerId = data.ownerId;
+ const isPreviewMode = data.isPreviewMode;
+ const isLocked = data.isLocked;
+
+ const isProjectOwner = currentUser?.id && projectOwnerId === currentUser.id;
+ const isOwner = currentUser?.id && ownerId === currentUser.id;
+ const isReadOnly =
+ isPreviewMode || (isLocked ? !isOwner && !isProjectOwner : false);
+
+ const { handleReact, handleRemoveReaction } = useBlockReactions({
+ id,
+ data,
+ currentUser,
+ isReadOnly,
+ });
+
+ const isBeingMoved = !!data.movingUserColor;
+ const borderColor = isBeingMoved ? data.movingUserColor : "var(--border)";
+
const [url, setUrl] = useState(data.content || "");
const [title, setTitle] = useState(data.title || "");
const [isEditing, setIsEditing] = useState(false);
@@ -78,20 +102,6 @@ const VideoBlock = memo(({ id, data, selected }: VideoBlockProps) => {
return () => yText.unobserve(observer);
}, [data.yText, isEditing, url]);
- const currentUser = data.currentUser;
- const projectOwnerId = data.projectOwnerId;
- const ownerId = data.ownerId;
- const isPreviewMode = data.isPreviewMode;
- const isLocked = data.isLocked;
-
- const isProjectOwner = currentUser?.id && projectOwnerId === currentUser.id;
- const isOwner = currentUser?.id && ownerId === currentUser.id;
- const isReadOnly =
- isPreviewMode || (isLocked ? !isOwner && !isProjectOwner : false);
-
- const isBeingMoved = !!data.movingUserColor;
- const borderColor = isBeingMoved ? data.movingUserColor : "var(--border)";
-
const onLongPress = useCallback((e: React.TouchEvent | TouchEvent) => {
const target = e.target as HTMLElement;
const event = new MouseEvent("contextmenu", {
@@ -127,6 +137,7 @@ const VideoBlock = memo(({ id, data, selected }: VideoBlockProps) => {
editor,
data.metadata,
newTitle,
+ data.reactions,
);
},
[id, data, currentUser, dict],
@@ -144,7 +155,15 @@ const VideoBlock = memo(({ id, data, selected }: VideoBlockProps) => {
currentUser?.username ||
dict.project.anonymous;
- data.onContentChange?.(id, newUrl, now, editor, data.metadata, title);
+ data.onContentChange?.(
+ id,
+ newUrl,
+ now,
+ editor,
+ data.metadata,
+ title,
+ data.reactions,
+ );
},
[id, data, currentUser, dict, syncToYjs, title],
);
@@ -275,7 +294,7 @@ const VideoBlock = memo(({ id, data, selected }: VideoBlockProps) => {
>
{
)}
-
+
{formatDate(data.updatedAt || "")}
@@ -358,6 +377,14 @@ const VideoBlock = memo(({ id, data, selected }: VideoBlockProps) => {
+
+
{
+ const { setNodes } = useReactFlow();
+ const { dict } = useI18n();
+
+ const handleReact = useCallback(
+ (emoji: string) => {
+ if (isReadOnly) return;
+
+ const currentReactions = data.reactions || [];
+ const existingIndex = currentReactions.findIndex(
+ (r) => r.emoji === emoji,
+ );
+ const userId = currentUser?.id;
+ if (!userId) return;
+
+ let newReactions;
+ if (existingIndex > -1) {
+ const reaction = currentReactions[existingIndex];
+ const hasReacted = reaction.users.some((u) =>
+ typeof u === "string" ? u === userId : u.id === userId,
+ );
+
+ if (hasReacted) return;
+
+ newReactions = [...currentReactions];
+ newReactions[existingIndex] = {
+ ...reaction,
+ count: reaction.count + 1,
+ users: [
+ ...reaction.users,
+ {
+ id: userId,
+ username:
+ currentUser?.username ||
+ currentUser?.displayName ||
+ dict.project.anonymous,
+ },
+ ],
+ };
+ } else {
+ newReactions = [
+ ...currentReactions,
+ {
+ emoji,
+ count: 1,
+ users: [
+ {
+ id: userId,
+ username:
+ currentUser?.username ||
+ currentUser?.displayName ||
+ dict.project.anonymous,
+ },
+ ],
+ },
+ ];
+ }
+
+ const now = new Date().toISOString();
+ const editor =
+ currentUser?.displayName ||
+ currentUser?.username ||
+ dict.project.anonymous;
+
+ if (setNodes) {
+ setNodes((nds) =>
+ nds.map((n) =>
+ n.id === id
+ ? {
+ ...n,
+ data: {
+ ...n.data,
+ reactions: newReactions,
+ updatedAt: now,
+ lastEditor: editor,
+ },
+ }
+ : n,
+ ),
+ );
+ }
+
+ data.onContentChange?.(
+ id,
+ data.content,
+ now,
+ editor,
+ data.metadata,
+ data.title,
+ newReactions,
+ );
+ },
+ [id, data, isReadOnly, currentUser, dict.project.anonymous, setNodes],
+ );
+
+ const handleRemoveReaction = useCallback(
+ (emoji: string) => {
+ if (isReadOnly) return;
+
+ const currentReactions = data.reactions || [];
+ const existingIndex = currentReactions.findIndex(
+ (r) => r.emoji === emoji,
+ );
+ const userId = currentUser?.id;
+ if (!userId || existingIndex === -1) return;
+
+ const reaction = currentReactions[existingIndex];
+ const hasReacted = reaction.users.some((u) =>
+ typeof u === "string" ? u === userId : u.id === userId,
+ );
+
+ if (!hasReacted) return;
+
+ let newReactions = [...currentReactions];
+ const newUsers = reaction.users.filter((u) =>
+ typeof u === "string" ? u !== userId : u.id !== userId,
+ );
+
+ if (newUsers.length === 0) {
+ newReactions = newReactions.filter((r) => r.emoji !== emoji);
+ } else {
+ newReactions[existingIndex] = {
+ ...reaction,
+ count: reaction.count - 1,
+ users: newUsers,
+ };
+ }
+
+ const now = new Date().toISOString();
+ const editor =
+ currentUser?.displayName ||
+ currentUser?.username ||
+ dict.project.anonymous;
+
+ if (setNodes) {
+ setNodes((nds) =>
+ nds.map((n) =>
+ n.id === id
+ ? {
+ ...n,
+ data: {
+ ...n.data,
+ reactions: newReactions,
+ updatedAt: now,
+ lastEditor: editor,
+ },
+ }
+ : n,
+ ),
+ );
+ }
+
+ data.onContentChange?.(
+ id,
+ data.content,
+ now,
+ editor,
+ data.metadata,
+ data.title,
+ newReactions,
+ );
+ },
+ [id, data, isReadOnly, currentUser, dict.project.anonymous, setNodes],
+ );
+
+ return { handleReact, handleRemoveReaction };
+};
diff --git a/src/app/components/project/hooks/useProjectCanvasGraph.ts b/src/app/components/project/hooks/useProjectCanvasGraph.ts
index aa4b422..41b545e 100644
--- a/src/app/components/project/hooks/useProjectCanvasGraph.ts
+++ b/src/app/components/project/hooks/useProjectCanvasGraph.ts
@@ -273,6 +273,7 @@ export const useProjectCanvasGraph = ({
type: "connection",
markerEnd: "connection-arrow",
data: { label: "" },
+ zIndex: 2000,
};
setLinks((lks) => addEdge(link, lks || []));
@@ -293,21 +294,19 @@ export const useProjectCanvasGraph = ({
(_: React.MouseEvent, block: Node) => {
if (block.type === "core") return;
- const adjustedPos = getAdjustedPosition(
- {
- x: block.position.x,
- y: block.position.y,
- width: block.measured?.width || block.width || DEFAULT_BLOCK_WIDTH,
- height:
- block.measured?.height || block.height || DEFAULT_BLOCK_HEIGHT,
- },
- {
- x: CORE_BLOCK_X,
- y: CORE_BLOCK_Y,
- width: CORE_BLOCK_WIDTH,
- height: CORE_BLOCK_HEIGHT,
- },
- );
+ const blockRect = {
+ x: block.position.x,
+ y: block.position.y,
+ width: block.measured?.width || block.width || DEFAULT_BLOCK_WIDTH,
+ height: block.measured?.height || block.height || DEFAULT_BLOCK_HEIGHT,
+ };
+
+ const adjustedPos = getAdjustedPosition(blockRect, {
+ x: CORE_BLOCK_X,
+ y: CORE_BLOCK_Y,
+ width: CORE_BLOCK_WIDTH,
+ height: CORE_BLOCK_HEIGHT,
+ });
const adjustedBlock = {
...block,
@@ -328,21 +327,19 @@ export const useProjectCanvasGraph = ({
if (block.type === "core") return;
updateMyPresence({ draggingBlockId: null });
- const adjustedPos = getAdjustedPosition(
- {
- x: block.position.x,
- y: block.position.y,
- width: block.measured?.width || block.width || DEFAULT_BLOCK_WIDTH,
- height:
- block.measured?.height || block.height || DEFAULT_BLOCK_HEIGHT,
- },
- {
- x: CORE_BLOCK_X,
- y: CORE_BLOCK_Y,
- width: CORE_BLOCK_WIDTH,
- height: CORE_BLOCK_HEIGHT,
- },
- );
+ const blockRect = {
+ x: block.position.x,
+ y: block.position.y,
+ width: block.measured?.width || block.width || DEFAULT_BLOCK_WIDTH,
+ height: block.measured?.height || block.height || DEFAULT_BLOCK_HEIGHT,
+ };
+
+ const adjustedPos = getAdjustedPosition(blockRect, {
+ x: CORE_BLOCK_X,
+ y: CORE_BLOCK_Y,
+ width: CORE_BLOCK_WIDTH,
+ height: CORE_BLOCK_HEIGHT,
+ });
const adjustedBlock = {
...block,
@@ -357,7 +354,7 @@ export const useProjectCanvasGraph = ({
),
});
},
- [applyMutation, updateMyPresence, links, blocks, setLinks],
+ [applyMutation, updateMyPresence],
);
const onContentChange = useCallback(
@@ -368,6 +365,11 @@ export const useProjectCanvasGraph = ({
lastEditor: string,
metadata?: string,
title?: string,
+ reactions?: {
+ emoji: string;
+ count: number;
+ users: (string | { id: string; username: string })[];
+ }[],
) => {
setBlocks((blocks) =>
blocks.map((b) =>
@@ -381,6 +383,7 @@ export const useProjectCanvasGraph = ({
lastEditor,
...(metadata !== undefined ? { metadata } : {}),
...(title !== undefined ? { title } : {}),
+ ...(reactions !== undefined ? { reactions } : {}),
},
}
: b,
diff --git a/src/app/components/project/hooks/useProjectCanvasState.ts b/src/app/components/project/hooks/useProjectCanvasState.ts
index b0f2b9f..d5174a9 100644
--- a/src/app/components/project/hooks/useProjectCanvasState.ts
+++ b/src/app/components/project/hooks/useProjectCanvasState.ts
@@ -31,6 +31,9 @@ const cleanBlockDataForSync = (
onCaretMove: _onCaretMove,
onResize: _onResize,
onResizeEnd: _onResizeEnd,
+ currentUser: _currentUser,
+ initialProjectId: _initialProjectId,
+ projectOwnerId: _projectOwnerId,
...rest
} = data;
return rest;
@@ -203,7 +206,18 @@ export const useProjectCanvasState = (
const rl = yLinks.get(key);
if (rl) {
if (index >= 0) {
- next[index] = { ...rl, selected: next[index].selected };
+ const localData = next[index].data as Record;
+ const remoteData = rl.data as Record;
+ next[index] = {
+ ...rl,
+ selected: next[index].selected,
+ data: {
+ ...remoteData,
+ isEditing: localData?.isEditing,
+ onLabelSubmit: localData?.onLabelSubmit,
+ onLabelCancel: localData?.onLabelCancel,
+ },
+ };
} else {
next.push({ ...rl, selected: false } as Edge);
}
@@ -356,6 +370,20 @@ export const useProjectCanvasState = (
if (isSummaryUpdate && isExistingDetailed) {
return;
}
+ const isUpgradeToDetailed =
+ !isSummaryUpdate && existing?.data?.isSummary;
+ if (isUpgradeToDetailed) {
+ const currentYText = yContents.get(block.id);
+ const newContent = (block.data?.content as string) || "";
+ if (
+ currentYText &&
+ currentYText.toString() === "" &&
+ newContent !== ""
+ ) {
+ currentYText.delete(0, currentYText.length);
+ currentYText.insert(0, newContent);
+ }
+ }
const hasChanged =
!existing ||
@@ -405,7 +433,14 @@ export const useProjectCanvasState = (
if (!isPreviewModeRef.current) {
yLinks.doc?.transact(() => {
nextLinks.forEach((link) => {
- const { selected, ...linkToSync } = link;
+ const { selected, data, ...rest } = link;
+ const {
+ isEditing: _,
+ onLabelSubmit: __,
+ onLabelCancel: ___,
+ ...cleanData
+ } = (data as Record) || {};
+ const linkToSync = { ...rest, data: cleanData };
const existing = yLinks.get(link.id);
const hasChanged =
diff --git a/src/app/components/project/hooks/useProjectData.ts b/src/app/components/project/hooks/useProjectData.ts
index 5b7ab2e..ce42da5 100644
--- a/src/app/components/project/hooks/useProjectData.ts
+++ b/src/app/components/project/hooks/useProjectData.ts
@@ -78,6 +78,7 @@ export const useProjectData = ({
...l,
type: l.type || "connection",
markerEnd: "connection-arrow",
+ zIndex: 2000,
}),
);
diff --git a/src/app/components/project/hooks/useTouchGestures.ts b/src/app/components/project/hooks/useTouchGestures.ts
index 62f38c9..196b090 100644
--- a/src/app/components/project/hooks/useTouchGestures.ts
+++ b/src/app/components/project/hooks/useTouchGestures.ts
@@ -23,7 +23,7 @@ export const useTouchGestures = ({
onDoubleTap,
longPressDelay = 500,
doubleTapDelay = 300,
- moveThreshold = 10,
+ moveThreshold = 25,
stopPropagation = false,
}: UseTouchGesturesProps) => {
const timerRef = useRef(null);
diff --git a/src/app/components/project/utils/collision.ts b/src/app/components/project/utils/collision.ts
index 37139cb..40eec13 100644
--- a/src/app/components/project/utils/collision.ts
+++ b/src/app/components/project/utils/collision.ts
@@ -66,3 +66,38 @@ export function getAdjustedPosition(
return { x: adjustedX, y: adjustedY };
}
+
+/**
+ * Finds the closest rect within a threshold.
+ */
+export function getClosestRect(
+ sourceRect: Rect,
+ targets: { id: string; rect: Rect }[],
+ threshold: number = 150,
+): string | null {
+ let closestId: string | null = null;
+ let minDistance = threshold;
+
+ const sourceCenter = {
+ x: sourceRect.x + sourceRect.width / 2,
+ y: sourceRect.y + sourceRect.height / 2,
+ };
+
+ for (const target of targets) {
+ const targetCenter = {
+ x: target.rect.x + target.rect.width / 2,
+ y: target.rect.y + target.rect.height / 2,
+ };
+
+ const dx = sourceCenter.x - targetCenter.x;
+ const dy = sourceCenter.y - targetCenter.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (distance < minDistance) {
+ minDistance = distance;
+ closestId = target.id;
+ }
+ }
+
+ return closestId;
+}
diff --git a/src/app/db/migrations/19AddLabelsAndReactions.ts b/src/app/db/migrations/19AddLabelsAndReactions.ts
new file mode 100644
index 0000000..213c8bf
--- /dev/null
+++ b/src/app/db/migrations/19AddLabelsAndReactions.ts
@@ -0,0 +1,25 @@
+import { Kysely } from "kysely";
+import { database } from "../../lib/types/db";
+
+export async function up(db: Kysely): Promise {
+ await db.schema.alterTable("links").addColumn("label", "text").execute();
+
+ await db.schema
+ .createTable("blockReactions")
+ .ifNotExists()
+ .addColumn("id", "text", (col) => col.primaryKey())
+ .addColumn("blockId", "text", (col) =>
+ col.notNull().references("blocks.id").onDelete("cascade"),
+ )
+ .addColumn("userId", "text", (col) =>
+ col.notNull().references("users.id").onDelete("cascade"),
+ )
+ .addColumn("emoji", "text", (col) => col.notNull())
+ .addColumn("createdAt", "text", (col) => col.notNull())
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropTable("blockReactions").execute();
+ await db.schema.alterTable("links").dropColumn("label").execute();
+}
diff --git a/src/app/global.css b/src/app/global.css
index d560d04..da942bc 100644
--- a/src/app/global.css
+++ b/src/app/global.css
@@ -22,7 +22,7 @@
--sidebar-collapsed-width: 0px;
--core-block-width: 640px;
--core-block-height: 480px;
- --accent: #004225;
+ --accent: #000080;
--warning: #f2c94c;
--danger: #eb5757;
--toast-bg: #404040;
@@ -470,7 +470,8 @@ body {
background-color: var(--user-color, #000);
}
-.user-presence-item:hover .user-presence-tooltip {
+.user-presence-item:hover .user-presence-tooltip,
+.reaction-badge:hover .user-presence-tooltip {
opacity: 1;
}
@@ -2147,6 +2148,42 @@ input:-webkit-autofill:active {
color: var(--danger);
}
+.error-message-hint {
+ display: block;
+ width: 100%;
+ text-align: left;
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ opacity: 0.6;
+ padding-left: 10px;
+}
+
+.error-message {
+ background-color: var(--bg-sidebar);
+ padding: 1rem;
+ text-align: center;
+ font-size: 0.75rem;
+ font-family: monospace;
+ color: var(--text-muted);
+ max-width: 56rem;
+ width: 100%;
+ cursor: pointer;
+ border-radius: 0.5rem;
+ border: 1px solid var(--border);
+ transition: all 0.2s ease;
+}
+
+.error-message:hover {
+ background-color: rgba(128, 128, 128, 0.1);
+}
+
+.error-message:active {
+ transform: scale(0.99);
+}
+
.github-error-hint {
font-size: 0.75rem;
opacity: 0.6;
@@ -2517,6 +2554,14 @@ input:-webkit-autofill:active {
text-overflow: ellipsis;
}
+.mr-2 {
+ margin-right: 8px;
+}
+
+.ml-2 {
+ margin-left: 8px;
+}
+
.\!ml-12 {
margin-left: 48px !important;
}
@@ -3254,6 +3299,10 @@ input:-webkit-autofill:active {
/* Connection handles styles */
+.react-flow__edge {
+ z-index: 2000 !important;
+}
+
.mental-canvas .react-flow__edge-path {
stroke: var(--text-main);
opacity: 0.3;
@@ -4053,6 +4102,133 @@ input:-webkit-autofill:active {
transition: 0.4s;
}
+/* Block Reactions Styles */
+.block-reactions-container {
+ position: absolute;
+ top: 100%;
+ right: 16px;
+ left: auto;
+ transform: translateY(-50%);
+ z-index: 100;
+ pointer-events: auto;
+}
+
+.reactions-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.reactions-list {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background-color: var(--bg-island);
+ border: 1px solid var(--border);
+ border-radius: 9999px;
+ padding: 4px 6px;
+ box-shadow:
+ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
+ 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+}
+
+.reaction-badge {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 6px;
+ border-radius: 9999px;
+ font-size: 12px;
+ line-height: 1;
+ cursor: pointer;
+ border: 1px solid transparent;
+ background-color: transparent;
+ color: var(--text-secondary);
+ transition: all 0.2s;
+}
+
+.reaction-badge:hover {
+ background-color: rgba(255, 255, 255, 0.05);
+ color: var(--text-main);
+}
+
+.reaction-badge.active {
+ background-color: rgba(var(--accent-rgb), 0.1);
+ border-color: rgba(var(--accent-rgb), 0.2);
+ color: var(--accent);
+}
+
+.add-reaction-group {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.add-reaction-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background-color: var(--bg-island);
+ border: 1px solid var(--border);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.2s;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+}
+
+.add-reaction-btn:hover,
+.add-reaction-btn.active {
+ background-color: var(--bg-island);
+ border-color: var(--text-secondary);
+ color: var(--text-main);
+ transform: scale(1.1);
+}
+
+.emoji-picker-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 998;
+}
+
+.emoji-picker-tooltip {
+ position: absolute;
+ bottom: 100%;
+ right: 0;
+ left: auto;
+ transform: none;
+ margin-bottom: 8px;
+ background-color: var(--bg-island);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 8px;
+ display: flex;
+ gap: 4px;
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
+ z-index: 999;
+}
+
+.emoji-option-btn {
+ font-size: 16px;
+ padding: 6px;
+ border-radius: 6px;
+ cursor: pointer;
+ background: transparent;
+ border: none;
+ transition: background-color 0.2s;
+}
+
+.emoji-option-btn:hover {
+ background-color: rgba(255, 255, 255, 0.05);
+ transform: scale(1.2);
+}
+
+.emoji-option-btn.active {
+ background-color: rgba(var(--accent-rgb), 0.1);
+}
+
.theme-checkbox:checked::before {
left: calc(100% - 2.25em - 0.438em);
background-position: 0;
diff --git a/src/app/i18n/en.json b/src/app/i18n/en.json
index 8735a15..ad70efb 100644
--- a/src/app/i18n/en.json
+++ b/src/app/i18n/en.json
@@ -86,6 +86,7 @@
},
"blocks": {
"addColor": "Add Color",
+ "addReaction": "Add Reaction",
"addTask": "Add task",
"blockTransferred": "Ownership transferred successfully",
"blockTypeChecklist": "Checklist",
@@ -153,6 +154,7 @@
"phonePlaceholder": "+00 000 000 000",
"pulls": "Open Pull Requests",
"release": "Latest Release",
+ "searchEmoji": "Search Emoji",
"stars": "Stars",
"taskPlaceholder": "Task...",
"title": "Title",
@@ -196,11 +198,17 @@
"enabled": "Enabled",
"error": "Error",
"export": "Export",
+ "failedToCopy": "Failed to copy",
"import": "Import",
"loading": "Loading...",
"me": "Me",
"notFound": "Not found",
"refresh": "Refresh",
+ "reloadPage": "Reload Page",
+ "contactSupport": "Contact Support",
+ "canvasErrorTitle": "Something went wrong",
+ "canvasErrorDescription": "We encountered an error while rendering the canvas. This might be due to a temporary glitch or a corrupted block.",
+ "clickToCopyError": "Error message (click to copy it) :",
"rename": "Rename",
"restore": "Restore",
"save": "Save",
@@ -210,6 +218,7 @@
"showMore": "Show more",
"success": "Success",
"unknown": "Unknown",
+ "unknownError": "Unknown error",
"update": "Update"
},
"dashboard": {
@@ -229,7 +238,8 @@
"statusMine": "Mine",
"statusShared": "Shared",
"team": "Team",
- "trash": "Trash"
+ "trash": "Trash",
+ "emptyTrashButton": "Empty Trash"
},
"gitTokens": {
"add": "Add Token",
@@ -306,6 +316,8 @@
"deletePermanently": "Delete Permanently",
"deleteProjectDescription": "Are you sure you want to delete this project? This action is irreversible.",
"deleteProjectTitle": "Delete Project?",
+ "emptyTrashTitle": "Empty Trash?",
+ "emptyTrashDescription": "Are you sure you want to permanently delete all items in the trash? This action is irreversible.",
"deleteUserDescription": "This action is irreversible. The user will lose all access to the workspace.",
"deleteUserTitle": "Delete user?",
"directInviteLabel": "Invite link:",
diff --git a/src/app/i18n/fr.json b/src/app/i18n/fr.json
index ee5ef9c..e98227b 100644
--- a/src/app/i18n/fr.json
+++ b/src/app/i18n/fr.json
@@ -86,6 +86,7 @@
},
"blocks": {
"addColor": "Ajouter une couleur",
+ "addReaction": "Ajouter une réaction",
"addTask": "Ajouter une tâche",
"blockTransferred": "Propriété transférée avec succès",
"blockTypeChecklist": "Checklist",
@@ -153,6 +154,7 @@
"phonePlaceholder": "+00 000 000 000",
"pulls": "Pull Requests Ouvertes",
"release": "Dernière Release",
+ "searchEmoji": "Rechercher un emoji",
"stars": "Étoiles",
"taskPlaceholder": "Tâche...",
"title": "Titre",
@@ -196,11 +198,17 @@
"enabled": "Activé",
"error": "Erreur",
"export": "Exporter",
+ "failedToCopy": "Échec de la copie",
"import": "Importer",
"loading": "Chargement...",
"me": "Moi",
"notFound": "Introuvable",
"refresh": "Actualiser",
+ "reloadPage": "Actualiser la page",
+ "contactSupport": "Contacter le support",
+ "canvasErrorTitle": "Quelque chose s'est mal passé",
+ "canvasErrorDescription": "Nous avons rencontré une erreur lors du rendu du canevas. Cela peut être dû à un problème temporaire ou à un bloc corrompu.",
+ "clickToCopyError": "Message d'erreur (cliquez pour copier) :",
"rename": "Renommer",
"restore": "Restaurer",
"save": "Enregistrer",
@@ -210,6 +218,7 @@
"showMore": "Afficher plus",
"success": "Succès",
"unknown": "Inconnu",
+ "unknownError": "Erreur inconnue",
"update": "Mettre à jour"
},
"dashboard": {
@@ -227,10 +236,11 @@
"recent": "Récents",
"sharedWithMe": "Partagés avec moi",
"starred": "Favoris",
- "statusMine": "Perso",
+ "statusMine": "Moi",
"statusShared": "Partagé",
"team": "Équipe",
- "trash": "Corbeille"
+ "trash": "Corbeille",
+ "emptyTrashButton": "Vider la corbeille"
},
"gitTokens": {
"add": "Ajouter un jeton",
@@ -307,6 +317,9 @@
"deletePermanently": "Supprimer définitivement",
"deleteProjectDescription": "Êtes-vous sûr de vouloir supprimer ce projet ? Cette action est irréversible.",
"deleteProjectTitle": "Supprimer le projet ?",
+ "emptyTrashTitle": "Vider la corbeille ?",
+ "emptyTrashDescription": "Êtes-vous sûr de vouloir supprimer définitivement tous les éléments de la corbeille ? Cette action est irréversible.",
+ "exportProject": "Exporter le projet",
"deleteUserDescription": "Cette action est irréversible. L'utilisateur perdra tout accès à l'espace de travail.",
"deleteUserTitle": "Supprimer l'utilisateur ?",
"directInviteLabel": "Lien d'invitation :",
diff --git a/src/app/i18n/it.json b/src/app/i18n/it.json
index 888e508..0d82665 100644
--- a/src/app/i18n/it.json
+++ b/src/app/i18n/it.json
@@ -86,6 +86,7 @@
},
"blocks": {
"addColor": "Aggiungi Colore",
+ "addReaction": "Aggiungi reazione",
"addTask": "Aggiungi attività",
"blockTransferred": "Proprietà trasferita con successo",
"blockTypeChecklist": "Checklist",
@@ -152,7 +153,8 @@
"paletteTitle": "Colori",
"phonePlaceholder": "+00 000 000 000",
"pulls": "Pull Request Aperte",
- "release": "Ultima Release",
+ "release": "Ultima versione",
+ "searchEmoji": "Cerca Emoji",
"stars": "Stelle",
"taskPlaceholder": "Attività...",
"title": "Titolo",
@@ -196,11 +198,17 @@
"enabled": "Abilitato",
"error": "Errore",
"export": "Esporta",
+ "failedToCopy": "Copia fallita",
"import": "Importa",
"loading": "Caricamento in corso...",
"me": "Io",
"notFound": "Non trovato",
"refresh": "Aggiorna",
+ "reloadPage": "Aggiorna la pagina",
+ "contactSupport": "Contatta il supporto",
+ "canvasErrorTitle": "Qualcosa è andato storto",
+ "canvasErrorDescription": "Abbiamo riscontrato un errore durante il rendering del canvas. Potrebbe essere dovuto a un problema temporaneo o a un blocco corrotto.",
+ "clickToCopyError": "Messaggio di errore (clicca per copiare) :",
"rename": "Rinomina",
"restore": "Ripristina",
"save": "Salva",
@@ -210,6 +218,7 @@
"showMore": "Mostra altro",
"success": "Successo",
"unknown": "Sconosciuto",
+ "unknownError": "Errore sconosciuto",
"update": "Aggiorna"
},
"dashboard": {
@@ -230,7 +239,8 @@
"statusMine": "Miei",
"statusShared": "Condivisi",
"team": "Team",
- "trash": "Cestino"
+ "trash": "Cestino",
+ "emptyTrashButton": "Svuota cestino"
},
"gitTokens": {
"add": "Aggiungi Token",
@@ -307,6 +317,9 @@
"deletePermanently": "Elimina definitivamente",
"deleteProjectDescription": "Sei sicuro di voler eliminare questo progetto? Questa azione è irreversibile.",
"deleteProjectTitle": "Eliminare Progetto?",
+ "emptyTrashDescription": "Sei sicuro di voler eliminare permanentemente tutti gli elementi nel cestino? Questa azione è irreversibile.",
+ "emptyTrashTitle": "Svuota cestino?",
+ "exportProject": "Esporta progetto",
"deleteUserDescription": "Questa azione è irreversibile. L'utente perderà ogni accesso all'area di lavoro.",
"deleteUserTitle": "Eliminare l'utente?",
"directInviteLabel": "Link di invito:",
diff --git a/src/app/lib/db.ts b/src/app/lib/db.ts
index 8ce5f6c..819f2c9 100644
--- a/src/app/lib/db.ts
+++ b/src/app/lib/db.ts
@@ -69,6 +69,12 @@ export function getPostgresConfig() {
export function getSqlitePath() {
const storageDir = getStorageDir();
+
+ // Force memory or test db during tests to prevent data loss
+ if (process.env.VITEST) {
+ return process.env.SQLITE_PATH || ":memory:";
+ }
+
if (process.env.SQLITE_PATH === ":memory:") {
return ":memory:";
}
@@ -151,6 +157,14 @@ function getSqlite(): DatabaseDriver.Database {
if (state.sqliteInstance) return state.sqliteInstance;
const dbPath = getSqlitePath();
+ if (process.env.VITEST && dbPath !== ":memory:") {
+ throw new Error(
+ `CRITICAL: Vitest is attempting to connect to a physical database file at ${dbPath}. ` +
+ "Tests must ONLY use :memory: to prevent data loss. " +
+ "Check your environment variables and vitest.config.ts.",
+ );
+ }
+
if (!process.env.VITEST) {
logger.info(`Using SQLite database at: ${dbPath}`);
}
diff --git a/src/app/lib/graph.ts b/src/app/lib/graph.ts
index 7db6d73..44e0b98 100644
--- a/src/app/lib/graph.ts
+++ b/src/app/lib/graph.ts
@@ -157,6 +157,7 @@ export function transformLink(dbLink: Record): Edge {
animated?: number | boolean;
type?: string;
data?: string | Record;
+ label?: string;
markerEnd?: string;
};
@@ -172,7 +173,7 @@ export function transformLink(dbLink: Record): Edge {
type: link.type || "connection",
animated: Boolean(link.animated),
markerEnd: link.markerEnd || "connection-arrow",
- data,
+ data: { ...data, label: link.label },
};
}
@@ -190,6 +191,7 @@ export function prepareLinkForDb(link: Edge, projectId: string) {
animated: link.animated ? 1 : 0,
type: link.type || "connection",
data: JSON.stringify(link.data || {}),
+ label: (link.data as Record)?.label as string | null,
updatedAt: new Date().toISOString(),
};
}
diff --git a/src/app/lib/migrations.ts b/src/app/lib/migrations.ts
index 9ac3202..750dc39 100644
--- a/src/app/lib/migrations.ts
+++ b/src/app/lib/migrations.ts
@@ -18,6 +18,7 @@ import * as fixFolderRlsRecursionMigration from "../db/migrations/15FixFolderRls
import * as addLinkPreviewsMigration from "../db/migrations/16AddLinkPreviews";
import * as migrateLinkMetadataMigration from "../db/migrations/17MigrateLinkMetadata";
import * as addSketchBlockTypeMigration from "../db/migrations/18AddSketchBlockType";
+import * as addLabelsAndReactionsMigration from "../db/migrations/19AddLabelsAndReactions";
class StaticMigrationProvider implements MigrationProvider {
async getMigrations(): Promise> {
@@ -38,6 +39,7 @@ class StaticMigrationProvider implements MigrationProvider {
"16AddLinkPreviews": addLinkPreviewsMigration,
"17MigrateLinkMetadata": migrateLinkMetadataMigration,
"18AddSketchBlockType": addSketchBlockTypeMigration,
+ "19AddLabelsAndReactions": addLabelsAndReactionsMigration,
};
}
}
diff --git a/src/app/lib/types/db.ts b/src/app/lib/types/db.ts
index 2905ffa..076027c 100644
--- a/src/app/lib/types/db.ts
+++ b/src/app/lib/types/db.ts
@@ -28,6 +28,9 @@ export type Block = Selectable;
export type NewBlock = Insertable;
export type BlockUpdate = Updateable;
+export type BlockReaction = Selectable;
+export type NewBlockReaction = Insertable;
+
export type Link = Selectable;
export type NewLink = Insertable;
@@ -156,10 +159,19 @@ export interface linksTable {
sourceOrientation: string | null;
targetOrientation: string | null;
data: string | null; // JSON string
+ label: string | null;
createdAt: ColumnType;
updatedAt: ColumnType;
}
+export interface blockReactionsTable {
+ id: Generated;
+ blockId: string;
+ userId: string;
+ emoji: string;
+ createdAt: ColumnType;
+}
+
export interface linkPreviewsTable {
id: Generated;
blockId: string;
@@ -280,6 +292,7 @@ export interface database {
githubRepoStats: githubRepoStatsTable;
linkPreviews: linkPreviewsTable;
userGitTokens: userGitTokensTable;
+ blockReactions: blockReactionsTable;
}
export interface githubRepoStatsTable {
diff --git a/src/app/login/LoginClient.tsx b/src/app/login/LoginClient.tsx
index 0e42393..f6f7395 100644
--- a/src/app/login/LoginClient.tsx
+++ b/src/app/login/LoginClient.tsx
@@ -290,7 +290,7 @@ export function LoginClient() {
>
{dict.auth.continueWith}{" "}
- {dict.common[provider as keyof typeof dict.common]}
+ {dict.auth[provider as keyof typeof dict.auth]}
) : (
@@ -335,7 +335,7 @@ export function LoginClient() {
>
{dict.auth.continueWith}{" "}
- {dict.common[provider as keyof typeof dict.common]}
+ {dict.auth[provider as keyof typeof dict.auth]}
);
diff --git a/src/auth.ts b/src/auth.ts
index 2452e24..4c65eda 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -202,6 +202,8 @@ export const { handlers, signIn, signOut, auth } = NextAuth(async () => {
username: user.username,
displayName: user.displayName,
role: user.role,
+ avatarUrl: user.avatarUrl,
+ color: user.color,
};
},
}),
@@ -357,29 +359,55 @@ export const { handlers, signIn, signOut, auth } = NextAuth(async () => {
},
async jwt({ token, user, account }) {
if (user && account) {
- const email = user.email?.toLowerCase();
- // For OAuth providers, user.id is the provider's ID.
- // We need to find our internal user by email.
- const dbUser = await db
- .selectFrom("users")
- .select([
- "id",
- "role",
- "avatarUrl",
- "color",
- "username",
- "displayName",
- ])
- .where("email", "=", email!)
- .executeTakeFirst();
+ // 1. Credentials Provider: user object already contains internal DB data from authorize()
+ if (account.provider === "credentials") {
+ if (isAuthUser(user)) {
+ token.id = user.id;
+ token.role = user.role;
+ token.avatarUrl = user.avatarUrl;
+ token.color = user.color;
+ token.username = user.username;
+ token.displayName = user.displayName;
+ } else {
+ console.warn(
+ "[Auth] Invalid user object from credentials provider",
+ user,
+ );
+ }
+ } else {
+ // 2. OAuth Provider: user.id is provider ID, need to fetch internal user
+ const email = user.email?.toLowerCase();
+ if (email) {
+ const dbUser = await db
+ .selectFrom("users")
+ .select([
+ "id",
+ "role",
+ "avatarUrl",
+ "color",
+ "username",
+ "displayName",
+ ])
+ .where("email", "=", email)
+ .executeTakeFirst();
+
+ if (dbUser) {
+ token.id = dbUser.id;
+ token.role = dbUser.role;
+ token.avatarUrl = dbUser.avatarUrl;
+ token.color = dbUser.color;
+ token.username = dbUser.username;
+ token.displayName = dbUser.displayName;
+ }
+ }
+ }
- if (dbUser) {
- token.id = dbUser.id;
- token.role = dbUser.role;
- token.avatarUrl = dbUser.avatarUrl;
- token.color = dbUser.color;
- token.username = dbUser.username;
- token.displayName = dbUser.displayName;
+ // Log warning if ID is missing
+ if (!token.id && !token.sub) {
+ console.warn("[Auth] JWT callback: Token has no ID or SUB", {
+ accountProvider: account?.provider,
+ userEmail: user?.email,
+ });
}
}
return token;
@@ -387,11 +415,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth(async () => {
async session({ session, token }) {
if (token && session.user) {
const user = session.user as unknown as AuthUser;
- user.id = token.id as string;
- user.role = token.role as "superadmin" | "admin" | "member";
+ // Fallback to sub if id is missing
+ user.id = (token.id || token.sub) as string;
+ user.role =
+ (token.role as "superadmin" | "admin" | "member") || "member";
user.avatarUrl = token.avatarUrl as string | null;
user.color = token.color as string | null;
- user.username = token.username as string;
+ user.username = (token.username as string) || "";
user.displayName = token.displayName as string | null;
}
return session;
@@ -427,6 +457,17 @@ export interface AuthUser {
color?: string | null;
}
+export function isAuthUser(user: unknown): user is AuthUser {
+ return (
+ typeof user === "object" &&
+ user !== null &&
+ typeof (user as AuthUser).id === "string" &&
+ typeof (user as AuthUser).email === "string" &&
+ typeof (user as AuthUser).username === "string" &&
+ ["superadmin", "admin", "member"].includes((user as AuthUser).role)
+ );
+}
+
export async function getAuthUser(): Promise {
const session = await auth();
const user = session?.user as unknown as AuthUser | undefined;
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 556aa55..d48e08c 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -1,15 +1,7 @@
-import { beforeAll, afterAll } from "vitest";
+import { beforeAll } from "vitest";
import { runMigrations } from "../app/lib/migrations";
beforeAll(async () => {
- // Use in-memory SQLite for tests
- process.env.SQLITE_PATH = ":memory:";
- Object.assign(process.env, { NODE_ENV: "development" });
-
// Initialize and migrate
await runMigrations();
});
-
-afterAll(async () => {
- // Cleanup if needed
-});
diff --git a/tsconfig.json b/tsconfig.json
index 9665250..1ae6e44 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": false,
diff --git a/vitest.config.ts b/vitest.config.ts
index a102c38..c53e1a4 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -6,9 +6,12 @@ export default defineConfig({
test: {
environment: "node",
setupFiles: ["src/test/setup.ts"],
+ env: {
+ SQLITE_PATH: ":memory:",
+ NODE_ENV: "development",
+ },
include: ["**/*.test.ts"],
fileParallelism: false,
- singleThread: true,
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],