From c959b35e8733f171a84b6c8017624ee7ef29465e Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 28 Apr 2025 20:52:51 +0000 Subject: [PATCH 01/21] feat: integrate Tabs and Footer components into ExcalidrawWrapper - Added a new Tabs component for enhanced functionality within the Excalidraw interface. - Integrated the Footer component to house the Tabs, improving layout and user interaction. - Updated ExcalidrawWrapper to conditionally render the Footer and Tabs based on the excalidrawAPI availability. --- src/frontend/src/ExcalidrawWrapper.tsx | 9 ++++++ src/frontend/src/ui/Tabs.tsx | 44 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 src/frontend/src/ui/Tabs.tsx diff --git a/src/frontend/src/ExcalidrawWrapper.tsx b/src/frontend/src/ExcalidrawWrapper.tsx index e359695..5053b12 100644 --- a/src/frontend/src/ExcalidrawWrapper.tsx +++ b/src/frontend/src/ExcalidrawWrapper.tsx @@ -10,6 +10,8 @@ import AuthDialog from './ui/AuthDialog'; import BackupsModal from './ui/BackupsDialog'; import SettingsDialog from './ui/SettingsDialog'; import { capture } from './utils/posthog'; +import { Footer } from '@atyrode/excalidraw'; +import Tabs from './ui/Tabs'; const defaultInitialData = { elements: [], @@ -105,6 +107,13 @@ export const ExcalidrawWrapper: React.FC = ({ )), }, <> + {excalidrawAPI && ( +
+ +
+ )} + + +); + +interface TabsProps { + excalidrawAPI: ExcalidrawImperativeAPI; +} + +const Tabs: React.FC = ({ + excalidrawAPI, +}: { + excalidrawAPI: ExcalidrawImperativeAPI; +}) => { + + return ( + <> + + + ); +}; + +export default Tabs; \ No newline at end of file From e45e911c67815cdfd0d749bcfc8ff657f88d515a Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 28 Apr 2025 21:12:11 +0000 Subject: [PATCH 02/21] chore: update @atyrode/excalidraw dependency to version 0.18.0-5 --- src/frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index 5ed54ff..4018d14 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "@atyrode/excalidraw": "^0.18.0-4", + "@atyrode/excalidraw": "^0.18.0-5", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.74.3", "@tanstack/react-query-devtools": "^5.74.3", From 5b5c19398f9e9eaa7dcc5848767e9775c56bebb9 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 28 Apr 2025 21:17:49 +0000 Subject: [PATCH 03/21] chore: update yarn lockfile --- src/frontend/yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index ca81dfe..8832fed 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@atyrode/excalidraw@^0.18.0-4": - version "0.18.0-4" - resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-4.tgz#3350bd09533cb424105fa615ec0e2e4e243f798e" - integrity sha512-MDYAXT34cNmhoc49eC7iuoweGHsirKKH0VnwDT9CJPWTjUfOrXp/NsL1PHyFIyhBu14NI1osSpGTD0qi1FPegQ== +"@atyrode/excalidraw@^0.18.0-5": + version "0.18.0-5" + resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-5.tgz#41ac495c315ac8000956c13ffbeb53323459f686" + integrity sha512-2EEPj4aWWHTrRbWGZ0QjF4Ds/pl+RTzQs1psYji78EnWSrSjFjqkEV2QNSyeB3jzD/qBkwiI0TwIHuvh0vv58w== dependencies: "@braintree/sanitize-url" "6.0.2" "@excalidraw/laser-pointer" "1.3.1" From 8d36ba1fd0df7085cd96c2f213e3aa5d1225a748 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 28 Apr 2025 21:25:45 +0000 Subject: [PATCH 04/21] chore: update @atyrode/excalidraw dependency to version 0.18.0-6 --- src/frontend/package.json | 2 +- src/frontend/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index 4018d14..8938e94 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "@atyrode/excalidraw": "^0.18.0-5", + "@atyrode/excalidraw": "^0.18.0-6", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.74.3", "@tanstack/react-query-devtools": "^5.74.3", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 8832fed..4331ac4 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@atyrode/excalidraw@^0.18.0-5": - version "0.18.0-5" - resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-5.tgz#41ac495c315ac8000956c13ffbeb53323459f686" - integrity sha512-2EEPj4aWWHTrRbWGZ0QjF4Ds/pl+RTzQs1psYji78EnWSrSjFjqkEV2QNSyeB3jzD/qBkwiI0TwIHuvh0vv58w== +"@atyrode/excalidraw@^0.18.0-6": + version "0.18.0-6" + resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-6.tgz#60fd05d249638e7a9901627c9c87624aeb72773e" + integrity sha512-M32BiTipeA79Td8iySxsf5tVcNqxsg46W+/bDC/v/7VyU82t7N/mAGrA5inuFO5sUf7Muwm9ysjTLu7yDN9msA== dependencies: "@braintree/sanitize-url" "6.0.2" "@excalidraw/laser-pointer" "1.3.1" From 0bb4cdff6e56d952558dbb5dae931ffba69ec2e6 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 28 Apr 2025 22:41:47 +0000 Subject: [PATCH 05/21] refactor: restructure pad folder --- .../src/pad/{containers => }/Dashboard.scss | 0 .../src/pad/{containers => }/Dashboard.tsx | 8 ++++---- .../src/pad/{controls => }/StateIndicator.scss | 0 .../src/pad/{controls => }/StateIndicator.tsx | 2 +- .../src/pad/{containers => }/Terminal.scss | 0 .../src/pad/{containers => }/Terminal.tsx | 2 +- .../pad/{controls => buttons}/ControlButton.scss | 0 .../pad/{controls => buttons}/ControlButton.tsx | 0 src/frontend/src/pad/index.ts | 16 ++++++++-------- 9 files changed, 14 insertions(+), 14 deletions(-) rename src/frontend/src/pad/{containers => }/Dashboard.scss (100%) rename src/frontend/src/pad/{containers => }/Dashboard.tsx (95%) rename src/frontend/src/pad/{controls => }/StateIndicator.scss (100%) rename src/frontend/src/pad/{controls => }/StateIndicator.tsx (96%) rename src/frontend/src/pad/{containers => }/Terminal.scss (100%) rename src/frontend/src/pad/{containers => }/Terminal.tsx (99%) rename src/frontend/src/pad/{controls => buttons}/ControlButton.scss (100%) rename src/frontend/src/pad/{controls => buttons}/ControlButton.tsx (100%) diff --git a/src/frontend/src/pad/containers/Dashboard.scss b/src/frontend/src/pad/Dashboard.scss similarity index 100% rename from src/frontend/src/pad/containers/Dashboard.scss rename to src/frontend/src/pad/Dashboard.scss diff --git a/src/frontend/src/pad/containers/Dashboard.tsx b/src/frontend/src/pad/Dashboard.tsx similarity index 95% rename from src/frontend/src/pad/containers/Dashboard.tsx rename to src/frontend/src/pad/Dashboard.tsx index 1c03dd4..0538ac9 100644 --- a/src/frontend/src/pad/containers/Dashboard.tsx +++ b/src/frontend/src/pad/Dashboard.tsx @@ -1,10 +1,10 @@ import React, { useState, useRef, useEffect } from 'react'; import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types'; import type { AppState } from '@atyrode/excalidraw/types'; -import StateIndicator from '../controls/StateIndicator'; -import ControlButton from '../controls/ControlButton'; -import { ActionButtonGrid } from '../buttons'; -import { useWorkspaceState } from '../../api/hooks'; +import StateIndicator from './StateIndicator'; +import ControlButton from './buttons/ControlButton'; +import { ActionButtonGrid } from './buttons'; +import { useWorkspaceState } from '../api/hooks'; import './Dashboard.scss'; // Direct import from types diff --git a/src/frontend/src/pad/controls/StateIndicator.scss b/src/frontend/src/pad/StateIndicator.scss similarity index 100% rename from src/frontend/src/pad/controls/StateIndicator.scss rename to src/frontend/src/pad/StateIndicator.scss diff --git a/src/frontend/src/pad/controls/StateIndicator.tsx b/src/frontend/src/pad/StateIndicator.tsx similarity index 96% rename from src/frontend/src/pad/controls/StateIndicator.tsx rename to src/frontend/src/pad/StateIndicator.tsx index f3ae9bb..93fe304 100644 --- a/src/frontend/src/pad/controls/StateIndicator.tsx +++ b/src/frontend/src/pad/StateIndicator.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useWorkspaceState, useAuthCheck } from '../../api/hooks'; +import { useWorkspaceState, useAuthCheck } from '../api/hooks'; import './StateIndicator.scss'; export const StateIndicator: React.FC = () => { diff --git a/src/frontend/src/pad/containers/Terminal.scss b/src/frontend/src/pad/Terminal.scss similarity index 100% rename from src/frontend/src/pad/containers/Terminal.scss rename to src/frontend/src/pad/Terminal.scss diff --git a/src/frontend/src/pad/containers/Terminal.tsx b/src/frontend/src/pad/Terminal.tsx similarity index 99% rename from src/frontend/src/pad/containers/Terminal.tsx rename to src/frontend/src/pad/Terminal.tsx index 9460bba..2e8fd90 100644 --- a/src/frontend/src/pad/containers/Terminal.tsx +++ b/src/frontend/src/pad/Terminal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { useWorkspaceState } from '../../api/hooks'; +import { useWorkspaceState } from '../api/hooks'; import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types'; import type { AppState } from '@atyrode/excalidraw/types'; import './Terminal.scss'; diff --git a/src/frontend/src/pad/controls/ControlButton.scss b/src/frontend/src/pad/buttons/ControlButton.scss similarity index 100% rename from src/frontend/src/pad/controls/ControlButton.scss rename to src/frontend/src/pad/buttons/ControlButton.scss diff --git a/src/frontend/src/pad/controls/ControlButton.tsx b/src/frontend/src/pad/buttons/ControlButton.tsx similarity index 100% rename from src/frontend/src/pad/controls/ControlButton.tsx rename to src/frontend/src/pad/buttons/ControlButton.tsx diff --git a/src/frontend/src/pad/index.ts b/src/frontend/src/pad/index.ts index 1edf88a..4da81bb 100644 --- a/src/frontend/src/pad/index.ts +++ b/src/frontend/src/pad/index.ts @@ -1,13 +1,13 @@ // Re-export all components from the pad module -export * from './controls/ControlButton'; -export * from './controls/StateIndicator'; -export * from './containers/Dashboard'; -export * from './containers/Terminal'; +export * from './buttons/ControlButton'; +export * from './StateIndicator'; +export * from './Dashboard'; +export * from './Terminal'; export * from './buttons'; export * from './editors'; // Default exports -export { default as ControlButton } from './controls/ControlButton'; -export { default as StateIndicator } from './controls/StateIndicator'; -export { default as Dashboard } from './containers/Dashboard'; -export { default as Terminal } from './containers/Terminal'; +export { default as ControlButton } from './buttons/ControlButton'; +export { default as StateIndicator } from './StateIndicator'; +export { default as Dashboard } from './Dashboard'; +export { default as Terminal } from './Terminal'; From a7be4df1bc5083f67f9c9184f4418ce5fadef353 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 28 Apr 2025 22:49:58 +0000 Subject: [PATCH 06/21] chore: update @atyrode/excalidraw dependency to version 0.18.0-7 --- src/frontend/package.json | 2 +- src/frontend/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index 8938e94..c513318 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "@atyrode/excalidraw": "^0.18.0-6", + "@atyrode/excalidraw": "^0.18.0-7", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.74.3", "@tanstack/react-query-devtools": "^5.74.3", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 4331ac4..e4991cf 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@atyrode/excalidraw@^0.18.0-6": - version "0.18.0-6" - resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-6.tgz#60fd05d249638e7a9901627c9c87624aeb72773e" - integrity sha512-M32BiTipeA79Td8iySxsf5tVcNqxsg46W+/bDC/v/7VyU82t7N/mAGrA5inuFO5sUf7Muwm9ysjTLu7yDN9msA== +"@atyrode/excalidraw@^0.18.0-7": + version "0.18.0-7" + resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-7.tgz#10c2a1cf2b4dcc4e765eb73b614b69bd7b3ff7da" + integrity sha512-R3f8aRCcpdKKQUaCTjr+MdtDal89HYNPIGck00I/t1ax0hs63B9ICMuBTvEQqebM+aHxFU4UqwriRWIWkq/l+g== dependencies: "@braintree/sanitize-url" "6.0.2" "@excalidraw/laser-pointer" "1.3.1" From 47e27fdc3c12be3cc677c403aa1bcbc1fcf70bb9 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 28 Apr 2025 23:12:15 +0000 Subject: [PATCH 07/21] feat: add Tabs component styling and functionality - Introduced a new Tabs.scss file for styling the Tabs component. - Updated Tabs.tsx to implement a tabs bar with a new button for creating a new pad, enhancing user interaction within the Excalidraw interface. --- src/frontend/src/ui/Tabs.scss | 10 +++++++ src/frontend/src/ui/Tabs.tsx | 51 +++++++++++++++++------------------ 2 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 src/frontend/src/ui/Tabs.scss diff --git a/src/frontend/src/ui/Tabs.scss b/src/frontend/src/ui/Tabs.scss new file mode 100644 index 0000000..587ab99 --- /dev/null +++ b/src/frontend/src/ui/Tabs.scss @@ -0,0 +1,10 @@ +.tabs-bar { + margin-inline-start: 0.6rem; + height: var(--lg-button-size); + + Button { + height: var(--lg-button-size) !important; + width: var(--lg-button-size) !important; + border: none !important; + } +} \ No newline at end of file diff --git a/src/frontend/src/ui/Tabs.tsx b/src/frontend/src/ui/Tabs.tsx index a1b3a55..83c7fe5 100644 --- a/src/frontend/src/ui/Tabs.tsx +++ b/src/frontend/src/ui/Tabs.tsx @@ -1,24 +1,9 @@ import React from "react"; import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types"; -import { Button, MIME_TYPES } from "@atyrode/excalidraw"; - -const COMMENT_SVG = ( - - - -); +import { Stack, Button, Section, Tooltip } from "@atyrode/excalidraw"; +import { FilePlus2 } from "lucide-react"; +import "./Tabs.scss"; interface TabsProps { excalidrawAPI: ExcalidrawImperativeAPI; @@ -30,14 +15,28 @@ const Tabs: React.FC = ({ excalidrawAPI: ExcalidrawImperativeAPI; }) => { - return ( - <> - - + const appState = excalidrawAPI.getAppState(); + return ( +
+ +
+ {!appState.viewModeEnabled && ( +
+ {}} + children={ +
+ +
} + /> + }> +
+
+ )} +
+
+
); }; From a113138b1173e4a22ab7016136d31b04ecf09c22 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Tue, 29 Apr 2025 02:08:32 +0000 Subject: [PATCH 08/21] chore: update @atyrode/excalidraw dependency to version 0.18.0-8 --- src/frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index c513318..39c93d1 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "@atyrode/excalidraw": "^0.18.0-7", + "@atyrode/excalidraw": "^0.18.0-8", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.74.3", "@tanstack/react-query-devtools": "^5.74.3", From b37b7d445d8b48a3eda0004243958abdc7b3cf35 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Tue, 29 Apr 2025 02:13:46 +0000 Subject: [PATCH 09/21] chore: update @atyrode/excalidraw dependency to version 0.18.0-8 --- src/frontend/yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index e4991cf..d15001f 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@atyrode/excalidraw@^0.18.0-7": - version "0.18.0-7" - resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-7.tgz#10c2a1cf2b4dcc4e765eb73b614b69bd7b3ff7da" - integrity sha512-R3f8aRCcpdKKQUaCTjr+MdtDal89HYNPIGck00I/t1ax0hs63B9ICMuBTvEQqebM+aHxFU4UqwriRWIWkq/l+g== +"@atyrode/excalidraw@^0.18.0-8": + version "0.18.0-8" + resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-8.tgz#878db7be3725ecde5d7f9d92c14cb57bba92d3e7" + integrity sha512-0/HzS6unGQI9EY/mtwoCFfZaFOShUkD4VzqHnXbMxyktcDZ+xxHHE+fgWYt+p/ijXF3MN4ofY7LD8FVGn/Hn5Q== dependencies: "@braintree/sanitize-url" "6.0.2" "@excalidraw/laser-pointer" "1.3.1" From b0aa09d1f5bd4997e6977b1ae06df4904930561b Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Tue, 29 Apr 2025 23:39:58 +0000 Subject: [PATCH 10/21] fix: (temp) work around cytoscape issue by forcing version --- src/frontend/package.json | 4 + src/frontend/yarn.lock | 288 +++++++++++++++++++------------------- 2 files changed, 148 insertions(+), 144 deletions(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index 39c93d1..9d77044 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -15,6 +15,10 @@ "react": "19.0.0", "react-dom": "19.0.0" }, + "resolutions": { + "cytoscape": "3.31.2", + "**/cytoscape": "3.31.2" + }, "devDependencies": { "@types/node": "^22.14.0", "typescript": "^5", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index d15001f..8238f15 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -437,110 +437,110 @@ resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438" integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg== -"@rollup/rollup-android-arm-eabi@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz#d964ee8ce4d18acf9358f96adc408689b6e27fe3" - integrity sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg== - -"@rollup/rollup-android-arm64@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz#9b5e130ecc32a5fc1e96c09ff371743ee71a62d3" - integrity sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w== - -"@rollup/rollup-darwin-arm64@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.0.tgz#ef439182c739b20b3c4398cfc03e3c1249ac8903" - integrity sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ== - -"@rollup/rollup-darwin-x64@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.0.tgz#d7380c1531ab0420ca3be16f17018ef72dd3d504" - integrity sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA== - -"@rollup/rollup-freebsd-arm64@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.0.tgz#cbcbd7248823c6b430ce543c59906dd3c6df0936" - integrity sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg== - -"@rollup/rollup-freebsd-x64@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.0.tgz#96bf6ff875bab5219c3472c95fa6eb992586a93b" - integrity sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw== - -"@rollup/rollup-linux-arm-gnueabihf@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.0.tgz#d80cd62ce6d40f8e611008d8dbf03b5e6bbf009c" - integrity sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA== - -"@rollup/rollup-linux-arm-musleabihf@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.0.tgz#75440cfc1e8d0f87a239b4c31dfeaf4719b656b7" - integrity sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg== - -"@rollup/rollup-linux-arm64-gnu@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.0.tgz#ac527485ecbb619247fb08253ec8c551a0712e7c" - integrity sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg== - -"@rollup/rollup-linux-arm64-musl@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.0.tgz#74d2b5cb11cf714cd7d1682e7c8b39140e908552" - integrity sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ== - -"@rollup/rollup-linux-loongarch64-gnu@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz#a0a310e51da0b5fea0e944b0abd4be899819aef6" - integrity sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg== - -"@rollup/rollup-linux-powerpc64le-gnu@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.0.tgz#4077e2862b0ac9f61916d6b474d988171bd43b83" - integrity sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw== - -"@rollup/rollup-linux-riscv64-gnu@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.0.tgz#5812a1a7a2f9581cbe12597307cc7ba3321cf2f3" - integrity sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA== - -"@rollup/rollup-linux-riscv64-musl@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.0.tgz#973aaaf4adef4531375c36616de4e01647f90039" - integrity sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ== - -"@rollup/rollup-linux-s390x-gnu@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.0.tgz#9bad59e907ba5bfcf3e9dbd0247dfe583112f70b" - integrity sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw== - -"@rollup/rollup-linux-x64-gnu@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.0.tgz#68b045a720bd9b4d905f462b997590c2190a6de0" - integrity sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ== - -"@rollup/rollup-linux-x64-musl@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.0.tgz#8e703e2c2ad19ba7b2cb3d8c3a4ad11d4ee3a282" - integrity sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw== - -"@rollup/rollup-win32-arm64-msvc@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.0.tgz#c5bee19fa670ff5da5f066be6a58b4568e9c650b" - integrity sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ== - -"@rollup/rollup-win32-ia32-msvc@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.0.tgz#846e02c17044bd922f6f483a3b4d36aac6e2b921" - integrity sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA== - -"@rollup/rollup-win32-x64-msvc@4.40.0": - version "4.40.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz#fd92d31a2931483c25677b9c6698106490cbbc76" - integrity sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ== - -"@tanstack/query-core@5.74.7": - version "5.74.7" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.74.7.tgz#beb565a5f3d95a1e3bd756e5eb1e41c3eb48ae7f" - integrity sha512-X3StkN/Y6KGHndTjJf8H8th7AX4bKfbRpiVhVqevf0QWlxl6DhyJ0TYG3R0LARa/+xqDwzU9mA4pbJxzPCI29A== +"@rollup/rollup-android-arm-eabi@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz#e1562d360bca73c7bef6feef86098de3a2f1d442" + integrity sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw== + +"@rollup/rollup-android-arm64@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz#37ba63940211673e15dcc5f469a78e34276dbca7" + integrity sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw== + +"@rollup/rollup-darwin-arm64@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz#58b1eb86d997d71dabc5b78903233a3c27438ca0" + integrity sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA== + +"@rollup/rollup-darwin-x64@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.1.tgz#5e22dab3232b1e575d930ce891abb18fe19c58c9" + integrity sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw== + +"@rollup/rollup-freebsd-arm64@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz#04c892d9ff864d66e31419634726ab0bebb33707" + integrity sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw== + +"@rollup/rollup-freebsd-x64@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.1.tgz#f4b1e091f7cf5afc9e3a029d70128ad56409ecfb" + integrity sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.1.tgz#c8814bb5ce047a81b1fe4a33628dfd4ac52bd864" + integrity sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg== + +"@rollup/rollup-linux-arm-musleabihf@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.1.tgz#5b4e7bd83cbebbf5ffe958802dcfd4ee34bf73a3" + integrity sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg== + +"@rollup/rollup-linux-arm64-gnu@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.1.tgz#141c848e53cee011e82a11777b8a51f1b3e8d77c" + integrity sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg== + +"@rollup/rollup-linux-arm64-musl@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.1.tgz#22ebeaf2fa301aa4aa6c84b760e6cd1d1ac7eb1e" + integrity sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ== + +"@rollup/rollup-linux-loongarch64-gnu@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.1.tgz#20b77dc78e622f5814ff8e90c14c938ceb8043bc" + integrity sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ== + +"@rollup/rollup-linux-powerpc64le-gnu@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.1.tgz#2c90f99c987ef1198d4f8d15d754c286e1f07b13" + integrity sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg== + +"@rollup/rollup-linux-riscv64-gnu@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.1.tgz#9336fd5e47d7f4760d02aa85f76976176eef53ca" + integrity sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ== + +"@rollup/rollup-linux-riscv64-musl@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.1.tgz#d75b4d54d46439bb5c6c13762788f57e798f5670" + integrity sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA== + +"@rollup/rollup-linux-s390x-gnu@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.1.tgz#e9f09b802f1291839247399028beaef9ce034c81" + integrity sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg== + +"@rollup/rollup-linux-x64-gnu@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.1.tgz#0413169dc00470667dea8575c1129d4e7a73eb29" + integrity sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ== + +"@rollup/rollup-linux-x64-musl@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.1.tgz#c76fd593323c60ea219439a00da6c6d33ffd0ea6" + integrity sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ== + +"@rollup/rollup-win32-arm64-msvc@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.1.tgz#c7724c386eed0bda5ae7143e4081c1910cab349b" + integrity sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg== + +"@rollup/rollup-win32-ia32-msvc@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.1.tgz#7749e1b65cb64fe6d41ad1ad9e970a0ccc8ac350" + integrity sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA== + +"@rollup/rollup-win32-x64-msvc@4.40.1": + version "4.40.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.1.tgz#8078b71fe0d5825dcbf83d52a7dc858b39da165c" + integrity sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA== + +"@tanstack/query-core@5.74.9": + version "5.74.9" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.74.9.tgz#35d5b1075663072bea22aa3ce21508b195306ecd" + integrity sha512-qmjXpWyigDw4SfqdSBy24FzRvpBPXlaSbl92N77lcrL+yvVQLQkf0T6bQNbTxl9IEB/SvVFhhVZoIlQvFnNuuw== "@tanstack/query-devtools@5.74.7": version "5.74.7" @@ -548,18 +548,18 @@ integrity sha512-nSNlfuGdnHf4yB0S+BoNYOE1o3oAH093weAYZolIHfS2stulyA/gWfSk/9H4ZFk5mAAHb5vNqAeJOmbdcGPEQw== "@tanstack/react-query-devtools@^5.74.3": - version "5.74.7" - resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.74.7.tgz#97fd56ae76996467d544b546a1bd38aab4a94d1e" - integrity sha512-j60esTQF+ES0x52kQUYOX0Z8AJUcqCGANj6GaOf8J3YQz2bZPB1imLSw4SFeM3Ozv8uO/X/Dmh3IT1z+y57ZLQ== + version "5.74.11" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.74.11.tgz#81c078d4f202c51065de1735415360b80f2e1e12" + integrity sha512-vx8MzH4WUUk4ZW8uHq7T45XNDgePF5ecRoa7haWJZxDMQyAHM80GGMhEW/yRz6TeyS9UlfTUz2OLPvgGRvvVOA== dependencies: "@tanstack/query-devtools" "5.74.7" "@tanstack/react-query@^5.74.3": - version "5.74.7" - resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.74.7.tgz#3507541b43de72399a19a71ff66828a12b859581" - integrity sha512-u4o/RIWnnrq26orGZu2NDPwmVof1vtAiiV6KYUXd49GuK+8HX+gyxoAYqIaZogvCE1cqOuZAhQKcrKGYGkrLxg== + version "5.74.11" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.74.11.tgz#636f321ba3ee86060d15a961517a292ad57cb754" + integrity sha512-FFhn9ZiYRUOsxLAWZYxVfQTpVE7UWRaAeHJIWVDHKlmZZGc16rMHW9KrFZ8peC4hA71QUf/shJD8dPSMqDnRmA== dependencies: - "@tanstack/query-core" "5.74.7" + "@tanstack/query-core" "5.74.9" "@types/crypto-js@^4.2.2": version "4.2.2" @@ -608,9 +608,9 @@ integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== "@types/node@^22.14.0": - version "22.15.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.2.tgz#1db55aa64618ee93a58c8912f74beefe44aca905" - integrity sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A== + version "22.15.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.3.tgz#b7fb9396a8ec5b5dfb1345d8ac2502060e9af68b" + integrity sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw== dependencies: undici-types "~6.21.0" @@ -692,9 +692,9 @@ commander@^8.3.0: integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== core-js@^3.38.1: - version "3.41.0" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.41.0.tgz#57714dafb8c751a6095d028a7428f1fb5834a776" - integrity sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA== + version "3.42.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.42.0.tgz#edbe91f78ac8cfb6df8d997e74d368a68082fe37" + integrity sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g== cose-base@^1.0.0: version "1.0.3" @@ -736,7 +736,7 @@ cytoscape-cose-bilkent@^4.1.0: dependencies: cose-base "^1.0.0" -cytoscape@^3.28.1: +cytoscape@3.31.2, cytoscape@^3.28.1: version "3.31.2" resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.31.2.tgz#94d5b86d142599a2d6e750f6b2f3102518c7d48e" integrity sha512-/eOXg2uGdMdpGlEes5Sf6zE+jUG+05f3htFNQIxLxduOH/SsaUZiPBfAwP1btVIVzsnhiNOdi+hvDRLYfMZjGw== @@ -1656,9 +1656,9 @@ postcss@^8.4.36: source-map-js "^1.2.1" posthog-js@^1.236.0: - version "1.236.7" - resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.236.7.tgz#6a071905d4466573b80fd52bad1d3478594805b7" - integrity sha512-HatTinqAt/6aAraCgbnP+2MTeVTChdf6TDsQkef4/yUnXeA4tsHmXnGGJ3vnzQk7N//R6lIHN189BZDO9kuKAg== + version "1.237.0" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.237.0.tgz#351dc1984fc6124e6bbb21efe7ba8e116429560c" + integrity sha512-DyZfwDRz405cKKskL22CXvc9EpkBmuM9lCOYsZO3L1/zXu7IGiP9nNlLaxlzy7K/8mHxQ3szoy/DBSw/zXL1pw== dependencies: core-js "^3.38.1" fflate "^0.4.8" @@ -1727,32 +1727,32 @@ robust-predicates@^3.0.2: integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== rollup@^4.13.0: - version "4.40.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.40.0.tgz#13742a615f423ccba457554f006873d5a4de1920" - integrity sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w== + version "4.40.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.40.1.tgz#03d6c53ebb6a9c2c060ae686a61e72a2472b366f" + integrity sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw== dependencies: "@types/estree" "1.0.7" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.40.0" - "@rollup/rollup-android-arm64" "4.40.0" - "@rollup/rollup-darwin-arm64" "4.40.0" - "@rollup/rollup-darwin-x64" "4.40.0" - "@rollup/rollup-freebsd-arm64" "4.40.0" - "@rollup/rollup-freebsd-x64" "4.40.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.40.0" - "@rollup/rollup-linux-arm-musleabihf" "4.40.0" - "@rollup/rollup-linux-arm64-gnu" "4.40.0" - "@rollup/rollup-linux-arm64-musl" "4.40.0" - "@rollup/rollup-linux-loongarch64-gnu" "4.40.0" - "@rollup/rollup-linux-powerpc64le-gnu" "4.40.0" - "@rollup/rollup-linux-riscv64-gnu" "4.40.0" - "@rollup/rollup-linux-riscv64-musl" "4.40.0" - "@rollup/rollup-linux-s390x-gnu" "4.40.0" - "@rollup/rollup-linux-x64-gnu" "4.40.0" - "@rollup/rollup-linux-x64-musl" "4.40.0" - "@rollup/rollup-win32-arm64-msvc" "4.40.0" - "@rollup/rollup-win32-ia32-msvc" "4.40.0" - "@rollup/rollup-win32-x64-msvc" "4.40.0" + "@rollup/rollup-android-arm-eabi" "4.40.1" + "@rollup/rollup-android-arm64" "4.40.1" + "@rollup/rollup-darwin-arm64" "4.40.1" + "@rollup/rollup-darwin-x64" "4.40.1" + "@rollup/rollup-freebsd-arm64" "4.40.1" + "@rollup/rollup-freebsd-x64" "4.40.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.40.1" + "@rollup/rollup-linux-arm-musleabihf" "4.40.1" + "@rollup/rollup-linux-arm64-gnu" "4.40.1" + "@rollup/rollup-linux-arm64-musl" "4.40.1" + "@rollup/rollup-linux-loongarch64-gnu" "4.40.1" + "@rollup/rollup-linux-powerpc64le-gnu" "4.40.1" + "@rollup/rollup-linux-riscv64-gnu" "4.40.1" + "@rollup/rollup-linux-riscv64-musl" "4.40.1" + "@rollup/rollup-linux-s390x-gnu" "4.40.1" + "@rollup/rollup-linux-x64-gnu" "4.40.1" + "@rollup/rollup-linux-x64-musl" "4.40.1" + "@rollup/rollup-win32-arm64-msvc" "4.40.1" + "@rollup/rollup-win32-ia32-msvc" "4.40.1" + "@rollup/rollup-win32-x64-msvc" "4.40.1" fsevents "~2.3.2" roughjs@4.6.4: From 76bf5fb25933b9b62abf0d6891ecfc25fff8e7d6 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 4 May 2025 19:41:59 +0000 Subject: [PATCH 11/21] refactor: replace console.log with console.debug for build info and logout messages - Updated logging statements in vite.config.mts, BuildVersionCheck.tsx, and MainMenu.tsx to use console.debug instead of console.log for improved log level management. - Added a prefix '[pad.ws]' to debug messages for better context in logs. --- src/frontend/src/BuildVersionCheck.tsx | 4 ++-- src/frontend/src/ui/MainMenu.tsx | 4 ++-- src/frontend/vite.config.mts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/BuildVersionCheck.tsx b/src/frontend/src/BuildVersionCheck.tsx index 98bce93..162732c 100644 --- a/src/frontend/src/BuildVersionCheck.tsx +++ b/src/frontend/src/BuildVersionCheck.tsx @@ -40,7 +40,7 @@ export function BuildVersionCheck() { useEffect(() => { // On first load, store the initial build hash if (buildInfo?.buildHash && initialBuildHash === null) { - console.log('Initial build hash:', buildInfo.buildHash); + console.debug('[pad.ws] Initial build hash:', buildInfo.buildHash); setInitialBuildHash(buildInfo.buildHash); } @@ -49,7 +49,7 @@ export function BuildVersionCheck() { buildInfo?.buildHash && initialBuildHash !== buildInfo.buildHash) { - console.log('New version detected. Current:', initialBuildHash, 'New:', buildInfo.buildHash); + console.debug('[pad.ws] New version detected. Current:', initialBuildHash, 'New:', buildInfo.buildHash); // Save the canvas and then refresh handleVersionUpdate(); diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index b1d41d4..6f10cdc 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -194,9 +194,9 @@ export const MainMenuConfig: React.FC = ({ queryClient.invalidateQueries({ queryKey: ['userProfile'] }); // No need to redirect to the logout URL since we're already handling it via iframe - console.log("Logged out successfully"); + console.debug("[pad.ws] Logged out successfully"); } catch (error) { - console.error("Logout failed:", error); + console.error("[pad.ws] Logout failed:", error); } }; diff --git a/src/frontend/vite.config.mts b/src/frontend/vite.config.mts index 47e988d..856bb0d 100644 --- a/src/frontend/vite.config.mts +++ b/src/frontend/vite.config.mts @@ -24,7 +24,7 @@ const generateBuildInfoPlugin = (): Plugin => ({ JSON.stringify(buildInfo, null, 2) ); - console.log('Generated build-info.json with hash:', buildInfo.buildHash); + console.debug('[pad.ws] Generated build-info.json with hash:', buildInfo.buildHash); } }); From 50d0623ae05e2829212d68499d89ee67c5baeb01 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 4 May 2025 21:22:55 +0000 Subject: [PATCH 12/21] refactor: remove main execution block from CoderAPI - Deleted the main execution block in coder.py that instantiated CoderAPI and performed workspace status checks, streamlining the code for better modularity. --- src/backend/coder.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/backend/coder.py b/src/backend/coder.py index 9b8d33d..6ae5a7b 100644 --- a/src/backend/coder.py +++ b/src/backend/coder.py @@ -311,11 +311,3 @@ def set_workspace_dormancy(self, workspace_id, dormant: bool): response = requests.put(endpoint, headers=headers, json=data) response.raise_for_status() return response.json() - - -if __name__ == "__main__": - coder = CoderAPI() - workspace_id = coder.get_workspace_status_for_user("alex")["id"] - coder.set_workspace_dormancy(workspace_id, True) - state = coder.get_workspace_status_for_user("alex") - print(state) From 672a66b9d68affbb9e7576582ce017dc5a7adcdb Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 4 May 2025 23:12:55 +0000 Subject: [PATCH 13/21] feat: add tabs - Updated PadRepository to return pads sorted by creation timestamp for better organization. - Refactored PadService to include type annotations for pads and removed redundant checks for existing pads during creation and updates. - Implemented new API endpoints in PadRouter for updating, renaming, and deleting pads, enhancing user interaction with pad data. - Introduced a context menu in the frontend for pad actions (rename, delete) and improved pad selection handling in Tabs component. - Added utility functions for managing pad data in local storage, ensuring a seamless user experience across sessions. --- .../database/repository/pad_repository.py | 4 +- src/backend/database/service/pad_service.py | 15 +- src/backend/routers/pad_router.py | 174 +++++++++-- src/frontend/src/App.tsx | 61 +++- src/frontend/src/api/hooks.ts | 82 ++++- src/frontend/src/ui/TabContextMenu.scss | 52 ++++ src/frontend/src/ui/TabContextMenu.tsx | 109 +++++++ src/frontend/src/ui/Tabs.scss | 38 ++- src/frontend/src/ui/Tabs.tsx | 291 ++++++++++++++++-- src/frontend/src/utils/canvasUtils.ts | 202 +++++++++++- 10 files changed, 933 insertions(+), 95 deletions(-) create mode 100644 src/frontend/src/ui/TabContextMenu.scss create mode 100644 src/frontend/src/ui/TabContextMenu.tsx diff --git a/src/backend/database/repository/pad_repository.py b/src/backend/database/repository/pad_repository.py index 7b8123a..a9b6c67 100644 --- a/src/backend/database/repository/pad_repository.py +++ b/src/backend/database/repository/pad_repository.py @@ -33,8 +33,8 @@ async def get_by_id(self, pad_id: UUID) -> Optional[PadModel]: return result.scalars().first() async def get_by_owner(self, owner_id: UUID) -> List[PadModel]: - """Get all pads for a specific owner""" - stmt = select(PadModel).where(PadModel.owner_id == owner_id) + """Get all pads for a specific owner, sorted by created_at timestamp""" + stmt = select(PadModel).where(PadModel.owner_id == owner_id).order_by(PadModel.created_at) result = await self.session.execute(stmt) return result.scalars().all() diff --git a/src/backend/database/service/pad_service.py b/src/backend/database/service/pad_service.py index 9f1fdaf..c1c382e 100644 --- a/src/backend/database/service/pad_service.py +++ b/src/backend/database/service/pad_service.py @@ -9,7 +9,7 @@ from ..repository import PadRepository, UserRepository from .user_service import UserService - +from ..models import PadModel # Use TYPE_CHECKING to avoid circular imports if TYPE_CHECKING: from dependencies import UserSession @@ -63,11 +63,6 @@ async def create_pad(self, owner_id: UUID, display_name: str, data: Dict[str, An print(f"Error creating user as failsafe: {str(e)}") raise ValueError(f"Failed to create user with ID '{owner_id}': {str(e)}") - # Check if pad with same name already exists for this owner - existing_pad = await self.repository.get_by_name(owner_id, display_name) - if existing_pad: - raise ValueError(f"Pad with name '{display_name}' already exists for this user") - # Create pad pad = await self.repository.create(owner_id, display_name, data) return pad.to_dict() @@ -86,7 +81,7 @@ async def get_pads_by_owner(self, owner_id: UUID) -> List[Dict[str, Any]]: # This allows the pad_router to handle the case where a user doesn't exist return [] - pads = await self.repository.get_by_owner(owner_id) + pads: list[PadModel] = await self.repository.get_by_owner(owner_id) return [pad.to_dict() for pad in pads] async def get_pad_by_name(self, owner_id: UUID, display_name: str) -> Optional[Dict[str, Any]]: @@ -105,12 +100,6 @@ async def update_pad(self, pad_id: UUID, data: Dict[str, Any]) -> Optional[Dict[ if 'display_name' in data and not data['display_name']: raise ValueError("Display name cannot be empty") - # Check if new display_name already exists for this owner (if being updated) - if 'display_name' in data and data['display_name'] != pad.display_name: - existing_pad = await self.repository.get_by_name(pad.owner_id, data['display_name']) - if existing_pad: - raise ValueError(f"Pad with name '{data['display_name']}' already exists for this user") - # Update pad updated_pad = await self.repository.update(pad_id, data) return updated_pad.to_dict() if updated_pad else None diff --git a/src/backend/routers/pad_router.py b/src/backend/routers/pad_router.py index f1235a1..8fbb975 100644 --- a/src/backend/routers/pad_router.py +++ b/src/backend/routers/pad_router.py @@ -9,35 +9,60 @@ from config import MAX_BACKUPS_PER_USER, MIN_INTERVAL_MINUTES, DEFAULT_PAD_NAME, DEFAULT_TEMPLATE_NAME pad_router = APIRouter() +def ensure_pad_metadata(data: Dict[str, Any], pad_id: str, display_name: str) -> Dict[str, Any]: + """ + Ensure the pad metadata (uniqueId and displayName) is set in the data. + + Args: + data: The pad data to modify + pad_id: The pad ID to set as uniqueId + display_name: The display name to set + + Returns: + The modified data + """ + # Ensure the appState and pad objects exist + if "appState" not in data: + data["appState"] = {} + if "pad" not in data["appState"]: + data["appState"]["pad"] = {} + + # Set the uniqueId to match the database ID + data["appState"]["pad"]["uniqueId"] = str(pad_id) + data["appState"]["pad"]["displayName"] = display_name + + return data + -@pad_router.post("") -async def save_pad( +@pad_router.post("/{pad_id}") +async def update_specific_pad( + pad_id: UUID, data: Dict[str, Any], user: UserSession = Depends(require_auth), pad_service: PadService = Depends(get_pad_service), backup_service: BackupService = Depends(get_backup_service), ): - """Save pad data for the authenticated user""" + """Update a specific pad's data for the authenticated user""" try: - # Check if user already has a pad - user_pads = await pad_service.get_pads_by_owner(user.id) + # Get the pad to verify ownership + pad = await pad_service.get_pad(pad_id) - if not user_pads: - # Create a new pad if user doesn't have one - pad = await pad_service.create_pad( - owner_id=user.id, - display_name=DEFAULT_PAD_NAME, - data=data, - user_session=user - ) - else: - # Update existing pad - pad = user_pads[0] # Use the first pad (assuming one pad per user for now) - await pad_service.update_pad_data(pad["id"], data) + if not pad: + raise HTTPException(status_code=404, detail="Pad not found") - # Create a backup only if needed (if none exist or latest is > 5 min old) + # Verify the user owns this pad + if str(pad["owner_id"]) != str(user.id): + raise HTTPException(status_code=403, detail="You don't have permission to update this pad") + + # Ensure the uniqueId and displayName are set in the data + data = ensure_pad_metadata(data, str(pad_id), pad["display_name"]) + + # Update the pad + await pad_service.update_pad_data(pad_id, data) + + # Create a backup if needed await backup_service.create_backup_if_needed( - source_id=pad["id"], + source_id=pad_id, data=data, min_interval_minutes=MIN_INTERVAL_MINUTES, max_backups=MAX_BACKUPS_PER_USER @@ -45,25 +70,94 @@ async def save_pad( return {"status": "success"} except Exception as e: - print(f"Error saving pad data: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to save canvas data: {str(e)}") + print(f"Error updating pad: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to update pad: {str(e)}") + + +@pad_router.patch("/{pad_id}") +async def rename_pad( + pad_id: UUID, + data: Dict[str, str], + user: UserSession = Depends(require_auth), + pad_service: PadService = Depends(get_pad_service), +): + """Rename a pad for the authenticated user""" + try: + # Get the pad to verify ownership + pad = await pad_service.get_pad(pad_id) + + if not pad: + raise HTTPException(status_code=404, detail="Pad not found") + + # Verify the user owns this pad + if str(pad["owner_id"]) != str(user.id): + raise HTTPException(status_code=403, detail="You don't have permission to rename this pad") + + # Check if display_name is provided + if "display_name" not in data: + raise HTTPException(status_code=400, detail="display_name is required") + + # Update the pad's display name + update_data = {"display_name": data["display_name"]} + updated_pad = await pad_service.update_pad(pad_id, update_data) + + return {"status": "success", "pad": updated_pad} + except ValueError as e: + print(f"Error renaming pad: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + print(f"Error renaming pad: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to rename pad: {str(e)}") + + +@pad_router.delete("/{pad_id}") +async def delete_pad( + pad_id: UUID, + user: UserSession = Depends(require_auth), + pad_service: PadService = Depends(get_pad_service), +): + """Delete a pad for the authenticated user""" + try: + # Get the pad to verify ownership + pad = await pad_service.get_pad(pad_id) + + if not pad: + raise HTTPException(status_code=404, detail="Pad not found") + + # Verify the user owns this pad + if str(pad["owner_id"]) != str(user.id): + raise HTTPException(status_code=403, detail="You don't have permission to delete this pad") + + # Delete the pad + success = await pad_service.delete_pad(pad_id) + + if not success: + raise HTTPException(status_code=500, detail="Failed to delete pad") + + return {"status": "success"} + except ValueError as e: + print(f"Error deleting pad: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + print(f"Error deleting pad: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to delete pad: {str(e)}") @pad_router.get("") -async def get_pad( +async def get_all_pads( user: UserSession = Depends(require_auth), pad_service: PadService = Depends(get_pad_service), template_pad_service: TemplatePadService = Depends(get_template_pad_service), backup_service: BackupService = Depends(get_backup_service) ): - """Get pad data for the authenticated user""" + """Get all pads for the authenticated user""" try: # Get user's pads user_pads = await pad_service.get_pads_by_owner(user.id) if not user_pads: - # Return default canvas if user doesn't have a pad - return await create_pad_from_template( + # Create a default pad if user doesn't have any + new_pad = await create_pad_from_template( name=DEFAULT_TEMPLATE_NAME, display_name=DEFAULT_PAD_NAME, user=user, @@ -71,9 +165,19 @@ async def get_pad( template_pad_service=template_pad_service, backup_service=backup_service ) + + # Return the new pad in a list + return [new_pad] - # Return the first pad's data (assuming one pad per user for now) - return user_pads[0]["data"] + # Ensure each pad's data has the uniqueId and displayName set + for pad in user_pads: + pad_data = pad["data"] + + # Ensure the uniqueId and displayName are set in the data + pad_data = ensure_pad_metadata(pad_data, str(pad["id"]), pad["display_name"]) + + # Return all pads + return user_pads except Exception as e: print(f"Error getting pad data: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to get pad data: {str(e)}") @@ -96,18 +200,30 @@ async def create_pad_from_template( if not template: raise HTTPException(status_code=404, detail="Template not found") + # Get the template data + template_data = template["data"] + + # Before creating, ensure the pad object exists in the data + template_data = ensure_pad_metadata(template_data, "", "") + # Create a new pad using the template data pad = await pad_service.create_pad( owner_id=user.id, display_name=display_name, - data=template["data"], + data=template_data, user_session=user ) + # Set the uniqueId and displayName to match the database ID and display name + template_data = ensure_pad_metadata(template_data, str(pad["id"]), display_name) + + # Update the pad with the modified data + await pad_service.update_pad_data(pad["id"], template_data) + # Create an initial backup for the new pad await backup_service.create_backup_if_needed( source_id=pad["id"], - data=template["data"], + data=template_data, min_interval_minutes=0, # Always create initial backup max_backups=MAX_BACKUPS_PER_USER ) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 84ac890..2630c5a 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,9 +1,17 @@ import React, { useState, useCallback, useEffect, useRef } from "react"; -import { useCanvas, useDefaultCanvas, useUserProfile } from "./api/hooks"; +import { useAllPads, useUserProfile } from "./api/hooks"; import { ExcalidrawWrapper } from "./ExcalidrawWrapper"; import { debounce } from "./utils/debounce"; import posthog from "./utils/posthog"; -import { normalizeCanvasData } from "./utils/canvasUtils"; +import { + normalizeCanvasData, + getPadData, + storePadData, + setActivePad, + getActivePad, + getStoredActivePad, + loadPadData +} from "./utils/canvasUtils"; import { useSaveCanvas } from "./api/hooks"; import type * as TExcalidraw from "@atyrode/excalidraw"; import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; @@ -28,23 +36,49 @@ export default function App({ const { data: isAuthenticated, isLoading: isAuthLoading } = useAuthCheck(); const { data: userProfile } = useUserProfile(); - // Only enable canvas queries if authenticated and not loading - const { data: canvasData } = useCanvas({ - queryKey: ['canvas'], + // Only enable pad queries if authenticated and not loading + const { data: pads } = useAllPads({ + queryKey: ['allPads'], enabled: isAuthenticated === true && !isAuthLoading, retry: 1, }); + + // Get the first pad's data to use as the canvas data + const canvasData = pads && pads.length > 0 ? pads[0].data : null; // Excalidraw API ref const [excalidrawAPI, setExcalidrawAPI] = useState(null); useCustom(excalidrawAPI, customArgs); useHandleLibrary({ excalidrawAPI }); + // Using imported functions from canvasUtils.ts + useEffect(() => { - if (excalidrawAPI && canvasData) { - excalidrawAPI.updateScene(normalizeCanvasData(canvasData)); + if (excalidrawAPI && pads && pads.length > 0) { + // Check if there's a stored active pad ID + const storedActivePadId = getStoredActivePad(); + + // Find the pad that matches the stored ID, or use the first pad if no match + let padToActivate = pads[0]; + + if (storedActivePadId) { + // Try to find the pad with the stored ID + const matchingPad = pads.find(pad => pad.id === storedActivePadId); + if (matchingPad) { + console.debug(`[pad.ws] Found stored active pad in App.tsx: ${storedActivePadId}`); + padToActivate = matchingPad; + } else { + console.debug(`[pad.ws] Stored active pad ${storedActivePadId} not found in available pads`); + } + } + + // Set the active pad ID globally + setActivePad(padToActivate.id); + + // Load the pad data for the selected pad + loadPadData(excalidrawAPI, padToActivate.id, padToActivate.data); } - }, [excalidrawAPI, canvasData]); + }, [excalidrawAPI, pads]); const { mutate: saveCanvas } = useSaveCanvas({ onSuccess: () => { @@ -72,6 +106,10 @@ export default function App({ (elements: NonDeletedExcalidrawElement[], state: AppState, files: any) => { if (!isAuthenticated) return; + // Get the active pad ID using the imported function + const activePadId = getActivePad(); + if (!activePadId) return; + const canvasData = { elements, appState: state, @@ -81,12 +119,17 @@ export default function App({ const serialized = JSON.stringify(canvasData); if (serialized !== lastSentCanvasDataRef.current) { lastSentCanvasDataRef.current = serialized; + + // Store the canvas data in local storage + storePadData(activePadId, canvasData); + + // Save the canvas data to the server saveCanvas(canvasData); } }, 1200 ), - [saveCanvas, isAuthenticated] + [saveCanvas, isAuthenticated, storePadData] ); useEffect(() => { diff --git a/src/frontend/src/api/hooks.ts b/src/frontend/src/api/hooks.ts index c4a7a5e..389731f 100644 --- a/src/frontend/src/api/hooks.ts +++ b/src/frontend/src/api/hooks.ts @@ -29,6 +29,15 @@ export interface CanvasData { files: any; } +export interface PadData { + id: string; + owner_id: string; + display_name: string; + data: CanvasData; + created_at: string; + updated_at: string; +} + export interface CanvasBackup { id: number; timestamp: string; @@ -99,8 +108,9 @@ export const api = { } }, - // Canvas - getCanvas: async (): Promise => { + // Canvas functions are now handled through getAllPads + + getAllPads: async (): Promise => { try { const result = await fetchApi('/api/pad'); return result; @@ -111,7 +121,18 @@ export const api = { saveCanvas: async (data: CanvasData): Promise => { try { - const result = await fetchApi('/api/pad', { + // Get the active pad ID from the global variable + const activePadId = (window as any).activePadId; + + // We must have an active pad ID to save + if (!activePadId) { + throw new Error("No active pad ID found. Cannot save canvas."); + } + + // Use the specific pad endpoint + const endpoint = `/api/pad/${activePadId}`; + + const result = await fetchApi(endpoint, { method: 'POST', body: JSON.stringify(data), }); @@ -121,6 +142,31 @@ export const api = { } }, + renamePad: async (padId: string, newName: string): Promise => { + try { + const endpoint = `/api/pad/${padId}`; + const result = await fetchApi(endpoint, { + method: 'PATCH', + body: JSON.stringify({ display_name: newName }), + }); + return result; + } catch (error) { + throw error; + } + }, + + deletePad: async (padId: string): Promise => { + try { + const endpoint = `/api/pad/${padId}`; + const result = await fetchApi(endpoint, { + method: 'DELETE', + }); + return result; + } catch (error) { + throw error; + } + }, + getDefaultCanvas: async (): Promise => { try { const result = await fetchApi('/api/templates/default'); @@ -183,18 +229,10 @@ export function useWorkspaceState(options?: UseQueryOptions) { }); } -export function useCanvas(options?: UseQueryOptions) { +export function useAllPads(options?: UseQueryOptions) { return useQuery({ - queryKey: ['canvas'], - queryFn: api.getCanvas, - ...options, - }); -} - -export function useDefaultCanvas(options?: UseQueryOptions) { - return useQuery({ - queryKey: ['defaultCanvas'], - queryFn: api.getDefaultCanvas, + queryKey: ['allPads'], + queryFn: api.getAllPads, ...options, }); } @@ -249,3 +287,19 @@ export function useSaveCanvas(options?: UseMutationOptions) { + return useMutation({ + mutationFn: ({ padId, newName }) => api.renamePad(padId, newName), + // No automatic invalidation - we'll update the cache manually + ...options, + }); +} + +export function useDeletePad(options?: UseMutationOptions) { + return useMutation({ + mutationFn: (padId) => api.deletePad(padId), + // No automatic invalidation - we'll update the cache manually + ...options, + }); +} diff --git a/src/frontend/src/ui/TabContextMenu.scss b/src/frontend/src/ui/TabContextMenu.scss new file mode 100644 index 0000000..c70129a --- /dev/null +++ b/src/frontend/src/ui/TabContextMenu.scss @@ -0,0 +1,52 @@ +.tab-context-menu { + position: fixed; + background-color: #fff; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + min-width: 120px; + z-index: 1000; + + .menu-item { + padding: 8px 12px; + cursor: pointer; + + &:hover { + background-color: #f5f5f5; + } + + &.delete { + color: #e53935; + + &:hover { + background-color: #ffebee; + } + } + } + + form { + padding: 8px; + display: flex; + + input { + flex: 1; + padding: 4px 8px; + border: 1px solid #ccc; + border-radius: 4px; + margin-right: 4px; + } + + button { + background-color: #4285f4; + color: white; + border: none; + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + + &:hover { + background-color: #3367d6; + } + } + } +} diff --git a/src/frontend/src/ui/TabContextMenu.tsx b/src/frontend/src/ui/TabContextMenu.tsx new file mode 100644 index 0000000..ea941fe --- /dev/null +++ b/src/frontend/src/ui/TabContextMenu.tsx @@ -0,0 +1,109 @@ +import React, { useState, useRef, useEffect } from 'react'; +import './TabContextMenu.scss'; + +interface TabContextMenuProps { + x: number; + y: number; + padId: string; + padName: string; + onRename: (padId: string, newName: string) => void; + onDelete: (padId: string) => void; + onClose: () => void; +} + +const TabContextMenu: React.FC = ({ + x, + y, + padId, + padName, + onRename, + onDelete, + onClose +}) => { + const [isRenaming, setIsRenaming] = useState(false); + const [newName, setNewName] = useState(padName); + const menuRef = useRef(null); + const inputRef = useRef(null); + + // Position the menu above the cursor + const style = { + position: 'fixed' as const, + top: `${y - 80}px`, // Position above the cursor + left: `${x}px`, + }; + + // Handle clicks outside the menu to close it + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onClose]); + + // Focus the input when renaming + useEffect(() => { + if (isRenaming && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isRenaming]); + + const handleRenameClick = () => { + setIsRenaming(true); + }; + + const handleRenameSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (newName.trim() !== '') { + onRename(padId, newName); + setIsRenaming(false); + onClose(); + } + }; + + const handleDeleteClick = () => { + if (window.confirm(`Are you sure you want to delete "${padName}"?`)) { + onDelete(padId); + onClose(); + } + }; + + return ( +
+ {isRenaming ? ( +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setIsRenaming(false); + onClose(); + } + }} + /> + +
+ ) : ( + <> +
+ Rename +
+
+ Delete +
+ + )} +
+ ); +}; + +export default TabContextMenu; diff --git a/src/frontend/src/ui/Tabs.scss b/src/frontend/src/ui/Tabs.scss index 587ab99..67ca4a2 100644 --- a/src/frontend/src/ui/Tabs.scss +++ b/src/frontend/src/ui/Tabs.scss @@ -4,7 +4,41 @@ Button { height: var(--lg-button-size) !important; - width: var(--lg-button-size) !important; + width: auto !important; // Changed from fixed width to auto + min-width: var(--lg-button-size) !important; border: none !important; + margin-right: 0.5rem; + + &.active-pad { + background-color: #cc6d24 !important; + color: var(--color-on-primary) !important; + font-weight: bold; + } + + &.creating-pad { + opacity: 0.6; + cursor: not-allowed; + } } -} \ No newline at end of file + + .tabs-container { + display: flex; + flex-direction: row; + align-items: center; + overflow-x: auto; + max-width: 100%; + padding-bottom: 5px; // Add padding to ensure scrollbar doesn't overlap content + + .loading-indicator { + font-size: 0.8rem; + color: var(--color-muted); + margin-right: 0.5rem; + } + } + + .new-tab-button-container { + Button { + width: var(--lg-button-size) !important; + } + } +} diff --git a/src/frontend/src/ui/Tabs.tsx b/src/frontend/src/ui/Tabs.tsx index 83c7fe5..7468aa2 100644 --- a/src/frontend/src/ui/Tabs.tsx +++ b/src/frontend/src/ui/Tabs.tsx @@ -1,8 +1,23 @@ -import React from "react"; +import React, { useState, useEffect, useCallback } from "react"; import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types"; import { Stack, Button, Section, Tooltip } from "@atyrode/excalidraw"; import { FilePlus2 } from "lucide-react"; +import { useAllPads, useSaveCanvas, useRenamePad, useDeletePad, PadData } from "../api/hooks"; +import { queryClient } from "../api/queryClient"; +import { fetchApi } from "../api/apiUtils"; +import { + normalizeCanvasData, + getPadData, + storePadData, + setActivePad, + getActivePad, + getStoredActivePad, + loadPadData, + saveCurrentPadBeforeSwitching, + createNewPad +} from "../utils/canvasUtils"; +import TabContextMenu from "./TabContextMenu"; import "./Tabs.scss"; interface TabsProps { @@ -14,30 +29,258 @@ const Tabs: React.FC = ({ }: { excalidrawAPI: ExcalidrawImperativeAPI; }) => { - + const { data: pads, isLoading, refetch: refetchPads } = useAllPads(); const appState = excalidrawAPI.getAppState(); + const [isCreatingPad, setIsCreatingPad] = useState(false); + const [activePadId, setActivePadId] = useState(null); + + // Context menu state + const [contextMenu, setContextMenu] = useState<{ + visible: boolean; + x: number; + y: number; + padId: string; + padName: string; + }>({ + visible: false, + x: 0, + y: 0, + padId: '', + padName: '' + }); + + // Get the saveCanvas mutation + const { mutate: saveCanvas } = useSaveCanvas({ + onSuccess: () => { + console.debug("[pad.ws] Canvas saved to database successfully"); + }, + onError: (error) => { + console.error("[pad.ws] Failed to save canvas to database:", error); + } + }); + + // Get the renamePad mutation + const { mutate: renamePad } = useRenamePad({ + onSuccess: (data, variables) => { + console.debug("[pad.ws] Pad renamed successfully"); + + // Update the cache directly instead of refetching + const { padId, newName } = variables; + + // Get the current pads from the query cache + const currentPads = queryClient.getQueryData(['allPads']); + + if (currentPads) { + // Create a new array with the updated pad name + const updatedPads = currentPads.map(pad => + pad.id === padId + ? { ...pad, display_name: newName } + : pad + ); + + // Update the query cache with the new data + queryClient.setQueryData(['allPads'], updatedPads); + } + }, + onError: (error) => { + console.error("[pad.ws] Failed to rename pad:", error); + } + }); + + // Get the deletePad mutation + const { mutate: deletePad } = useDeletePad({ + onSuccess: (data, padId) => { + console.debug("[pad.ws] Pad deleted successfully"); + + // Update the cache directly instead of refetching + // Get the current pads from the query cache + const currentPads = queryClient.getQueryData(['allPads']); + + if (currentPads) { + // Create a new array without the deleted pad + const updatedPads = currentPads.filter(pad => pad.id !== padId); + + // Update the query cache with the new data + queryClient.setQueryData(['allPads'], updatedPads); + } + }, + onError: (error) => { + console.error("[pad.ws] Failed to delete pad:", error); + } + }); + + const handlePadSelect = (pad: any) => { + // Save the current canvas before switching tabs + if (activePadId) { + saveCurrentPadBeforeSwitching(excalidrawAPI, activePadId, saveCanvas); + } + + // Set the new active pad ID + setActivePadId(pad.id); + // Store the active pad ID globally + setActivePad(pad.id); + + // Load the pad data + loadPadData(excalidrawAPI, pad.id, pad.data); + }; + + // Set the active pad ID when the component mounts and when the pads data changes + useEffect(() => { + if (!isLoading && pads && pads.length > 0 && !activePadId) { + // Check if there's a stored active pad ID + const storedActivePadId = getStoredActivePad(); + + // Find the pad that matches the stored ID, or use the first pad if no match + let padToActivate = pads[0]; + + if (storedActivePadId) { + // Try to find the pad with the stored ID + const matchingPad = pads.find(pad => pad.id === storedActivePadId); + if (matchingPad) { + console.debug(`[pad.ws] Found stored active pad: ${storedActivePadId}`); + padToActivate = matchingPad; + } else { + console.debug(`[pad.ws] Stored active pad ${storedActivePadId} not found in available pads`); + } + } + + // Set the active pad ID + setActivePadId(padToActivate.id); + // Store the active pad ID globally + setActivePad(padToActivate.id); + + // Store all pads in local storage for the first time + pads.forEach(pad => { + // Only store if not already in local storage + if (!getPadData(pad.id)) { + storePadData(pad.id, pad.data); + } + }); + + // If the current canvas is empty, load the pad data + const currentElements = excalidrawAPI.getSceneElements(); + if (currentElements.length === 0) { + // Load the pad data using the imported function + loadPadData(excalidrawAPI, padToActivate.id, padToActivate.data); + } + } + }, [pads, isLoading, activePadId, excalidrawAPI]); + + const handleCreateNewPad = async () => { + if (isCreatingPad) return; // Prevent multiple clicks + + try { + setIsCreatingPad(true); + + // Create a new pad using the imported function + const newPad = await createNewPad(excalidrawAPI, activePadId, saveCanvas); + + // Set the active pad ID in the component state + setActivePadId(newPad.id); + } catch (error) { + console.error('Error creating new pad:', error); + } finally { + setIsCreatingPad(false); + } + }; + return ( -
- -
- {!appState.viewModeEnabled && ( -
- {}} - children={ -
- -
} - /> - }> -
-
- )} -
-
-
- ); + <> +
+ +
+ {!appState.viewModeEnabled && ( +
+ {/* Loading indicator */} + {isLoading && ( +
+ Loading pads... +
+ )} + + {/* List all pads */} + {!isLoading && pads && pads.map((pad) => ( +
{ + e.preventDefault(); + setContextMenu({ + visible: true, + x: e.clientX, + y: e.clientY, + padId: pad.id, + padName: pad.display_name + }); + }} + > + handlePadSelect(pad)} + children={pad.display_name} + className={activePadId === pad.id ? "active-pad" : ""} + /> + } /> +
+ ))} + + {/* New pad button */} +
+ {} : handleCreateNewPad} + className={isCreatingPad ? "creating-pad" : ""} + children={ +
+ +
+ } + /> + } /> +
+
+ )} +
+
+
+ + {/* Context Menu */} + {contextMenu.visible && ( + { + // Call the renamePad mutation + renamePad({ padId, newName }); + }} + onDelete={(padId) => { + // Don't allow deleting the last pad + if (pads && pads.length <= 1) { + alert("Cannot delete the last pad"); + return; + } + + // If deleting the active pad, switch to another pad first + if (padId === activePadId && pads) { + // Find another pad to activate + const otherPad = pads.find(p => p.id !== padId); + if (otherPad) { + // Set the new active pad + handlePadSelect(otherPad); + } + } + + // Call the deletePad mutation + deletePad(padId); + }} + onClose={() => { + setContextMenu(prev => ({ ...prev, visible: false })); + }} + /> + )} + + ); }; -export default Tabs; \ No newline at end of file +export default Tabs; diff --git a/src/frontend/src/utils/canvasUtils.ts b/src/frontend/src/utils/canvasUtils.ts index c6578c8..320d7d7 100644 --- a/src/frontend/src/utils/canvasUtils.ts +++ b/src/frontend/src/utils/canvasUtils.ts @@ -1,6 +1,10 @@ import { DEFAULT_SETTINGS } from '../types/settings'; import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types"; -import { CanvasData } from '../api/hooks'; +import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; +import type { AppState } from "@atyrode/excalidraw/types"; +import { CanvasData, PadData } from '../api/hooks'; +import { fetchApi } from '../api/apiUtils'; +import { queryClient } from '../api/queryClient'; /** * @@ -24,8 +28,9 @@ export function normalizeCanvasData(data: any) { const existingPad = appState.pad || {}; const existingUserSettings = existingPad.userSettings || {}; - // Merge existing user settings with default settings + // Merge existing pad properties with our updates appState.pad = { + ...existingPad, // Preserve all existing properties (uniqueId, displayName, etc.) moduleBorderOffset: { left: 10, right: 10, @@ -45,6 +50,199 @@ export function normalizeCanvasData(data: any) { return { ...data, appState }; } +// Local storage keys +export const LOCAL_STORAGE_PADS_KEY = 'pad_ws_pads'; +export const LOCAL_STORAGE_ACTIVE_PAD_KEY = 'pad_ws_active_pad'; + +/** + * Stores pad data in local storage + * @param padId The ID of the pad to store + * @param data The pad data to store + */ +export function storePadData(padId: string, data: any): void { + try { + // Get existing pads data from local storage + const storedPadsString = localStorage.getItem(LOCAL_STORAGE_PADS_KEY); + const storedPads = storedPadsString ? JSON.parse(storedPadsString) : {}; + + // Update the pad data + storedPads[padId] = data; + + // Save back to local storage + localStorage.setItem(LOCAL_STORAGE_PADS_KEY, JSON.stringify(storedPads)); + + console.debug(`[pad.ws] Stored pad ${padId} data in local storage`); + } catch (error) { + console.error('[pad.ws] Error storing pad data in local storage:', error); + } +} + +/** + * Gets pad data from local storage + * @param padId The ID of the pad to retrieve + * @returns The pad data or null if not found + */ +export function getPadData(padId: string): any | null { + try { + // Get pads data from local storage + const storedPadsString = localStorage.getItem(LOCAL_STORAGE_PADS_KEY); + if (!storedPadsString) return null; + + const storedPads = JSON.parse(storedPadsString); + + // Return the pad data if it exists + return storedPads[padId] || null; + } catch (error) { + console.error('[pad.ws] Error getting pad data from local storage:', error); + return null; + } +} + +/** + * Sets the active pad ID globally and stores it in local storage + * @param padId The ID of the pad to set as active + */ +export function setActivePad(padId: string): void { + (window as any).activePadId = padId; + + // Store the active pad ID in local storage + try { + localStorage.setItem(LOCAL_STORAGE_ACTIVE_PAD_KEY, padId); + console.debug(`[pad.ws] Stored active pad ID ${padId} in local storage`); + } catch (error) { + console.error('[pad.ws] Error storing active pad ID in local storage:', error); + } + + console.debug(`[pad.ws] Set active pad to ${padId}`); +} + +/** + * Gets the current active pad ID from the global variable + * @returns The active pad ID or null if not set + */ +export function getActivePad(): string | null { + return (window as any).activePadId || null; +} + +/** + * Gets the stored active pad ID from local storage + * @returns The stored active pad ID or null if not found + */ +export function getStoredActivePad(): string | null { + try { + const storedActivePadId = localStorage.getItem(LOCAL_STORAGE_ACTIVE_PAD_KEY); + return storedActivePadId; + } catch (error) { + console.error('[pad.ws] Error getting active pad ID from local storage:', error); + return null; + } +} + +/** + * Saves the current pad data before switching to another pad + * @param excalidrawAPI The Excalidraw API instance + * @param activePadId The current active pad ID + * @param saveCanvas The saveCanvas mutation function + */ +export function saveCurrentPadBeforeSwitching( + excalidrawAPI: ExcalidrawImperativeAPI, + activePadId: string | null, + saveCanvas: (data: CanvasData) => void +): void { + if (!activePadId) return; + + // Get the current elements, state, and files + const elements = excalidrawAPI.getSceneElements(); + const appState = excalidrawAPI.getAppState(); + const files = excalidrawAPI.getFiles(); + + // Create the canvas data object + const canvasData = { + elements: [...elements] as any[], // Convert readonly array to mutable array + appState, + files + }; + + // Save the canvas data to local storage + storePadData(activePadId, canvasData); + + // Save the canvas data to the server + saveCanvas(canvasData); + + console.debug("[pad.ws] Saved canvas before switching"); +} + +/** + * Loads pad data into the Excalidraw canvas + * @param excalidrawAPI The Excalidraw API instance + * @param padId The ID of the pad to load + * @param serverData The server data to use as fallback + */ +export function loadPadData( + excalidrawAPI: ExcalidrawImperativeAPI, + padId: string, + serverData: any +): void { + // Try to get the pad data from local storage first + const localPadData = getPadData(padId); + + if (localPadData) { + // Use the local data if available + console.debug(`[pad.ws] Loading pad ${padId} data from local storage`); + excalidrawAPI.updateScene(normalizeCanvasData(localPadData)); + } else if (serverData) { + // Fall back to the server data + console.debug(`[pad.ws] No local data found for pad ${padId}, using server data`); + excalidrawAPI.updateScene(normalizeCanvasData(serverData)); + } +} + +/** + * Creates a new pad from the default template + * @param excalidrawAPI The Excalidraw API instance + * @param activePadId The current active pad ID + * @param saveCanvas The saveCanvas mutation function + * @returns Promise resolving to the new pad data + */ +export async function createNewPad( + excalidrawAPI: ExcalidrawImperativeAPI, + activePadId: string | null, + saveCanvas: (data: CanvasData) => void +): Promise { + // Save the current canvas before creating a new pad + if (activePadId) { + saveCurrentPadBeforeSwitching(excalidrawAPI, activePadId, saveCanvas); + } + + // Create a new pad from the default template + const newPad = await fetchApi('/api/pad/from-template/default', { + method: 'POST', + body: JSON.stringify({ + display_name: `New Pad ${new Date().toLocaleTimeString()}`, + }), + }); + + // Manually update the pads list instead of refetching + // Get the current pads from the query cache + const currentPads = queryClient.getQueryData(['allPads']) || []; + + // Add the new pad to the list + queryClient.setQueryData(['allPads'], [...currentPads, newPad]); + + // Store the new pad data in local storage + storePadData(newPad.id, newPad.data); + + // Update the canvas with the new pad's data + // Normalize the data before updating the scene + excalidrawAPI.updateScene(normalizeCanvasData(newPad.data)); + console.debug("[pad.ws] Loaded new pad data"); + + // Set the active pad ID globally + setActivePad(newPad.id); + + return newPad; +} + /** * Saves the current canvas state using the Excalidraw API * @param saveCanvas The saveCanvas mutation function from useSaveCanvas hook From 3e6f7de76015c8b7438f6161692f80cc377c36c8 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 4 May 2025 23:49:01 +0000 Subject: [PATCH 14/21] feat: add pad backups retrieval functionality - Implemented a new API endpoint to retrieve backups for a specific pad, including ownership verification and backup data formatting. - Updated frontend API hooks to support fetching pad backups and integrated this functionality into the BackupsDialog component. - Enhanced the context menu and tab components to improve user interaction with pad actions and backup management. - Refactored styles for the tab context menu to improve UI consistency and responsiveness. --- src/backend/routers/pad_router.py | 46 +++++++++ src/frontend/src/api/hooks.ts | 27 ++++- src/frontend/src/ui/BackupsDialog.tsx | 11 +- src/frontend/src/ui/TabContextMenu.scss | 128 ++++++++++++++++-------- src/frontend/src/ui/TabContextMenu.tsx | 4 +- src/frontend/src/ui/Tabs.tsx | 5 +- 6 files changed, 171 insertions(+), 50 deletions(-) diff --git a/src/backend/routers/pad_router.py b/src/backend/routers/pad_router.py index 8fbb975..4ebdca8 100644 --- a/src/backend/routers/pad_router.py +++ b/src/backend/routers/pad_router.py @@ -237,6 +237,52 @@ async def create_pad_from_template( raise HTTPException(status_code=500, detail=f"Failed to create pad from template: {str(e)}") +@pad_router.get("/{pad_id}/backups") +async def get_pad_backups( + pad_id: UUID, + limit: int = MAX_BACKUPS_PER_USER, + user: UserSession = Depends(require_auth), + pad_service: PadService = Depends(get_pad_service), + backup_service: BackupService = Depends(get_backup_service) +): + """Get backups for a specific pad""" + # Limit the number of backups to the maximum configured value + if limit > MAX_BACKUPS_PER_USER: + limit = MAX_BACKUPS_PER_USER + + try: + # Get the pad to verify ownership + pad = await pad_service.get_pad(pad_id) + + if not pad: + raise HTTPException(status_code=404, detail="Pad not found") + + # Verify the user owns this pad + if str(pad["owner_id"]) != str(user.id): + raise HTTPException(status_code=403, detail="You don't have permission to access this pad's backups") + + # Get backups for this specific pad + backups_data = await backup_service.get_backups_by_source(pad_id) + + # Limit the number of backups if needed + if len(backups_data) > limit: + backups_data = backups_data[:limit] + + # Format backups to match the expected response format + backups = [] + for backup in backups_data: + backups.append({ + "id": backup["id"], + "timestamp": backup["created_at"], + "data": backup["data"] + }) + + return {"backups": backups, "pad_name": pad["display_name"]} + except Exception as e: + print(f"Error getting pad backups: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to get pad backups: {str(e)}") + + @pad_router.get("/recent") async def get_recent_canvas_backups( limit: int = MAX_BACKUPS_PER_USER, diff --git a/src/frontend/src/api/hooks.ts b/src/frontend/src/api/hooks.ts index 389731f..a4870fa 100644 --- a/src/frontend/src/api/hooks.ts +++ b/src/frontend/src/api/hooks.ts @@ -46,6 +46,7 @@ export interface CanvasBackup { export interface CanvasBackupsResponse { backups: CanvasBackup[]; + pad_name?: string; } export interface BuildInfo { @@ -186,6 +187,15 @@ export const api = { } }, + getPadBackups: async (padId: string, limit: number = 10): Promise => { + try { + const result = await fetchApi(`/api/pad/${padId}/backups?limit=${limit}`); + return result; + } catch (error) { + throw error; + } + }, + // Build Info getBuildInfo: async (): Promise => { try { @@ -245,6 +255,15 @@ export function useCanvasBackups(limit: number = 10, options?: UseQueryOptions) { + return useQuery({ + queryKey: ['padBackups', padId, limit], + queryFn: () => padId ? api.getPadBackups(padId, limit) : Promise.reject('No pad ID provided'), + enabled: !!padId, // Only run the query if padId is provided + ...options, + }); +} + export function useBuildInfo(options?: UseQueryOptions) { return useQuery({ queryKey: ['buildInfo'], @@ -281,8 +300,14 @@ export function useSaveCanvas(options?: UseMutationOptions { - // Invalidate canvas backups query to trigger refetch + // Get the active pad ID from the global variable + const activePadId = (window as any).activePadId; + + // Invalidate canvas backups queries to trigger refetch queryClient.invalidateQueries({ queryKey: ['canvasBackups'] }); + if (activePadId) { + queryClient.invalidateQueries({ queryKey: ['padBackups', activePadId] }); + } }, ...options, }); diff --git a/src/frontend/src/ui/BackupsDialog.tsx b/src/frontend/src/ui/BackupsDialog.tsx index 5eef5a0..6cfe627 100644 --- a/src/frontend/src/ui/BackupsDialog.tsx +++ b/src/frontend/src/ui/BackupsDialog.tsx @@ -1,7 +1,7 @@ import React, { useState, useCallback } from "react"; import { Dialog } from "@atyrode/excalidraw"; -import { useCanvasBackups, CanvasBackup } from "../api/hooks"; -import { normalizeCanvasData } from "../utils/canvasUtils"; +import { usePadBackups, CanvasBackup } from "../api/hooks"; +import { normalizeCanvasData, getActivePad } from "../utils/canvasUtils"; import "./BackupsDialog.scss"; interface BackupsModalProps { @@ -14,7 +14,8 @@ const BackupsModal: React.FC = ({ onClose, }) => { const [modalIsShown, setModalIsShown] = useState(true); - const { data, isLoading, error } = useCanvasBackups(); + const activePadId = getActivePad(); + const { data, isLoading, error } = usePadBackups(activePadId); const [selectedBackup, setSelectedBackup] = useState(null); // Functions from CanvasBackups.tsx @@ -113,7 +114,9 @@ const BackupsModal: React.FC = ({ onCloseRequest={handleClose} title={
-

Canvas Backups

+

+ {data?.pad_name ? `${data.pad_name} (this pad) - Backups` : 'Canvas Backups'} +

} closeOnClickOutside={true} diff --git a/src/frontend/src/ui/TabContextMenu.scss b/src/frontend/src/ui/TabContextMenu.scss index c70129a..e97a4ea 100644 --- a/src/frontend/src/ui/TabContextMenu.scss +++ b/src/frontend/src/ui/TabContextMenu.scss @@ -1,52 +1,102 @@ .tab-context-menu { position: fixed; - background-color: #fff; - border: 1px solid #ccc; border-radius: 4px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - min-width: 120px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); + padding: 0.5rem 0; + list-style: none; + user-select: none; + margin: -0.25rem 0 0 0.125rem; + min-width: 9.5rem; z-index: 1000; + background-color: var(--popup-secondary-bg-color, #fff); + border: 1px solid var(--button-gray-3, #ccc); + cursor: default; +} + +.tab-context-menu button { + color: var(--popup-text-color, #333); +} + +.tab-context-menu .menu-item { + position: relative; + width: 100%; + min-width: 9.5rem; + margin: 0; + padding: 0.25rem 1rem 0.25rem 1.25rem; + text-align: start; + border-radius: 0; + background-color: transparent; + border: none; + white-space: nowrap; + font-family: inherit; + cursor: pointer; + + display: grid; + grid-template-columns: 1fr 0.2fr; + align-items: center; + + .menu-item__label { + justify-self: start; + margin-inline-end: 20px; + } - .menu-item { - padding: 8px 12px; - cursor: pointer; - - &:hover { - background-color: #f5f5f5; - } - - &.delete { + &.delete { + .menu-item__label { color: #e53935; - - &:hover { - background-color: #ffebee; - } } } - - form { - padding: 8px; - display: flex; - - input { - flex: 1; - padding: 4px 8px; - border: 1px solid #ccc; - border-radius: 4px; - margin-right: 4px; +} + +.tab-context-menu .menu-item:hover { + color: var(--popup-bg-color, #fff); + background-color: var(--select-highlight-color, #f5f5f5); + + &.delete { + .menu-item__label { + color: var(--popup-bg-color, #fff); } + background-color: #e53935; + } +} + +.tab-context-menu .menu-item:focus { + z-index: 1; +} + +@media (max-width: 640px) { + .tab-context-menu .menu-item { + display: block; + + .menu-item__label { + margin-inline-end: 0; + } + } +} + +.tab-context-menu form { + padding: 0.5rem 1rem; + display: flex; + + input { + flex: 1; + padding: 0.25rem 0.5rem; + border: 1px solid var(--button-gray-3, #ccc); + border-radius: 4px; + margin-right: 0.25rem; + background-color: var(--input-bg-color, #fff); + color: var(--text-color-primary, #333); + } + + button { + background-color: var(--color-surface-primary-container, #4285f4); + color: var(--color-on-primary-container, white); + border: none; + border-radius: 4px; + padding: 0.25rem 0.5rem; + cursor: pointer; - button { - background-color: #4285f4; - color: white; - border: none; - border-radius: 4px; - padding: 4px 8px; - cursor: pointer; - - &:hover { - background-color: #3367d6; - } + &:hover { + background-color: var(--color-primary-light, #3367d6); } } } diff --git a/src/frontend/src/ui/TabContextMenu.tsx b/src/frontend/src/ui/TabContextMenu.tsx index ea941fe..f84c437 100644 --- a/src/frontend/src/ui/TabContextMenu.tsx +++ b/src/frontend/src/ui/TabContextMenu.tsx @@ -95,10 +95,10 @@ const TabContextMenu: React.FC = ({ ) : ( <>
- Rename + Rename
- Delete + Delete
)} diff --git a/src/frontend/src/ui/Tabs.tsx b/src/frontend/src/ui/Tabs.tsx index 7468aa2..b266c3d 100644 --- a/src/frontend/src/ui/Tabs.tsx +++ b/src/frontend/src/ui/Tabs.tsx @@ -5,13 +5,10 @@ import { Stack, Button, Section, Tooltip } from "@atyrode/excalidraw"; import { FilePlus2 } from "lucide-react"; import { useAllPads, useSaveCanvas, useRenamePad, useDeletePad, PadData } from "../api/hooks"; import { queryClient } from "../api/queryClient"; -import { fetchApi } from "../api/apiUtils"; import { - normalizeCanvasData, getPadData, storePadData, setActivePad, - getActivePad, getStoredActivePad, loadPadData, saveCurrentPadBeforeSwitching, @@ -29,7 +26,7 @@ const Tabs: React.FC = ({ }: { excalidrawAPI: ExcalidrawImperativeAPI; }) => { - const { data: pads, isLoading, refetch: refetchPads } = useAllPads(); + const { data: pads, isLoading } = useAllPads(); const appState = excalidrawAPI.getAppState(); const [isCreatingPad, setIsCreatingPad] = useState(false); const [activePadId, setActivePadId] = useState(null); From db88d4259561b6f6a4e130a23461b90cc8f1e4e6 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 4 May 2025 23:49:21 +0000 Subject: [PATCH 15/21] chore: update @atyrode/excalidraw dependency to version 0.18.0-9 --- src/frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index 9d77044..197dd9e 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "@atyrode/excalidraw": "^0.18.0-8", + "@atyrode/excalidraw": "^0.18.0-9", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.74.3", "@tanstack/react-query-devtools": "^5.74.3", From d21232a731592065c59b566c5dd1f7503410320e Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 4 May 2025 23:53:05 +0000 Subject: [PATCH 16/21] chore: add clsx dependency and update excalidraw version in package.json and yarn.lock --- src/frontend/package.json | 1 + src/frontend/yarn.lock | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index 197dd9e..707a206 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -9,6 +9,7 @@ "@tanstack/react-query-devtools": "^5.74.3", "@types/crypto-js": "^4.2.2", "browser-fs-access": "0.29.1", + "clsx": "^2.1.1", "crypto-js": "^4.2.0", "lucide-react": "^0.488.0", "posthog-js": "^1.236.0", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 8238f15..73f29bc 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@atyrode/excalidraw@^0.18.0-8": - version "0.18.0-8" - resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-8.tgz#878db7be3725ecde5d7f9d92c14cb57bba92d3e7" - integrity sha512-0/HzS6unGQI9EY/mtwoCFfZaFOShUkD4VzqHnXbMxyktcDZ+xxHHE+fgWYt+p/ijXF3MN4ofY7LD8FVGn/Hn5Q== +"@atyrode/excalidraw@^0.18.0-9": + version "0.18.0-9" + resolved "https://registry.yarnpkg.com/@atyrode/excalidraw/-/excalidraw-0.18.0-9.tgz#6a69b5d0b44b902c10ba9b27e46803231a628d95" + integrity sha512-Wej+UFAemSTHrLTcOOYCYxnZ4DTsYgRmE+NKMLtCOXLAI69nCQ7eyIMeKdI01rfNetjzT7OOZLHi/AIhx6GYGg== dependencies: "@braintree/sanitize-url" "6.0.2" "@excalidraw/laser-pointer" "1.3.1" @@ -681,6 +681,11 @@ clsx@1.1.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA== +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + commander@7: version "7.2.0" resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" From 2322fea2efa025d2e5ab3f33e1b422e4d907b44f Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 5 May 2025 00:07:33 +0000 Subject: [PATCH 17/21] feat: enhance context menu and tab functionality - Refactored TabContextMenu and Tabs components to improve user interaction with context menus. - Introduced a new Popover component for better positioning and handling of context menus. - Updated styles for context menus to ensure consistency and responsiveness across different screen sizes. - Added functionality for dynamic context menu items based on user actions, including rename and delete options. - Improved tooltip display logic in Tabs component for better user experience with long pad names. --- src/frontend/src/ui/TabContextMenu.scss | 150 +++++++---- src/frontend/src/ui/TabContextMenu.tsx | 328 +++++++++++++++++++----- src/frontend/src/ui/Tabs.tsx | 13 +- 3 files changed, 372 insertions(+), 119 deletions(-) diff --git a/src/frontend/src/ui/TabContextMenu.scss b/src/frontend/src/ui/TabContextMenu.scss index e97a4ea..a840665 100644 --- a/src/frontend/src/ui/TabContextMenu.scss +++ b/src/frontend/src/ui/TabContextMenu.scss @@ -1,4 +1,8 @@ -.tab-context-menu { +/* Unified context menu styles */ + +/* Base context menu container */ +.tab-context-menu, +.context-menu { position: fixed; border-radius: 4px; box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); @@ -13,11 +17,20 @@ cursor: default; } +/* Context menu list */ +.context-menu { + padding: 0; + margin: 0; +} + +/* Button text color */ .tab-context-menu button { color: var(--popup-text-color, #333); } -.tab-context-menu .menu-item { +/* Menu items */ +.tab-context-menu .menu-item, +.context-menu-item { position: relative; width: 100%; min-width: 9.5rem; @@ -30,73 +43,118 @@ white-space: nowrap; font-family: inherit; cursor: pointer; +} +/* Menu item layout for tab context menu */ +.tab-context-menu .menu-item { display: grid; grid-template-columns: 1fr 0.2fr; align-items: center; +} - .menu-item__label { - justify-self: start; - margin-inline-end: 20px; - } - - &.delete { - .menu-item__label { - color: #e53935; - } - } +/* Menu item layout for context menu */ +.context-menu-item { + display: flex; + justify-content: space-between; + align-items: center; } -.tab-context-menu .menu-item:hover { +/* Menu item label */ +.tab-context-menu .menu-item .menu-item__label { + justify-self: start; + margin-inline-end: 20px; +} + +.context-menu-item__label { + flex: 1; +} + +/* Delete item styling */ +.tab-context-menu .menu-item.delete .menu-item__label, +.context-menu-item.dangerous { + color: #e53935; +} + +/* Hover states */ +.tab-context-menu .menu-item:hover, +.context-menu-item:hover { color: var(--popup-bg-color, #fff); background-color: var(--select-highlight-color, #f5f5f5); +} - &.delete { - .menu-item__label { - color: var(--popup-bg-color, #fff); - } - background-color: #e53935; - } +/* Dangerous hover states */ +.tab-context-menu .menu-item.delete:hover, +.context-menu-item.dangerous:hover { + background-color: #e53935; +} + +.tab-context-menu .menu-item.delete:hover .menu-item__label, +.context-menu-item.dangerous:hover { + color: var(--popup-bg-color, #fff); } +/* Focus state */ .tab-context-menu .menu-item:focus { z-index: 1; } -@media (max-width: 640px) { - .tab-context-menu .menu-item { - display: block; +/* Separator */ +.context-menu-item-separator { + margin: 0.25rem 0; + border: none; + border-top: 1px solid var(--button-gray-3, #ccc); +} - .menu-item__label { - margin-inline-end: 0; - } - } +/* Shortcut display */ +.context-menu-item__shortcut { + margin-left: 1rem; + color: var(--text-color-secondary, #666); + font-size: 0.8rem; +} + +/* Checkmark for selected items */ +.context-menu-item.checkmark::before { + content: "✓"; + position: absolute; + left: 0.5rem; } +/* Form styling for rename functionality */ .tab-context-menu form { padding: 0.5rem 1rem; display: flex; - - input { - flex: 1; - padding: 0.25rem 0.5rem; - border: 1px solid var(--button-gray-3, #ccc); - border-radius: 4px; - margin-right: 0.25rem; - background-color: var(--input-bg-color, #fff); - color: var(--text-color-primary, #333); +} + +.tab-context-menu form input { + flex: 1; + padding: 0.25rem 0.5rem; + border: 1px solid var(--button-gray-3, #ccc); + border-radius: 4px; + margin-right: 0.25rem; + background-color: var(--input-bg-color, #fff); + color: var(--text-color-primary, #333); +} + +.tab-context-menu form button { + background-color: var(--color-surface-primary-container, #4285f4); + color: var(--color-on-primary-container, white); + border: none; + border-radius: 4px; + padding: 0.25rem 0.5rem; + cursor: pointer; +} + +.tab-context-menu form button:hover { + background-color: var(--color-primary-light, #3367d6); +} + +/* Responsive adjustments */ +@media (max-width: 640px) { + .tab-context-menu .menu-item { + display: block; } - button { - background-color: var(--color-surface-primary-container, #4285f4); - color: var(--color-on-primary-container, white); - border: none; - border-radius: 4px; - padding: 0.25rem 0.5rem; - cursor: pointer; - - &:hover { - background-color: var(--color-primary-light, #3367d6); - } + .tab-context-menu .menu-item .menu-item__label { + margin-inline-end: 0; } } diff --git a/src/frontend/src/ui/TabContextMenu.tsx b/src/frontend/src/ui/TabContextMenu.tsx index f84c437..9e8b9a2 100644 --- a/src/frontend/src/ui/TabContextMenu.tsx +++ b/src/frontend/src/ui/TabContextMenu.tsx @@ -1,6 +1,36 @@ import React, { useState, useRef, useEffect } from 'react'; +import clsx from 'clsx'; + import './TabContextMenu.scss'; +const CONTEXT_MENU_SEPARATOR = "separator"; + +type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action; +type ContextMenuItems = (ContextMenuItem | false | null | undefined)[]; + +interface Action { + name: string; + label: string | (() => string); + predicate?: () => boolean; + checked?: (appState: any) => boolean; + dangerous?: boolean; +} + +interface ContextMenuProps { + actionManager: ActionManager; + items: ContextMenuItems; + top: number; + left: number; + onClose: (callback?: () => void) => void; +} + +interface ActionManager { + executeAction: (action: Action, source: string) => void; + app: { + props: any; + }; +} + interface TabContextMenuProps { x: number; y: number; @@ -11,32 +41,35 @@ interface TabContextMenuProps { onClose: () => void; } -const TabContextMenu: React.FC = ({ - x, - y, - padId, - padName, - onRename, - onDelete, - onClose +// Popover component +const Popover: React.FC<{ + onCloseRequest: () => void; + top: number; + left: number; + fitInViewport?: boolean; + offsetLeft?: number; + offsetTop?: number; + viewportWidth?: number; + viewportHeight?: number; + children: React.ReactNode; +}> = ({ + onCloseRequest, + top, + left, + children, + fitInViewport = false, + offsetLeft = 0, + offsetTop = 0, + viewportWidth = window.innerWidth, + viewportHeight = window.innerHeight }) => { - const [isRenaming, setIsRenaming] = useState(false); - const [newName, setNewName] = useState(padName); - const menuRef = useRef(null); - const inputRef = useRef(null); - - // Position the menu above the cursor - const style = { - position: 'fixed' as const, - top: `${y - 80}px`, // Position above the cursor - left: `${x}px`, - }; - - // Handle clicks outside the menu to close it + const popoverRef = useRef(null); + + // Handle clicks outside the popover to close it useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - onClose(); + if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { + onCloseRequest(); } }; @@ -44,65 +77,218 @@ const TabContextMenu: React.FC = ({ return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [onClose]); + }, [onCloseRequest]); - // Focus the input when renaming + // Adjust position if needed to fit in viewport useEffect(() => { - if (isRenaming && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); + if (fitInViewport && popoverRef.current) { + const rect = popoverRef.current.getBoundingClientRect(); + const adjustedLeft = Math.min(left, viewportWidth - rect.width); + const adjustedTop = Math.min(top, viewportHeight - rect.height); + + if (popoverRef.current) { + popoverRef.current.style.left = `${adjustedLeft}px`; + popoverRef.current.style.top = `${adjustedTop}px`; + } } - }, [isRenaming]); + }, [fitInViewport, left, top, viewportWidth, viewportHeight]); - const handleRenameClick = () => { - setIsRenaming(true); - }; + return ( +
+ {children} +
+ ); +}; - const handleRenameSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (newName.trim() !== '') { - onRename(padId, newName); - setIsRenaming(false); - onClose(); +// ContextMenu component +const ContextMenu: React.FC = ({ + actionManager, + items, + top, + left, + onClose +}) => { + // Filter items based on predicate + const filteredItems = items.reduce((acc: ContextMenuItem[], item) => { + if ( + item && + (item === CONTEXT_MENU_SEPARATOR || + !item.predicate || + item.predicate()) + ) { + acc.push(item); } - }; + return acc; + }, []); + + return ( + { + onClose(); + }} + top={top} + left={left} + fitInViewport={true} + viewportWidth={window.innerWidth} + viewportHeight={window.innerHeight} + > +
    event.preventDefault()} + > + {filteredItems.map((item, idx) => { + if (item === CONTEXT_MENU_SEPARATOR) { + if ( + !filteredItems[idx - 1] || + filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR + ) { + return null; + } + return
    ; + } + + const actionName = item.name; + let label = ""; + if (item.label) { + if (typeof item.label === "function") { + label = item.label(); + } else { + label = item.label; + } + } + + return ( +
  • { + // Log the click + console.debug('[pad.ws] Menu item clicked:', item.name); + + // Store the callback to execute after closing + const callback = () => { + actionManager.executeAction(item, "contextMenu"); + }; + + // Close the menu and execute the callback + onClose(callback); + }} + > + +
  • + ); + })} +
+
+ ); +}; + +// Simple ActionManager implementation for the tab context menu +class TabActionManager implements ActionManager { + padId: string; + padName: string; + onRename: (padId: string, newName: string) => void; + onDelete: (padId: string) => void; + app: any; + + constructor( + padId: string, + padName: string, + onRename: (padId: string, newName: string) => void, + onDelete: (padId: string) => void + ) { + this.padId = padId; + this.padName = padName; + this.onRename = onRename; + this.onDelete = onDelete; + this.app = { props: {} }; + } + + executeAction(action: Action, source: string) { + console.debug('[pad.ws] Executing action:', action.name, 'from source:', source); + + if (action.name === 'rename') { + const newName = window.prompt('Rename pad', this.padName); + if (newName && newName.trim() !== '') { + this.onRename(this.padId, newName); + } + } else if (action.name === 'delete') { + console.debug('[pad.ws] Attempting to delete pad:', this.padId, this.padName); + if (window.confirm(`Are you sure you want to delete "${this.padName}"?`)) { + console.debug('[pad.ws] User confirmed delete, calling onDelete'); + this.onDelete(this.padId); + } + } + } +} - const handleDeleteClick = () => { - if (window.confirm(`Are you sure you want to delete "${padName}"?`)) { - onDelete(padId); - onClose(); +// Main TabContextMenu component +const TabContextMenu: React.FC = ({ + x, + y, + padId, + padName, + onRename, + onDelete, + onClose +}) => { + // Create an action manager instance + const actionManager = new TabActionManager(padId, padName, onRename, onDelete); + + // Define menu items + const menuItems = [ + { + name: 'rename', + label: 'Rename', + predicate: () => true, + }, + CONTEXT_MENU_SEPARATOR, // Add separator between rename and delete + { + name: 'delete', + label: 'Delete', + predicate: () => true, + dangerous: true, + } + ]; + + // Create a wrapper for onClose that handles the callback + const handleClose = (callback?: () => void) => { + console.debug('[pad.ws] TabContextMenu handleClose called, has callback:', !!callback); + + // First call the original onClose + onClose(); + + // Then execute the callback if provided + if (callback) { + callback(); } }; return ( -
- {isRenaming ? ( -
- setNewName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setIsRenaming(false); - onClose(); - } - }} - /> - -
- ) : ( - <> -
- Rename -
-
- Delete -
- - )} -
+ ); }; diff --git a/src/frontend/src/ui/Tabs.tsx b/src/frontend/src/ui/Tabs.tsx index b266c3d..8464d2e 100644 --- a/src/frontend/src/ui/Tabs.tsx +++ b/src/frontend/src/ui/Tabs.tsx @@ -210,13 +210,22 @@ const Tabs: React.FC = ({ }); }} > - 32 ? ( + handlePadSelect(pad)} + children={`${pad.display_name.substring(0, 32)}...`} + className={activePadId === pad.id ? "active-pad" : ""} + /> + } /> + ) : ( + } + /> + + )} + + {/* Right scroll button - only visible when there are more pads than can fit in the view */} + {pads && pads.length > PADS_PER_PAGE && ( + + 0 ? `\n(${Math.max(0, pads.length - (startPadIndex + PADS_PER_PAGE))} more)` : ''}`} + children={ + + } + /> + + )} - + )} diff --git a/src/frontend/src/utils/canvasUtils.ts b/src/frontend/src/utils/canvasUtils.ts index 320d7d7..b73fdab 100644 --- a/src/frontend/src/utils/canvasUtils.ts +++ b/src/frontend/src/utils/canvasUtils.ts @@ -53,6 +53,7 @@ export function normalizeCanvasData(data: any) { // Local storage keys export const LOCAL_STORAGE_PADS_KEY = 'pad_ws_pads'; export const LOCAL_STORAGE_ACTIVE_PAD_KEY = 'pad_ws_active_pad'; +export const LOCAL_STORAGE_SCROLL_INDEX_KEY = 'pad_ws_scroll_index'; /** * Stores pad data in local storage @@ -138,6 +139,33 @@ export function getStoredActivePad(): string | null { } } +/** + * Sets the scroll index in local storage + * @param index The scroll index to store + */ +export function setScrollIndex(index: number): void { + try { + localStorage.setItem(LOCAL_STORAGE_SCROLL_INDEX_KEY, index.toString()); + console.debug(`[pad.ws] Stored scroll index ${index} in local storage`); + } catch (error) { + console.error('[pad.ws] Error storing scroll index in local storage:', error); + } +} + +/** + * Gets the stored scroll index from local storage + * @returns The stored scroll index or 0 if not found + */ +export function getStoredScrollIndex(): number { + try { + const storedScrollIndex = localStorage.getItem(LOCAL_STORAGE_SCROLL_INDEX_KEY); + return storedScrollIndex ? parseInt(storedScrollIndex, 10) : 0; + } catch (error) { + console.error('[pad.ws] Error getting scroll index from local storage:', error); + return 0; + } +} + /** * Saves the current pad data before switching to another pad * @param excalidrawAPI The Excalidraw API instance From fbda6b816450ecb85245e31629de16be786d04a1 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 5 May 2025 03:02:33 +0000 Subject: [PATCH 19/21] feat: improve tab visibility and tooltip display - Enhanced the Tabs component to ensure newly created pads are visible by adjusting the start pad index based on the current pads in the query cache. - Updated tooltip logic to display truncated pad names more effectively, improving user experience with long pad names. --- src/frontend/src/ui/Tabs.tsx | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/ui/Tabs.tsx b/src/frontend/src/ui/Tabs.tsx index 41abfa8..b6f7673 100644 --- a/src/frontend/src/ui/Tabs.tsx +++ b/src/frontend/src/ui/Tabs.tsx @@ -188,6 +188,25 @@ const Tabs: React.FC = ({ // Set the active pad ID in the component state setActivePadId(newPad.id); + + // Get the current pads from the query cache + const currentPads = queryClient.getQueryData(['allPads']); + + if (currentPads) { + // Find the index of the newly created pad + const newPadIndex = currentPads.findIndex(pad => pad.id === newPad.id); + + if (newPadIndex !== -1) { + // Calculate the appropriate startPadIndex to ensure the new pad is visible + // We want to position the view so that the new pad is visible + // Ideally, we want the new pad to be the last visible pad in the view + const newStartIndex = Math.max(0, Math.min(newPadIndex - PADS_PER_PAGE + 1, currentPads.length - PADS_PER_PAGE)); + + // Update both the component state and the stored value + setStartPadIndex(newStartIndex); + setScrollIndex(newStartIndex); + } + } } catch (error) { console.error('Error creating new pad:', error); } finally { @@ -320,14 +339,14 @@ const Tabs: React.FC = ({ }} > {/* Only show tooltip if name is likely to be truncated (more than ~15 characters) */} - {pad.display_name.length > 8 ? ( + {pad.display_name.length > 11 ? ( handlePadSelect(pad)} className={activePadId === pad.id ? "active-pad" : ""} children={
- {pad.display_name} + {pad.display_name.length > 8 ? `${pad.display_name.substring(0, 11)}...` : pad.display_name} {startPadIndex + pads.slice(startPadIndex, startPadIndex + PADS_PER_PAGE).indexOf(pad) + 1}
} From 0ab5d9ad149292ee90427e960e10309397bd2b7b Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 5 May 2025 03:39:18 +0000 Subject: [PATCH 20/21] style: update tab button borders for improved aesthetics - Removed border from tab buttons for a cleaner look. - Added a solid border to active tab buttons to enhance visibility. - Adjusted styles for new tab button container to maintain consistency in design. --- src/frontend/src/ui/Tabs.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/ui/Tabs.scss b/src/frontend/src/ui/Tabs.scss index 84ed863..fa12aa6 100644 --- a/src/frontend/src/ui/Tabs.scss +++ b/src/frontend/src/ui/Tabs.scss @@ -7,7 +7,6 @@ height: var(--lg-button-size) !important; width: 100px !important; min-width: 100px !important; - border: none !important; margin-right: 0.6rem; text-overflow: ellipsis; overflow: hidden; @@ -17,6 +16,7 @@ background-color: #cc6d24 !important; color: var(--color-on-primary) !important; font-weight: bold; + border: 1px solid #cccccc !important; .tab-position { color: var(--color-on-primary) !important; @@ -35,6 +35,7 @@ display: flex; flex-direction: column; justify-content: center; + .tab-position { position: absolute; @@ -114,6 +115,7 @@ .new-tab-button-container { Button { + border: none !important; min-width: auto !important; width: var(--lg-button-size) !important; } From 0e700637ed7cd3dd3fcb108a391dba78d03b174f Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 5 May 2025 04:01:54 +0000 Subject: [PATCH 21/21] feat: add PadsDialog for managing pads - Introduced a new PadsDialog component to facilitate pad management, including renaming and deleting pads. - Updated ExcalidrawWrapper and MainMenuConfig to integrate the new PadsDialog, allowing users to access it from the main menu. - Enhanced the Tabs component to handle active pad changes via custom events, improving synchronization across components. - Added styles for the PadsDialog to ensure a consistent and user-friendly interface. - Implemented analytics tracking for pad creation, renaming, and deletion events to enhance user insights. --- src/frontend/src/ExcalidrawWrapper.tsx | 15 ++ src/frontend/src/ui/MainMenu.tsx | 15 +- src/frontend/src/ui/PadsDialog.scss | 185 +++++++++++++++++ src/frontend/src/ui/PadsDialog.tsx | 277 +++++++++++++++++++++++++ src/frontend/src/ui/Tabs.tsx | 149 ++++++++----- src/frontend/src/utils/canvasUtils.ts | 4 + 6 files changed, 590 insertions(+), 55 deletions(-) create mode 100644 src/frontend/src/ui/PadsDialog.scss create mode 100644 src/frontend/src/ui/PadsDialog.tsx diff --git a/src/frontend/src/ExcalidrawWrapper.tsx b/src/frontend/src/ExcalidrawWrapper.tsx index 5053b12..267e548 100644 --- a/src/frontend/src/ExcalidrawWrapper.tsx +++ b/src/frontend/src/ExcalidrawWrapper.tsx @@ -8,6 +8,7 @@ import { MainMenuConfig } from './ui/MainMenu'; import { lockEmbeddables, renderCustomEmbeddable } from './CustomEmbeddableRenderer'; import AuthDialog from './ui/AuthDialog'; import BackupsModal from './ui/BackupsDialog'; +import PadsDialog from './ui/PadsDialog'; import SettingsDialog from './ui/SettingsDialog'; import { capture } from './utils/posthog'; import { Footer } from '@atyrode/excalidraw'; @@ -53,6 +54,7 @@ export const ExcalidrawWrapper: React.FC = ({ // State for modals const [showBackupsModal, setShowBackupsModal] = useState(false); + const [showPadsModal, setShowPadsModal] = useState(false); const [showSettingsModal, setShowSettingsModal] = useState(false); // Handle auth state changes @@ -69,6 +71,10 @@ export const ExcalidrawWrapper: React.FC = ({ setShowBackupsModal(false); }; + const handleClosePadsModal = () => { + setShowPadsModal(false); + }; + const handleCloseSettingsModal = () => { setShowSettingsModal(false); }; @@ -119,6 +125,8 @@ export const ExcalidrawWrapper: React.FC = ({ excalidrawAPI={excalidrawAPI} showBackupsModal={showBackupsModal} setShowBackupsModal={setShowBackupsModal} + showPadsModal={showPadsModal} + setShowPadsModal={setShowPadsModal} showSettingsModal={showSettingsModal} setShowSettingsModal={setShowSettingsModal} /> @@ -135,6 +143,13 @@ export const ExcalidrawWrapper: React.FC = ({ /> )} + {showPadsModal && ( + + )} + {showSettingsModal && ( void; + showPadsModal: boolean; + setShowPadsModal: (show: boolean) => void; showSettingsModal?: boolean; setShowSettingsModal?: (show: boolean) => void; } @@ -30,6 +32,7 @@ export const MainMenuConfig: React.FC = ({ MainMenu, excalidrawAPI, setShowBackupsModal, + setShowPadsModal, setShowSettingsModal = (show: boolean) => {}, }) => { const [showAccountModal, setShowAccountModal] = useState(false); @@ -128,6 +131,10 @@ export const MainMenuConfig: React.FC = ({ const handleCanvasBackupsClick = () => { setShowBackupsModal(true); }; + + const handleManagePadsClick = () => { + setShowPadsModal(true); + }; const handleSettingsClick = () => { setShowSettingsModal(true); @@ -230,6 +237,12 @@ export const MainMenuConfig: React.FC = ({ + } + onClick={handleManagePadsClick} + > + Manage pads... + } onClick={handleCanvasBackupsClick} diff --git a/src/frontend/src/ui/PadsDialog.scss b/src/frontend/src/ui/PadsDialog.scss new file mode 100644 index 0000000..05ba01d --- /dev/null +++ b/src/frontend/src/ui/PadsDialog.scss @@ -0,0 +1,185 @@ +.pads-dialog { + &__wrapper { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + } + + &__title-container { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__title { + margin: 0; + font-size: 1.2rem; + font-weight: 600; + } + + &__content { + padding: 1rem; + max-height: 70vh; + overflow-y: auto; + } + + &__loading, + &__error, + &__empty { + text-align: center; + padding: 2rem 0; + color: var(--text-primary-color); + } + + &__list { + list-style: none; + padding: 0; + margin: 0; + } + + &__item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + border-radius: 6px; + margin-bottom: 0.5rem; + background-color: var(--island-bg-color); + transition: background-color 0.2s ease; + + &:hover { + background-color: var(--button-hover-bg); + } + + &--active { + border-left: 3px solid #cc6d24; + } + } + + &__item-content { + display: flex; + flex-direction: column; + flex: 1; + padding: 0.25rem; + border-radius: 4px; + transition: background-color 0.2s ease; + + &--clickable { + cursor: pointer; + + &:hover { + background-color: var(--button-hover-bg); + } + } + + &--current { + cursor: default; + } + } + + &__name { + font-weight: 500; + margin-bottom: 0.25rem; + } + + &__current { + font-weight: normal; + font-style: italic; + color: #cc6d24; + } + + &__timestamps { + display: flex; + flex-direction: column; + gap: 0.2rem; + } + + &__timestamp { + font-size: 0.8rem; + color: var(--text-secondary-color); + } + + &__actions { + display: flex; + gap: 0.5rem; + } + + &__icon-button { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + border-radius: 4px; + padding: 0.4rem; + cursor: pointer; + color: var(--text-primary-color); + transition: background-color 0.2s ease, color 0.2s ease; + + &:hover { + background-color: var(--button-hover-bg); + color: #cc6d24; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + + &:hover { + background: none; + color: var(--text-primary-color); + } + } + } + + &__edit-form { + display: flex; + flex-direction: column; + width: 100%; + gap: 0.5rem; + + input { + padding: 0.5rem; + border-radius: 4px; + border: 1px solid var(--button-gray-2); + background-color: var(--input-bg-color); + color: var(--text-primary-color); + font-size: 1rem; + } + } + + &__edit-actions { + display: flex; + gap: 0.5rem; + } + + &__button { + padding: 0.4rem 0.8rem; + border-radius: 4px; + border: none; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.2s ease; + + &--save { + background-color: #cc6d24; + color: white; + + &:hover { + background-color: #b05e1f; + } + } + + &--cancel { + background-color: var(--button-gray-2); + color: var(--text-primary-color); + + &:hover { + background-color: var(--button-gray-3); + } + } + } +} diff --git a/src/frontend/src/ui/PadsDialog.tsx b/src/frontend/src/ui/PadsDialog.tsx new file mode 100644 index 0000000..632ee3d --- /dev/null +++ b/src/frontend/src/ui/PadsDialog.tsx @@ -0,0 +1,277 @@ +import React, { useState, useCallback } from "react"; +import { Dialog } from "@atyrode/excalidraw"; +import { Pencil, Trash2 } from "lucide-react"; +import { useAllPads, useRenamePad, useDeletePad, PadData } from "../api/hooks"; +import { loadPadData, getActivePad, setActivePad, saveCurrentPadBeforeSwitching } from "../utils/canvasUtils"; +import { queryClient } from "../api/queryClient"; +import { capture } from "../utils/posthog"; +import "./PadsDialog.scss"; + +interface PadsDialogProps { + excalidrawAPI?: any; + onClose?: () => void; +} + +const PadsDialog: React.FC = ({ + excalidrawAPI, + onClose, +}) => { + const [modalIsShown, setModalIsShown] = useState(true); + const { data: pads, isLoading, error } = useAllPads(); + const activePadId = getActivePad(); + const [editingPadId, setEditingPadId] = useState(null); + const [newPadName, setNewPadName] = useState(""); + + // Get the renamePad mutation + const { mutate: renamePad } = useRenamePad({ + onSuccess: (data, variables) => { + console.debug("[pad.ws] Pad renamed successfully"); + + // Update the cache directly instead of refetching + const { padId, newName } = variables; + + // Get the current pads from the query cache + const currentPads = queryClient.getQueryData(['allPads']); + + if (currentPads) { + // Create a new array with the updated pad name + const updatedPads = currentPads.map(pad => + pad.id === padId + ? { ...pad, display_name: newName } + : pad + ); + + // Update the query cache with the new data + queryClient.setQueryData(['allPads'], updatedPads); + } + + // Reset editing state + setEditingPadId(null); + }, + onError: (error) => { + console.error("[pad.ws] Failed to rename pad:", error); + setEditingPadId(null); + } + }); + + // Get the deletePad mutation + const { mutate: deletePad } = useDeletePad({ + onSuccess: (data, padId) => { + console.debug("[pad.ws] Pad deleted successfully"); + + // Update the cache directly instead of refetching + // Get the current pads from the query cache + const currentPads = queryClient.getQueryData(['allPads']); + + if (currentPads) { + // Create a new array without the deleted pad + const updatedPads = currentPads.filter(pad => pad.id !== padId); + + // Update the query cache with the new data + queryClient.setQueryData(['allPads'], updatedPads); + } + }, + onError: (error) => { + console.error("[pad.ws] Failed to delete pad:", error); + } + }); + + const handleClose = useCallback(() => { + setModalIsShown(false); + if (onClose) { + onClose(); + } + }, [onClose]); + + const handleRenameClick = (pad: PadData) => { + setEditingPadId(pad.id); + setNewPadName(pad.display_name); + }; + + const handleRenameSubmit = (padId: string) => { + if (newPadName.trim() === "") return; + + // Track pad rename event + capture("pad_renamed", { + padId, + newName: newPadName + }); + + // Call the renamePad mutation + renamePad({ padId, newName: newPadName }); + }; + + const handleDeleteClick = (pad: PadData) => { + // Don't allow deleting the last pad + if (pads && pads.length <= 1) { + alert("Cannot delete the last pad"); + return; + } + + // Confirm deletion + if (!window.confirm(`Are you sure you want to delete "${pad.display_name}"?`)) { + return; + } + + // Track pad deletion event + capture("pad_deleted", { + padId: pad.id, + padName: pad.display_name + }); + + // If deleting the active pad, switch to another pad first + if (pad.id === activePadId && pads) { + const otherPad = pads.find(p => p.id !== pad.id); + if (otherPad && excalidrawAPI) { + handleLoadPad(otherPad); + } + } + + // Call the deletePad mutation + deletePad(pad.id); + }; + + const handleLoadPad = (pad: PadData) => { + if (!excalidrawAPI) return; + + // Save the current canvas before switching tabs + if (activePadId) { + saveCurrentPadBeforeSwitching(excalidrawAPI, activePadId, (data) => { + console.debug("[pad.ws] Canvas saved before switching"); + }); + } + + // Set the new active pad ID + setActivePad(pad.id); + + // Load the pad data + loadPadData(excalidrawAPI, pad.id, pad.data); + + // Close the dialog + handleClose(); + }; + + // Format date function + const formatDate = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + // Dialog content + const dialogContent = ( +
+ {isLoading ? ( +
Loading pads...
+ ) : error ? ( +
Error loading pads
+ ) : !pads || pads.length === 0 ? ( +
No pads available
+ ) : ( +
    + {pads.map((pad) => ( +
  • + {editingPadId === pad.id ? ( +
    + setNewPadName(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleRenameSubmit(pad.id); + } else if (e.key === 'Escape') { + setEditingPadId(null); + } + }} + /> +
    + + +
    +
    + ) : ( + <> +
    pad.id !== activePadId && handleLoadPad(pad)} + > + + {pad.display_name} + {pad.id === activePadId && (current)} + +
    + Created: {formatDate(pad.created_at)} + Last updated: {formatDate(pad.updated_at || pad.created_at)} +
    +
    +
    + + +
    + + )} +
  • + ))} +
+ )} +
+ ); + + return ( + <> + {modalIsShown && ( +
+ +

+ Manage Pads +

+
+ } + closeOnClickOutside={true} + children={dialogContent} + /> + + )} + + ); +}; + +export default PadsDialog; diff --git a/src/frontend/src/ui/Tabs.tsx b/src/frontend/src/ui/Tabs.tsx index b6f7673..0997cfa 100644 --- a/src/frontend/src/ui/Tabs.tsx +++ b/src/frontend/src/ui/Tabs.tsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect, useCallback, useRef, useLayoutEffect } from "react"; +import React, { useState, useEffect, useRef, useLayoutEffect } from "react"; import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types"; import { Stack, Button, Section, Tooltip } from "@atyrode/excalidraw"; import { FilePlus2, ChevronLeft, ChevronRight } from "lucide-react"; import { useAllPads, useSaveCanvas, useRenamePad, useDeletePad, PadData } from "../api/hooks"; import { queryClient } from "../api/queryClient"; +import { capture } from "../utils/posthog"; import { getPadData, storePadData, @@ -135,31 +136,61 @@ const Tabs: React.FC = ({ loadPadData(excalidrawAPI, pad.id, pad.data); }; + // Listen for active pad change events + useEffect(() => { + const handleActivePadChange = (event: Event) => { + const customEvent = event as CustomEvent; + const newActivePadId = customEvent.detail; + console.debug(`[pad.ws] Received activePadChanged event with padId: ${newActivePadId}`); + setActivePadId(newActivePadId); + }; + + // Add event listener + window.addEventListener('activePadChanged', handleActivePadChange); + + // Clean up + return () => { + window.removeEventListener('activePadChanged', handleActivePadChange); + }; + }, []); + // Set the active pad ID when the component mounts and when the pads data changes useEffect(() => { - if (!isLoading && pads && pads.length > 0 && !activePadId) { + if (!isLoading && pads && pads.length > 0) { // Check if there's a stored active pad ID const storedActivePadId = getStoredActivePad(); - // Find the pad that matches the stored ID, or use the first pad if no match - let padToActivate = pads[0]; - - if (storedActivePadId) { - // Try to find the pad with the stored ID - const matchingPad = pads.find(pad => pad.id === storedActivePadId); - if (matchingPad) { - console.debug(`[pad.ws] Found stored active pad: ${storedActivePadId}`); - padToActivate = matchingPad; - } else { - console.debug(`[pad.ws] Stored active pad ${storedActivePadId} not found in available pads`); + if (!activePadId || !pads.some(pad => pad.id === activePadId)) { + // Find the pad that matches the stored ID, or use the first pad if no match + let padToActivate = pads[0]; + + if (storedActivePadId) { + // Try to find the pad with the stored ID + const matchingPad = pads.find(pad => pad.id === storedActivePadId); + if (matchingPad) { + console.debug(`[pad.ws] Found stored active pad: ${storedActivePadId}`); + padToActivate = matchingPad; + } else { + console.debug(`[pad.ws] Stored active pad ${storedActivePadId} not found in available pads`); + } + } + + // Set the active pad ID + setActivePadId(padToActivate.id); + // Store the active pad ID globally + setActivePad(padToActivate.id); + + // If the current canvas is empty, load the pad data + const currentElements = excalidrawAPI.getSceneElements(); + if (currentElements.length === 0) { + // Load the pad data using the imported function + loadPadData(excalidrawAPI, padToActivate.id, padToActivate.data); } + } else if (storedActivePadId && storedActivePadId !== activePadId) { + // Update local state to match global state + setActivePadId(storedActivePadId); } - // Set the active pad ID - setActivePadId(padToActivate.id); - // Store the active pad ID globally - setActivePad(padToActivate.id); - // Store all pads in local storage for the first time pads.forEach(pad => { // Only store if not already in local storage @@ -167,13 +198,6 @@ const Tabs: React.FC = ({ storePadData(pad.id, pad.data); } }); - - // If the current canvas is empty, load the pad data - const currentElements = excalidrawAPI.getSceneElements(); - if (currentElements.length === 0) { - // Load the pad data using the imported function - loadPadData(excalidrawAPI, padToActivate.id, padToActivate.data); - } } }, [pads, isLoading, activePadId, excalidrawAPI]); @@ -186,6 +210,12 @@ const Tabs: React.FC = ({ // Create a new pad using the imported function const newPad = await createNewPad(excalidrawAPI, activePadId, saveCanvas); + // Track pad creation event + capture("pad_created", { + padId: newPad.id, + padName: newPad.display_name + }); + // Set the active pad ID in the component state setActivePadId(newPad.id); @@ -197,12 +227,7 @@ const Tabs: React.FC = ({ const newPadIndex = currentPads.findIndex(pad => pad.id === newPad.id); if (newPadIndex !== -1) { - // Calculate the appropriate startPadIndex to ensure the new pad is visible - // We want to position the view so that the new pad is visible - // Ideally, we want the new pad to be the last visible pad in the view const newStartIndex = Math.max(0, Math.min(newPadIndex - PADS_PER_PAGE + 1, currentPads.length - PADS_PER_PAGE)); - - // Update both the component state and the stored value setStartPadIndex(newStartIndex); setScrollIndex(newStartIndex); } @@ -229,13 +254,6 @@ const Tabs: React.FC = ({ } }; - // Create a dependency that only changes when the number of pads changes or pad IDs change - const padStructure = React.useMemo(() => { - return pads ? pads.map(pad => pad.id) : []; - }, [pads]); - - // We've removed the auto-centering feature that would automatically position the active pad in the middle of the tab bar - // Create a ref for the tabs wrapper to handle wheel events const tabsWrapperRef = useRef(null); @@ -300,7 +318,7 @@ const Tabs: React.FC = ({ className="tabs-wrapper" ref={tabsWrapperRef} > - {/* New pad button - moved to the beginning */} + {/* New pad button */}
= ({ }); }} > - {/* Only show tooltip if name is likely to be truncated (more than ~15 characters) */} - {pad.display_name.length > 11 ? ( - handlePadSelect(pad)} - className={activePadId === pad.id ? "active-pad" : ""} - children={ -
- {pad.display_name.length > 8 ? `${pad.display_name.substring(0, 11)}...` : pad.display_name} - {startPadIndex + pads.slice(startPadIndex, startPadIndex + PADS_PER_PAGE).indexOf(pad) + 1} -
- } - /> - } /> + {/* Show tooltip for all active tabs or truncated names */} + {(activePadId === pad.id || pad.display_name.length > 11) ? ( + 11 + ? `${pad.display_name} (current pad)` + : "Current pad") + : pad.display_name + } + children={ +