From a272807a63a6460ade24dbd9cd89e30f082bbc27 Mon Sep 17 00:00:00 2001 From: 3xpyth0n Date: Sun, 15 Feb 2026 10:08:11 +0100 Subject: [PATCH 1/2] feat(canvas): add edge labels and block reactions - Add support for labeling edges with inline editing on double-click - Implement emoji reactions on blocks --- CHANGELOG.md | 14 + package-lock.json | 90 +- package.json | 3 +- .../(main)/management/ManagementClient.tsx | 4 +- src/app/api/git/stats/route.ts | 7 - src/app/api/projects/[id]/graph/route.ts | 48 + src/app/api/projects/route.ts | 18 +- src/app/api/projects/trash/route.ts | 118 ++ src/app/components/dashboard/ProjectList.tsx | 168 ++- src/app/components/project/BlockReactions.tsx | 223 ++++ src/app/components/project/CanvasBlock.tsx | 70 +- src/app/components/project/CanvasEdge.tsx | 122 +- src/app/components/project/ChecklistBlock.tsx | 78 +- src/app/components/project/ContactBlock.tsx | 75 +- src/app/components/project/NoteBlock.tsx | 46 +- src/app/components/project/PaletteBlock.tsx | 41 +- src/app/components/project/ProjectCanvas.tsx | 915 +++++++------- .../project/ProjectCanvasErrorBoundary.tsx | 90 +- .../components/project/ProjectCoreBlock.tsx | 60 +- src/app/components/project/SketchBlock.tsx | 1072 +++++++++-------- src/app/components/project/SnippetBlock.tsx | 60 +- src/app/components/project/UserMapContext.tsx | 37 + src/app/components/project/VideoBlock.tsx | 61 +- src/app/components/project/canvas-edge.css | 31 + .../project/hooks/useBlockReactions.ts | 184 +++ .../project/hooks/useProjectCanvasGraph.ts | 65 +- .../project/hooks/useProjectCanvasState.ts | 39 +- .../project/hooks/useProjectData.ts | 1 + .../project/hooks/useTouchGestures.ts | 2 +- src/app/components/project/utils/collision.ts | 35 + .../db/migrations/19AddLabelsAndReactions.ts | 25 + src/app/global.css | 180 ++- src/app/i18n/en.json | 14 +- src/app/i18n/fr.json | 17 +- src/app/i18n/it.json | 17 +- src/app/lib/db.ts | 14 + src/app/lib/graph.ts | 4 +- src/app/lib/migrations.ts | 2 + src/app/lib/types/db.ts | 13 + src/app/login/LoginClient.tsx | 4 +- src/test/setup.ts | 10 +- tsconfig.json | 2 +- vitest.config.ts | 5 +- 43 files changed, 2823 insertions(+), 1261 deletions(-) create mode 100644 src/app/api/projects/trash/route.ts create mode 100644 src/app/components/project/BlockReactions.tsx create mode 100644 src/app/components/project/UserMapContext.tsx create mode 100644 src/app/components/project/canvas-edge.css create mode 100644 src/app/components/project/hooks/useBlockReactions.ts create mode 100644 src/app/db/migrations/19AddLabelsAndReactions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3954717..1df1f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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. + ## [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]}

diff --git a/src/app/api/git/stats/route.ts b/src/app/api/git/stats/route.ts index fdb82eb..dc5b845 100644 --- a/src/app/api/git/stats/route.ts +++ b/src/app/api/git/stats/route.ts @@ -57,15 +57,8 @@ export async function GET(req: NextRequest) { } catch (e) { console.error("[GitStats] Failed to decrypt token:", e); } - } else { - // Do not allow requests to arbitrary hosts that the user has not configured - return NextResponse.json( - { error: "No configured credentials for requested host" }, - { status: 400 }, - ); } } else { - // Without an authenticated user, do not allow proxying to arbitrary hosts return NextResponse.json( { error: "Authentication required to access repository stats" }, { status: 401 }, diff --git a/src/app/api/projects/[id]/graph/route.ts b/src/app/api/projects/[id]/graph/route.ts index 25131e6..0ffd65b 100644 --- a/src/app/api/projects/[id]/graph/route.ts +++ b/src/app/api/projects/[id]/graph/route.ts @@ -8,6 +8,7 @@ import { prepareLinkForDb, DbBlock, } from "@lib/graph"; +import type { BlockData } from "@components/project/CanvasBlock"; import { z } from "zod"; export const dynamic = "force-dynamic"; @@ -226,6 +227,40 @@ export const POST = projectAction( .execute(); if (blocks?.length) { + const reactionsToInsert: { + id: string; + blockId: string; + userId: string; + emoji: string; + createdAt: string; + }[] = []; + blocks.forEach((block: Node) => { + if (block.data?.reactions && Array.isArray(block.data.reactions)) { + block.data.reactions.forEach( + (r: { + users: (string | { id: string; username: string })[]; + emoji: string; + }) => { + if (r.users && Array.isArray(r.users)) { + r.users.forEach( + (userOrId: string | { id: string; username: string }) => { + const userId = + typeof userOrId === "string" ? userOrId : userOrId.id; + reactionsToInsert.push({ + id: crypto.randomUUID(), + blockId: block.id, + userId: userId, + emoji: r.emoji, + createdAt: new Date().toISOString(), + }); + }, + ); + } + }, + ); + } + }); + const blocksToInsert = blocks.map((n: Node) => prepareBlockForDb(n, project.id, user.id || project.ownerId), ); @@ -236,6 +271,19 @@ export const POST = projectAction( .values(blocksToInsert.slice(i, i + 1000)) .execute(); } + + if (reactionsToInsert.length) { + for (let i = 0; i < reactionsToInsert.length; i += 1000) { + try { + await trx + .insertInto("blockReactions") + .values(reactionsToInsert.slice(i, i + 1000)) + .execute(); + } catch (error) { + console.error("Failed to insert reactions:", error); + } + } + } } if (links?.length) { diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index fca9332..d0778e4 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -49,23 +49,19 @@ export const GET = authenticatedAction( .as("collaboratorCount"), ]); - // Apply View Filters if (view === "trash") { - // Trash view: Only show soft-deleted projects owned by the user query = query .where("projects.deletedAt", "is not", null) .where("projects.ownerId", "=", user.id); } else { - // Default views: Exclude soft-deleted projects query = query.where("projects.deletedAt", "is", null); if (folderId) { - // Folder View: Show projects in specific folder query = query.where("projects.folderId", "=", folderId); } else if (view === "my-projects") { query = query - .where("projects.ownerId", "=", user.id) - .where("projects.folderId", "is", null); + .where("projects.folderId", "is", null) + .where("projects.ownerId", "=", user.id); } else if (view === "shared") { query = query .where("projects.ownerId", "!=", user.id) @@ -98,18 +94,15 @@ export const GET = authenticatedAction( } else if (view === "recent" && (!ids || ids.length === 0)) { return []; } else { - // view === "all" (default) query = query.where("projects.folderId", "is", null); } } - // Access Control: User must be owner, direct collaborator, or folder collaborator - if (view !== "my-projects" && view !== "trash") { + // Only apply global security filter if not already handled by specific view logic + if (view !== "trash" && view !== "my-projects") { query = query.where((eb) => eb.or([ - // 1. Direct Owner eb("projects.ownerId", "=", user.id), - // 2. Direct Collaborator eb( "projects.id", "in", @@ -118,7 +111,6 @@ export const GET = authenticatedAction( .select("projectId") .where("userId", "=", user.id), ), - // 3. Folder Access (Owner or Collaborator) eb( "projects.folderId", "in", @@ -159,7 +151,6 @@ export const POST = authenticatedAction( const { name, description, folderId } = body; - // If folderId provided, verify access if (folderId) { const folder = await db .selectFrom("folders") @@ -197,7 +188,6 @@ export const POST = authenticatedAction( }) .execute(); - // Create default Core Block const blockId = crypto.randomUUID(); await trx .insertInto("blocks") diff --git a/src/app/api/projects/trash/route.ts b/src/app/api/projects/trash/route.ts new file mode 100644 index 0000000..6fff38f --- /dev/null +++ b/src/app/api/projects/trash/route.ts @@ -0,0 +1,118 @@ +import { getDb, runTransaction } from "@lib/db"; +import { authenticatedAction } from "@lib/server-utils"; +import { logSecurityEvent } from "@lib/audit"; +import { headers } from "next/headers"; + +export const DELETE = authenticatedAction( + async (_req, { user }) => { + if (!user) throw new Error("Unauthorized"); + const db = getDb(); + const headersList = await headers(); + const ip = headersList.get("x-forwarded-for") || "127.0.0.1"; + + try { + await runTransaction(db, async (trx) => { + const foldersInTrash = await trx + .selectFrom("folders") + .select("id") + .where("deletedAt", "is not", null) + .where("ownerId", "=", user.id) + .execute(); + + const folderIds = foldersInTrash.map((f) => f.id); + + const projectsToDelete = await trx + .selectFrom("projects") + .select("id") + .where((eb) => + eb.or([ + eb("deletedAt", "is not", null), + folderIds.length > 0 + ? eb("folderId", "in", folderIds) + : eb.val(false), + ]), + ) + .where("ownerId", "=", user.id) + .execute(); + + const projectIds = projectsToDelete.map((p) => p.id); + + if (projectIds.length > 0) { + await trx + .deleteFrom("projectCollaborators") + .where("projectId", "in", projectIds) + .execute(); + await trx + .deleteFrom("projectStars") + .where("projectId", "in", projectIds) + .execute(); + await trx + .deleteFrom("blockSnapshots") + .where( + "blockId", + "in", + trx + .selectFrom("blocks") + .select("id") + .where("projectId", "in", projectIds), + ) + .execute(); + await trx + .deleteFrom("linkPreviews") + .where( + "blockId", + "in", + trx + .selectFrom("blocks") + .select("id") + .where("projectId", "in", projectIds), + ) + .execute(); + await trx + .deleteFrom("blocks") + .where("projectId", "in", projectIds) + .execute(); + await trx + .deleteFrom("links") + .where("projectId", "in", projectIds) + .execute(); + await trx + .deleteFrom("temporalStates") + .where("projectId", "in", projectIds) + .execute(); + await trx + .deleteFrom("projects") + .where("id", "in", projectIds) + .execute(); + } + + if (folderIds.length > 0) { + await trx + .deleteFrom("folderCollaborators") + .where("folderId", "in", folderIds) + .execute(); + await trx + .deleteFrom("folders") + .where("id", "in", folderIds) + .execute(); + } + }); + + await logSecurityEvent("emptyTrash", "success", { + userId: user.id, + ip, + }); + + return { success: true }; + } catch (error) { + console.error("Empty trash failed:", error); + await logSecurityEvent("emptyTrash", "failure", { + userId: user.id, + ip, + }); + + throw { status: 500, message: "Failed to empty trash" }; + } + }, + { requireUser: true }, +); diff --git a/src/app/components/dashboard/ProjectList.tsx b/src/app/components/dashboard/ProjectList.tsx index 719488a..70edb70 100644 --- a/src/app/components/dashboard/ProjectList.tsx +++ b/src/app/components/dashboard/ProjectList.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { Plus, @@ -20,6 +20,7 @@ import { ProjectModal } from "./ProjectModal"; import { ProjectAccessModal } from "./ProjectAccessModal"; import { FolderAccessModal } from "./FolderAccessModal"; import { getRecentProjects } from "@lib/utils"; +import { toast } from "sonner"; import { Button } from "@components/ui/Button"; import { Modal } from "@components/ui/Modal"; @@ -81,6 +82,7 @@ export function ProjectList({ view, folderId }: ProjectListProps) { const [projectToDelete, setProjectToDelete] = useState(null); const [folderToDelete, setFolderToDelete] = useState(null); + const [showEmptyTrashModal, setShowEmptyTrashModal] = useState(false); const [dragOverFolderId, setDragOverFolderId] = useState(null); const [dragOverBreadcrumb, setDragOverBreadcrumb] = useState( @@ -92,6 +94,11 @@ export function ProjectList({ view, folderId }: ProjectListProps) { folder?: Folder; project?: Project; } | null>(null); + const [adjustedMenuPos, setAdjustedMenuPos] = useState<{ + x: number; + y: number; + } | null>(null); + const contextMenuRef = useRef(null); const { rippleRef } = useTouch(); @@ -130,23 +137,68 @@ export function ProjectList({ view, folderId }: ProjectListProps) { stopPropagation: true, }); - // Close context menu on click elsewhere useEffect(() => { const handleClick = () => setContextMenu(null); window.addEventListener("click", handleClick); return () => window.removeEventListener("click", handleClick); }, []); + useEffect(() => { + if (contextMenu && contextMenuRef.current) { + const menuRect = contextMenuRef.current.getBoundingClientRect(); + const margin = 16; + + let x = contextMenu.x; + let y = contextMenu.y; + + if (x + menuRect.width + margin > window.innerWidth) { + x = window.innerWidth - menuRect.width - margin; + } + if (x < margin) x = margin; + + if (y + menuRect.height + margin > window.innerHeight) { + y = window.innerHeight - menuRect.height - margin; + } + if (y < margin) y = margin; + + setAdjustedMenuPos({ x, y }); + } else if (contextMenu) { + setAdjustedMenuPos({ x: contextMenu.x, y: contextMenu.y }); + } else { + setAdjustedMenuPos(null); + } + }, [contextMenu]); + + useEffect(() => { + if (contextMenu && contextMenuRef.current && adjustedMenuPos) { + const menuRect = contextMenuRef.current.getBoundingClientRect(); + const margin = 16; + let needsUpdate = false; + let { x, y } = adjustedMenuPos; + + if (x + menuRect.width + margin > window.innerWidth) { + x = window.innerWidth - menuRect.width - margin; + needsUpdate = true; + } + if (y + menuRect.height + margin > window.innerHeight) { + y = window.innerHeight - menuRect.height - margin; + needsUpdate = true; + } + + if (needsUpdate) { + setAdjustedMenuPos({ x, y }); + } + } + }, [contextMenu, adjustedMenuPos]); + const fetchData = useCallback(async () => { try { setLoading(true); - // Reset state immediately to avoid stale data flash setProjects([]); setFolders([]); setCurrentFolder(null); - // Prepare fetch promises let projectUrl = `/api/projects?view=${view || "all"}`; if (folderId) { projectUrl += `&folderId=${folderId}`; @@ -180,14 +232,12 @@ export function ProjectList({ view, folderId }: ProjectListProps) { ) : Promise.resolve(null); - // Execute in parallel const [projectsData, foldersData, currentFolderData] = await Promise.all([ projectsPromise, foldersPromise, currentFolderPromise, ]); - // Process Projects if (view === "recent") { const ids = getRecentProjects(); const orderMap = new Map(ids.map((id, index) => [id, index])); @@ -198,14 +248,13 @@ export function ProjectList({ view, folderId }: ProjectListProps) { }); } setProjects(projectsData); - - // Process Folders setFolders(foldersData); - - // Process Current Folder setCurrentFolder(currentFolderData); } catch (_err) { - // Silently fail + // Handle error + setProjects([]); + setFolders([]); + setCurrentFolder(null); } finally { setLoading(false); } @@ -248,7 +297,6 @@ export function ProjectList({ view, folderId }: ProjectListProps) { const newIsStarred = !folder.isStarred; setFolders((prev) => { - // If we are in "starred" view and we unstar, we should remove it from view if (view === "starred" && !newIsStarred) { return prev.filter((f) => f.id !== folder.id); } @@ -313,9 +361,15 @@ export function ProjectList({ view, folderId }: ProjectListProps) { const confirmDeleteProject = async () => { if (!projectToDelete) return; try { - await fetch(`/api/projects/${projectToDelete.id}?permanent=true`, { - method: "DELETE", - }); + const isTrashView = view === "trash"; + await fetch( + `/api/projects/${projectToDelete.id}${ + isTrashView ? "?permanent=true" : "" + }`, + { + method: "DELETE", + }, + ); fetchData(); } catch (e) { console.error(e); @@ -344,6 +398,24 @@ export function ProjectList({ view, folderId }: ProjectListProps) { } }; + const confirmEmptyTrash = async () => { + try { + const response = await fetch("/api/projects/trash", { + method: "DELETE", + }); + if (response.ok) { + toast.success(dict.common.success || "Trash emptied successfully"); + fetchData(); + setShowEmptyTrashModal(false); + } else { + toast.error(dict.common.error || "Failed to empty trash"); + } + } catch (error) { + console.error("Error emptying trash:", error); + toast.error(dict.common.error || "An error occurred"); + } + }; + const handleCreateFolder = async () => { try { const res = await fetch("/api/folders", { @@ -425,7 +497,6 @@ export function ProjectList({ view, folderId }: ProjectListProps) { }; const handleInputBlur = (e: React.FocusEvent) => { - // If moving focus to another edit input, don't save/close yet if ( e.relatedTarget && (e.relatedTarget as HTMLElement).classList.contains( @@ -441,26 +512,23 @@ export function ProjectList({ view, folderId }: ProjectListProps) { e.dataTransfer.setData("projectId", projectId); e.dataTransfer.effectAllowed = "move"; - // Create a custom drag image to fix transparency issues const target = e.currentTarget as HTMLElement; const clone = target.cloneNode(true) as HTMLElement; - // Set styles to ensure visibility clone.style.position = "absolute"; clone.style.top = "-9999px"; clone.style.left = "-9999px"; clone.style.width = `${target.offsetWidth}px`; clone.style.height = `${target.offsetHeight}px`; - clone.style.opacity = "1"; // Browser adds its own transparency (usually ~50%), so we start with 100% - clone.style.backgroundColor = "var(--bg-island)"; // Solid background + clone.style.opacity = "1"; + clone.style.backgroundColor = "var(--bg-island)"; clone.style.zIndex = "9999"; clone.style.border = "1px solid var(--border)"; - clone.style.borderRadius = "8px"; // Match rounded corners + clone.style.borderRadius = "8px"; document.body.appendChild(clone); e.dataTransfer.setDragImage(clone, 0, 0); - // Clean up setTimeout(() => { document.body.removeChild(clone); }, 0); @@ -484,7 +552,6 @@ export function ProjectList({ view, folderId }: ProjectListProps) { const projectId = e.dataTransfer.getData("projectId"); if (!projectId) return; - // Optimistic update setProjects((prev) => prev.filter((p) => p.id !== projectId)); setFolders((prev) => prev.map((f) => @@ -501,10 +568,9 @@ export function ProjectList({ view, folderId }: ProjectListProps) { headers: { "Content-Type": "application/json" }, }); if (!res.ok) throw new Error("Failed to move project"); - // Optionally refresh data } catch (err) { console.error(err); - fetchData(); // Revert on error + fetchData(); } }; @@ -529,10 +595,8 @@ export function ProjectList({ view, folderId }: ProjectListProps) { const projectId = e.dataTransfer.getData("projectId"); if (!projectId) return; - // If moving to same folder (current view), ignore if (targetFolderId === folderId) return; - // Optimistic: Remove from current list since it moved out setProjects((prev) => prev.filter((p) => p.id !== projectId)); try { @@ -543,7 +607,7 @@ export function ProjectList({ view, folderId }: ProjectListProps) { }); } catch (err) { console.error(err); - fetchData(); // Revert + fetchData(); } }; @@ -586,7 +650,6 @@ export function ProjectList({ view, folderId }: ProjectListProps) { } const isReadOnlyView = ["trash", "recent", "starred"].includes(view || ""); - // Allow creation ONLY in Home (view is undefined/null/empty) const canCreate = !view; return ( @@ -596,7 +659,6 @@ export function ProjectList({ view, folderId }: ProjectListProps) {
{folderId ? ( <> - {/* Breadcrumb */}
- {/* Title */}

{currentFolder ? ( currentFolder.name @@ -677,10 +738,19 @@ export function ProjectList({ view, folderId }: ProjectListProps) {

)} + + {isTrash && (projects.length > 0 || folders.length > 0) && ( + + )}
- {/* Folders */} {!folderId && (!isReadOnlyView || view === "starred" || view === "trash") && folders.map((folder) => ( @@ -856,7 +926,6 @@ export function ProjectList({ view, folderId }: ProjectListProps) {
))} - {/* Empty State */} {projects.length === 0 && folders.length === 0 && (
canCreate && setShowCreate(true)} @@ -891,7 +960,6 @@ export function ProjectList({ view, folderId }: ProjectListProps) {
)} - {/* Project Cards */} {projects.map((project) => (
)} - {contextMenu && ( + {contextMenu && adjustedMenuPos && (
e.stopPropagation()} @@ -1219,6 +1288,31 @@ export function ProjectList({ view, folderId }: ProjectListProps) {
+ + setShowEmptyTrashModal(false)} + title={dict.modals.emptyTrashTitle} + className="max-w-md" + > +
+

+ {dict.modals.emptyTrashDescription} +

+
+ + +
+
+
); } diff --git a/src/app/components/project/BlockReactions.tsx b/src/app/components/project/BlockReactions.tsx new file mode 100644 index 0000000..d0f97cd --- /dev/null +++ b/src/app/components/project/BlockReactions.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useState, useCallback, useMemo, useRef, useEffect } from "react"; +import { Smile } from "lucide-react"; +import { useI18n } from "@providers/I18nProvider"; +import { useUserMap } from "./UserMapContext"; + +interface Reaction { + emoji: string; + count: number; + users: (string | { id: string; username: string })[]; +} + +interface BlockReactionsProps { + reactions?: Reaction[]; + onReact: (emoji: string) => void; + onRemoveReaction: (emoji: string) => void; + currentUserId?: string; + isReadOnly?: boolean; +} + +const PREDEFINED_EMOJIS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"]; + +export const BlockReactions = ({ + reactions = [], + onReact, + onRemoveReaction, + currentUserId, + isReadOnly, +}: BlockReactionsProps) => { + const { dict, lang } = useI18n(); + const { resolveUser } = useUserMap(); + const [showPicker, setShowPicker] = useState(false); + const pickerRef = useRef(null); + const buttonRef = useRef(null); + + // Close picker when clicking outside + useEffect(() => { + if (!showPicker) return; + + const handleClickOutside = (event: Event) => { + if ( + pickerRef.current && + !pickerRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setShowPicker(false); + } + }; + + document.addEventListener("pointerdown", handleClickOutside, { + capture: true, + }); + document.addEventListener("mousedown", handleClickOutside, { + capture: true, + }); + return () => { + document.removeEventListener("pointerdown", handleClickOutside, { + capture: true, + }); + document.removeEventListener("mousedown", handleClickOutside, { + capture: true, + }); + }; + }, [showPicker]); + + const listFormatter = useMemo( + () => new Intl.ListFormat(lang, { style: "long", type: "conjunction" }), + [lang], + ); + + const toggleReaction = useCallback( + (emoji: string) => { + if (isReadOnly) return; + const existingReaction = reactions.find((r) => r.emoji === emoji); + const hasReacted = + existingReaction && currentUserId + ? existingReaction.users.some((u) => + typeof u === "string" + ? u === currentUserId + : u.id === currentUserId, + ) + : false; + + if (hasReacted) { + onRemoveReaction(emoji); + } else { + onReact(emoji); + } + setShowPicker(false); + }, + [isReadOnly, onReact, onRemoveReaction, reactions, currentUserId], + ); + + if (reactions.length === 0 && isReadOnly) return null; + + return ( +
+
+ {/* Existing Reactions */} + {reactions.length > 0 && ( +
+ {reactions.map((reaction) => { + const hasReacted = currentUserId + ? reaction.users.some((u) => + typeof u === "string" + ? u === currentUserId + : u.id === currentUserId, + ) + : false; + + const userNames = reaction.users.map((u) => { + const userId = typeof u === "string" ? u : u.id; + const resolved = resolveUser(userId); + if (resolved) { + return resolved.displayName || resolved.username; + } + return typeof u === "object" ? u.username : u; + }); + + // Determine tooltip color: use first user's color if single user, otherwise black + let tooltipColor = "#000"; + if (reaction.users.length === 1) { + const firstUserId = + typeof reaction.users[0] === "string" + ? reaction.users[0] + : reaction.users[0].id; + const resolved = resolveUser(firstUserId); + if (resolved?.color) { + tooltipColor = resolved.color; + } + } + + return ( + + ); + })} +
+ )} + + {/* Add Reaction Button */} + {!isReadOnly && ( +
+ + + {/* Emoji Picker Tooltip */} + {showPicker && ( +
+ {PREDEFINED_EMOJIS.map((emoji) => { + const existingReaction = reactions.find( + (r) => r.emoji === emoji, + ); + const hasReacted = + existingReaction && currentUserId + ? existingReaction.users.some((u) => + typeof u === "string" + ? u === currentUserId + : u.id === currentUserId, + ) + : false; + + return ( + + ); + })} +
+ )} +
+ )} +
+
+ ); +}; diff --git a/src/app/components/project/CanvasBlock.tsx b/src/app/components/project/CanvasBlock.tsx index 4ce7f1b..c3cc24c 100644 --- a/src/app/components/project/CanvasBlock.tsx +++ b/src/app/components/project/CanvasBlock.tsx @@ -63,6 +63,8 @@ import SketchBlock from "./SketchBlock"; import ProjectCoreBlock from "./ProjectCoreBlock"; import NoteBlock from "./NoteBlock"; +import { BlockReactions } from "./BlockReactions"; +import { useBlockReactions } from "./hooks/useBlockReactions"; export type BlockData = { title?: string; @@ -95,6 +97,11 @@ export type BlockData = { status?: string; rationale?: string; intent?: string; + reactions?: { + emoji: string; + count: number; + users: (string | { id: string; username: string })[]; + }[]; onContentChange?: ( blockId: string, content: string, @@ -102,6 +109,11 @@ export type BlockData = { lastEditor: string, metadata?: string, title?: string, + reactions?: { + emoji: string; + count: number; + users: (string | { id: string; username: string })[]; + }[], ) => void; onFocus?: (blockId: string, index: number) => void; onBlur?: (blockId: string) => void; @@ -365,6 +377,13 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { const isReadOnly = isPreviewMode || (isLocked ? !isOwner && !isProjectOwner : false); + const { handleReact, handleRemoveReaction } = useBlockReactions({ + id, + data, + currentUser, + isReadOnly, + }); + const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { if (isReadOnly) return; @@ -624,7 +643,15 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { const metadataString = newMetadata ? JSON.stringify(newMetadata) : undefined; - data.onContentChange?.(id, content, now, editor, metadataString, title); + data.onContentChange?.( + id, + content, + now, + editor, + metadataString, + title, + data.reactions, + ); if (setNodes) { setNodes((nds) => @@ -756,6 +783,7 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { editor, currentMetadata ? JSON.stringify(currentMetadata) : undefined, title, + data.reactions, ); } @@ -877,6 +905,7 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { editor, metadata ? JSON.stringify(metadata) : undefined, newTitle, + data.reactions, ); }, [id, data, currentUser, dict.project.anonymous, metadata, content], @@ -999,6 +1028,7 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { editor, metadataString, title, + data.reactions, ); // Sync metadata to yNodes @@ -1141,7 +1171,15 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { const onContentChange = data.onContentChange; const metadataString = metadata ? JSON.stringify(metadata) : undefined; - onContentChange?.(id, newContent, now, editor, metadataString, title); + onContentChange?.( + id, + newContent, + now, + editor, + metadataString, + title, + data.reactions, + ); }; const handleFocus = (e: React.FocusEvent) => { @@ -1163,7 +1201,15 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { const onContentChange = data.onContentChange; const metadataString = metadata ? JSON.stringify(metadata) : undefined; - onContentChange?.(id, content, now, editor, metadataString, title); + onContentChange?.( + id, + content, + now, + editor, + metadataString, + title, + data.reactions, + ); }; const formatDate = (isoString: string) => { @@ -1440,6 +1486,7 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { editor, metadata ? JSON.stringify(metadata) : undefined, title, + data.reactions, ); setGithubError(null); }} @@ -1622,6 +1669,7 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { editor, metadata ? JSON.stringify(metadata) : undefined, title, + data.reactions, ); }} onBlur={() => { @@ -1943,7 +1991,9 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { selected ? "selected" : "" } ${isRemoteTyping ? "remote-typing" : ""} ${ isBeingMoved ? "is-moving" : "" - } ${isReadOnly ? "read-only" : ""} flex flex-col !p-0`} + } ${ + isReadOnly ? "read-only" : "" + } flex flex-col !p-0 relative w-full h-full`} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} style={ @@ -1954,7 +2004,7 @@ const CanvasBlockComponent = (props: CanvasBlockProps) => { > { {renderContent()}
-
+
{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.displayName +
-
- {u.displayName || u.username} -
+
+ {u.displayName || u.username} +
+
-
- ))} -
- )} -
- - {currentUser?.id === projectOwnerId && ( + ))} +
+ )} +
- )} - + {currentUser?.id === projectOwnerId && ( + + )} + +
-
- + -
-
-
- - {zoom}% - +
+
+
+ + {zoom}% + +
-
- - - - - - - - - - - - - - - - - - - - {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} +
+
+
+ + + + {dict.common.contactSupport} + +
+
+ ); +} + 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 */} -
- - {activePopup === "pen" && ( -
- {STROKE_SIZES.map((s) => ( - + {activePopup === "pen" && ( +
+ {STROKE_SIZES.map((s) => ( + + ))} +
+ )} +
+ + {/* Eraser Tool */} +
+ + {activePopup === "eraser" && ( +
-
- - ))} + {STROKE_SIZES.map((s) => ( + + ))} +
+ )}
- )} -
- {/* Eraser Tool */} -
- - {activePopup === "eraser" && ( -
- {STROKE_SIZES.map((s) => ( - + {activePopup === "color" && ( +
-
- - ))} + {COLORS.map((c) => ( +
+ )}
- )} -
-
+
- {/* Color Picker */} -
- - {activePopup === "color" && ( -
{ + handleUndo(); + stopPropagation(e); + }} {...preventDrag} - onClick={stopPropagation} - style={{ - pointerEvents: "auto", - backgroundColor: "#121212", - zIndex: 100, + disabled={history.length === 0} + className={`p-1 rounded-md transition-all duration-200 ${ + history.length === 0 + ? "text-white/20 cursor-not-allowed opacity-30" + : "text-white hover:text-white hover:bg-white/10 opacity-100" + }`} + title="Undo" + > + + +
- )} -
- -
- - {/* Actions */} - - - + +
+ )} + + {/* SVG Canvas Area */} +
- - -
- )} + + + {(() => { + // 1. Prepare all strokes including current interaction + const allStrokes = [...strokes]; + if (currentPoints) { + allStrokes.push({ + points: currentPoints, + color: tool === "eraser" ? "#000000" : color, + size: tool === "pen" ? penSize : eraserSize, + isEraser: tool === "eraser", + }); + } + + // 2. Group consecutive strokes by type + const groups: { + type: "draw" | "erase"; + strokes: Stroke[]; + id: string; + }[] = []; + let currentGroup: { + type: "draw" | "erase"; + strokes: Stroke[]; + id: string; + } | null = null; + + allStrokes.forEach((stroke, i) => { + const type = stroke.isEraser ? "erase" : "draw"; + if (!currentGroup || currentGroup.type !== type) { + currentGroup = { + type, + strokes: [stroke], + id: `group-${i}`, + }; + groups.push(currentGroup); + } else { + currentGroup.strokes.push(stroke); + } + }); + + // 3. Generate masks for each "draw" group + // A draw group needs a mask that includes all *subsequent* erase groups + return groups.map((group, i) => { + if (group.type !== "draw") return null; + + const subsequentErasers = groups + .slice(i + 1) + .filter((g) => g.type === "erase"); + if (subsequentErasers.length === 0) return null; // No mask needed + + const maskId = `mask-${group.id}`; + return ( + + + {subsequentErasers.flatMap((g) => + g.strokes.map((s, j) => ( + + )), + )} + + ); + }); + })()} + + + {/* Render Groups */} + {(() => { + const allStrokes = [...strokes]; + if (currentPoints) { + allStrokes.push({ + points: currentPoints, + color: tool === "eraser" ? "#000000" : color, + size: tool === "pen" ? penSize : eraserSize, + isEraser: tool === "eraser", + }); + } - {/* SVG Canvas Area */} -
- - - {(() => { - // 1. Prepare all strokes including current interaction - const allStrokes = [...strokes]; - if (currentPoints) { - allStrokes.push({ - points: currentPoints, - color: tool === "eraser" ? "#000000" : color, - size: tool === "pen" ? penSize : eraserSize, - isEraser: tool === "eraser", + const groups: { + type: "draw" | "erase"; + strokes: Stroke[]; + id: string; + }[] = []; + let currentGroup: { + type: "draw" | "erase"; + strokes: Stroke[]; + id: string; + } | null = null; + + allStrokes.forEach((stroke, i) => { + const type = stroke.isEraser ? "erase" : "draw"; + if (!currentGroup || currentGroup.type !== type) { + currentGroup = { + type, + strokes: [stroke], + id: `group-${i}`, + }; + groups.push(currentGroup); + } else { + currentGroup.strokes.push(stroke); + } }); - } - // 2. Group consecutive strokes by type - const groups: { - type: "draw" | "erase"; - strokes: Stroke[]; - id: string; - }[] = []; - let currentGroup: { - type: "draw" | "erase"; - strokes: Stroke[]; - id: string; - } | null = null; - - allStrokes.forEach((stroke, i) => { - const type = stroke.isEraser ? "erase" : "draw"; - if (!currentGroup || currentGroup.type !== type) { - currentGroup = { type, strokes: [stroke], id: `group-${i}` }; - groups.push(currentGroup); - } else { - currentGroup.strokes.push(stroke); - } - }); - - // 3. Generate masks for each "draw" group - // A draw group needs a mask that includes all *subsequent* erase groups - return groups.map((group, i) => { - if (group.type !== "draw") return null; - - const subsequentErasers = groups - .slice(i + 1) - .filter((g) => g.type === "erase"); - if (subsequentErasers.length === 0) return null; // No mask needed - - const maskId = `mask-${group.id}`; - return ( - - - {subsequentErasers.flatMap((g) => - g.strokes.map((s, j) => ( + return groups.map((group, i) => { + if (group.type === "erase") return null; // Erasers are only used in masks + + const subsequentErasers = groups + .slice(i + 1) + .filter((g) => g.type === "erase"); + const maskId = + subsequentErasers.length > 0 + ? `mask-${group.id}` + : undefined; + + return ( + + {group.strokes.map((stroke, j) => ( - )), - )} - - ); - }); - })()} - - - {/* Render Groups */} - {(() => { - const allStrokes = [...strokes]; - if (currentPoints) { - allStrokes.push({ - points: currentPoints, - color: tool === "eraser" ? "#000000" : color, - size: tool === "pen" ? penSize : eraserSize, - isEraser: tool === "eraser", - }); - } - - const groups: { - type: "draw" | "erase"; - strokes: Stroke[]; - id: string; - }[] = []; - let currentGroup: { - type: "draw" | "erase"; - strokes: Stroke[]; - id: string; - } | null = null; - - allStrokes.forEach((stroke, i) => { - const type = stroke.isEraser ? "erase" : "draw"; - if (!currentGroup || currentGroup.type !== type) { - currentGroup = { type, strokes: [stroke], id: `group-${i}` }; - groups.push(currentGroup); - } else { - currentGroup.strokes.push(stroke); - } - }); - - return groups.map((group, i) => { - if (group.type === "erase") return null; // Erasers are only used in masks - - const subsequentErasers = groups - .slice(i + 1) - .filter((g) => g.type === "erase"); - const maskId = - subsequentErasers.length > 0 ? `mask-${group.id}` : undefined; - - return ( - - {group.strokes.map((stroke, j) => ( - - ))} - - ); - }); - })()} - -
- -
-
-
- {formatDate(data.updatedAt || "")} + ))} + + ); + }); + })()} +
-
- {isLocked && } -
- {(data.authorName || dict.project.anonymous).toLowerCase()} + +
+
+
+ {formatDate(data.updatedAt || "")} +
+
+ {isLocked && } +
+ {(data.authorName || dict.project.anonymous).toLowerCase()} +
+
-
- {/* Handles */} - - {!isHandleConnected("left-target") &&
} - - - {!isHandleConnected("left") &&
} - - - {!isHandleConnected("right") &&
} - - - {!isHandleConnected("right-target") &&
} - - - {!isHandleConnected("top-target") &&
} - - - {!isHandleConnected("top") &&
} - - - {!isHandleConnected("bottom") &&
} - - - {!isHandleConnected("bottom-target") &&
} - -
+ + {!isHandleConnected("left-target") &&
} + + + {!isHandleConnected("left") &&
} + + + {!isHandleConnected("right") &&
} + + + {!isHandleConnected("right-target") &&
} + + + {!isHandleConnected("top-target") &&
} + + + {!isHandleConnected("top") &&
} + + + {!isHandleConnected("bottom") &&
} + + + {!isHandleConnected("bottom-target") && ( +
+ )} + +
+ + ); }); diff --git a/src/app/components/project/SnippetBlock.tsx b/src/app/components/project/SnippetBlock.tsx index e7f539b..c2656c1 100644 --- a/src/app/components/project/SnippetBlock.tsx +++ b/src/app/components/project/SnippetBlock.tsx @@ -33,6 +33,8 @@ import { BlockData } from "./CanvasBlock"; import { DEFAULT_BLOCK_WIDTH, DEFAULT_BLOCK_HEIGHT } from "./utils/constants"; import { Select, SelectOption } from "../ui/Select"; import "./snippet-block.css"; +import { BlockReactions } from "./BlockReactions"; +import { useBlockReactions } from "./hooks/useBlockReactions"; type SnippetBlockProps = NodeProps> & { isReadOnly?: boolean; @@ -150,24 +152,11 @@ const SnippetBlock = memo(({ id, data, selected }: SnippetBlockProps) => { 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", { - bubbles: true, - cancelable: true, - clientX: - "touches" in e ? e.touches[0].clientX : (e as MouseEvent).clientX, - clientY: - "touches" in e ? e.touches[0].clientY : (e as MouseEvent).clientY, - }); - target.dispatchEvent(event); - }, []); - - const touchHandlers = useTouchGestures({ - onLongPress, + const { handleReact, handleRemoveReaction } = useBlockReactions({ + id, + data, + currentUser, + isReadOnly, }); const handleTitleChange = useCallback( @@ -187,6 +176,7 @@ const SnippetBlock = memo(({ id, data, selected }: SnippetBlockProps) => { editor, data.metadata, newTitle, + data.reactions, ); }, [id, data, currentUser, dict], @@ -209,6 +199,7 @@ const SnippetBlock = memo(({ id, data, selected }: SnippetBlockProps) => { editor, JSON.stringify({ language }), title, + data.reactions, ); }, [id, data, currentUser, dict, language, syncToYjs, title], @@ -230,11 +221,32 @@ const SnippetBlock = memo(({ id, data, selected }: SnippetBlockProps) => { editor, JSON.stringify({ language: newLang }), title, + data.reactions, ); }, [id, data, currentUser, dict, code, title], ); + 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", { + bubbles: true, + cancelable: true, + clientX: + "touches" in e ? e.touches[0].clientX : (e as MouseEvent).clientX, + clientY: + "touches" in e ? e.touches[0].clientY : (e as MouseEvent).clientY, + }); + target.dispatchEvent(event); + }, []); + + const touchHandlers = useTouchGestures({ + onLongPress, + }); + const formatDate = (isoString: string) => { if (!isoString) return ""; const date = new Date(isoString); @@ -379,7 +391,7 @@ const SnippetBlock = memo(({ id, data, selected }: SnippetBlockProps) => { > {
-
+
{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/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"], From 8d12abbafe27b22cf15b7384b6b64c12923c8549 Mon Sep 17 00:00:00 2001 From: 3xpyth0n Date: Sun, 15 Feb 2026 21:30:53 +0100 Subject: [PATCH 2/2] fix(auth): resolve missing ownerId and optimize session hydration - Fix null `ownerId` in session by adding fallback to `token.sub`. - Optimize `jwt` callback to avoid redundant DB queries for Credentials provider. --- CHANGELOG.md | 4 +++ src/auth.ts | 91 +++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df1f19..b173667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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/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;