Compare commits

...

244 Commits

Author SHA1 Message Date
Jeff Emmett 1b0fc57779 fix 'c', mycofi room,remove gesture tool 2025-10-02 17:28:50 -04:00
Jeff Emmett 8fa8c388d9 fixed shared piano 2025-09-04 17:54:39 +02:00
Jeff Emmett 356a262114 update tldraw functions for update 2025-09-04 16:58:15 +02:00
Jeff Emmett 1abeeaea10 update R2 storage to JSON format 2025-09-04 16:26:35 +02:00
Jeff Emmett 808b37425a update tldraw functions 2025-09-04 15:30:57 +02:00
Jeff Emmett 8385e30d25 separate worker and buckets between dev & prod, fix cron job scheduler 2025-09-04 15:12:44 +02:00
Jeff Emmett 391e13c350 update embedshape 2025-09-02 22:59:10 +02:00
Jeff Emmett d0233c0eb6 update workers to work again 2025-09-02 22:29:12 +02:00
Jeff Emmett 3b137b0b55 fix worker url in env vars for prod 2025-09-02 14:28:11 +02:00
Jeff Emmett ec9db36a50 debug videochat 2025-09-02 13:26:57 +02:00
Jeff Emmett e78f9a8281 fix worker url in prod 2025-09-02 13:16:15 +02:00
Jeff Emmett c99b9710b5 worker env vars fix 2025-09-02 11:04:55 +02:00
Jeff Emmett a8c9bd845b deploy worker 2025-09-02 01:27:35 +02:00
Jeff Emmett 9a9cab1b8e fix video chat in prod env vars 2025-09-02 00:43:57 +02:00
Jeff Emmett 1d1b64fe7c update env vars 2025-09-01 20:47:22 +02:00
Jeff Emmett 17ba57ce6e fix zoom & videochat 2025-09-01 09:44:52 +02:00
Jeff Emmett fa2f16c019 fix vercel errors 2025-08-25 23:46:37 +02:00
Jeff Emmett 0c980f5f48 Merge branch 'auth-webcrypto' 2025-08-25 16:11:46 +02:00
Jeff Emmett fdc14a1a92 fix vercel deployment errors 2025-08-25 07:14:21 +02:00
Jeff Emmett 956463d43f user auth via webcryptoapi, starred boards, dashboard view 2025-08-25 06:48:47 +02:00
Jeff Emmett 125e565c55 shared piano in progress 2025-08-23 16:07:43 +02:00
Jeff Emmett 129d72cd58 fix gesturetool 2025-07-29 23:01:37 -04:00
Jeff Emmett b01cb9abf8 fix gesturetool for vercel 2025-07-29 22:49:27 -04:00
Jeff Emmett f949f323de working auth login and starred boards on dashboard! 2025-07-29 22:04:14 -04:00
Jeff Emmett 5eb5789c23 add in gestures and ctrl+space command tool (TBD add global LLM) 2025-07-29 16:02:51 -04:00
Jeff Emmett 15fa9b8d19 implemented collections and graph layout tool 2025-07-29 14:52:57 -04:00
Jeff Emmett 7e3cca656e update spelling 2025-07-29 12:46:23 -04:00
Jeff Emmett 6e373e57f1 update presentations and added resilience subpage 2025-07-29 12:41:15 -04:00
Jeff Emmett 545372dcba update presentations page 2025-07-27 17:52:23 -04:00
Jeff Emmett d7fcf121f8 update contact page with calendar booking & added presentations 2025-07-27 16:07:44 -04:00
Jeff Emmett c5e606e326 auth in progress 2025-04-17 15:51:49 -07:00
Shawn Anderson bb144428d0 Revert "updated website copy, installed locked-view function (coordinates break when locked tho), trying to get video transcripts working"
This reverts commit d7b1e348e9.
2025-04-16 13:05:57 -07:00
Shawn Anderson 33f1aa4e90 Revert "Update Daily API key in production env"
This reverts commit 30c0dfc3ba.
2025-04-16 13:05:55 -07:00
Shawn Anderson 411fc99201 Revert "fix daily API key in prod"
This reverts commit 49f11dc6e5.
2025-04-16 13:05:54 -07:00
Shawn Anderson 4364743555 Revert "update wrangler"
This reverts commit b0beefe516.
2025-04-16 13:05:52 -07:00
Shawn Anderson 6dd387613b Revert "Fix cron job connection to daily board backup"
This reverts commit e936d1c597.
2025-04-16 13:05:51 -07:00
Shawn Anderson 04705665f5 Revert "update website main page and repo readme, add scroll bar to markdown tool"
This reverts commit 7b84d34c98.
2025-04-16 13:05:50 -07:00
Shawn Anderson c13d8720d2 Revert "update readme"
This reverts commit 52736e9812.
2025-04-16 13:05:44 -07:00
Shawn Anderson df72890577 Revert "remove footer"
This reverts commit 4e88428706.
2025-04-16 13:05:31 -07:00
Jeff Emmett 4e88428706 remove footer 2025-04-15 23:04:17 -07:00
Jeff Emmett 52736e9812 update readme 2025-04-15 22:47:51 -07:00
Jeff Emmett 7b84d34c98 update website main page and repo readme, add scroll bar to markdown tool 2025-04-15 22:35:02 -07:00
Jeff-Emmett e936d1c597 Fix cron job connection to daily board backup 2025-04-08 15:49:34 -07:00
Jeff-Emmett b0beefe516 update wrangler 2025-04-08 15:32:37 -07:00
Jeff-Emmett 49f11dc6e5 fix daily API key in prod 2025-04-08 14:45:54 -07:00
Jeff-Emmett 30c0dfc3ba Update Daily API key in production env 2025-04-08 14:39:29 -07:00
Jeff-Emmett d7b1e348e9 updated website copy, installed locked-view function (coordinates break when locked tho), trying to get video transcripts working 2025-04-08 14:32:15 -07:00
Jeff-Emmett 2a3b79df15 fix asset upload rendering errors 2025-03-19 18:30:15 -07:00
Jeff-Emmett b11aecffa4 Fixed asset upload CORS for broken links, updated markdown tool, changed keyboard shortcuts & menu ordering 2025-03-19 17:24:22 -07:00
Jeff-Emmett 4b5ba9eab3 Markdown tool working, console log cleanup 2025-03-15 14:57:57 -07:00
Jeff-Emmett 0add9bd514 lock & unlock shapes, clean up overrides & context menu, make embed element easier to interact with 2025-03-15 01:03:55 -07:00
Jeff-Emmett a770d516df hide broadcast from context menu 2025-03-05 18:06:22 -05:00
Jeff-Emmett 47db716af3 camera initialization fixed 2025-02-26 09:48:17 -05:00
Jeff-Emmett e7e911c5bb prompt shape working, fix indicator & scroll later 2025-02-25 17:53:36 -05:00
Jeff-Emmett 1126fc4a1c LLM prompt tool operational, fixed keyboard shortcut conflicts 2025-02-25 15:48:29 -05:00
Jeff-Emmett 59e9025336 changed zoom shortcut to ctrl+up & ctrl+down, savetoPDF to alt+s 2025-02-25 15:24:41 -05:00
Jeff-Emmett 7d6afb6c6b Fix context menu with defaults 2025-02-25 11:38:53 -05:00
Jeff-Emmett 3a99af257d video fix 2025-02-16 11:35:05 +01:00
Jeff-Emmett 12256c5b9c working video calls 2025-02-13 20:38:01 +01:00
Jeff-Emmett 87854883c6 deploy embed minimize function 2025-02-12 18:20:33 +01:00
Jeff-Emmett ebe2d4c0a2 Fix localstorage error on worker, promptshape 2025-02-11 14:35:22 +01:00
Jeff-Emmett d733b61a66 fix llm prompt for mobile 2025-02-08 20:29:06 +01:00
Jeff-Emmett 61143d2c20 Fixed API key button placement & status update 2025-02-08 19:30:20 +01:00
Jeff-Emmett f47c3e0007 reduce file size for savetoPDF 2025-02-08 19:09:20 +01:00
Jeff-Emmett 536e1e7a87 update wrangler 2025-02-08 17:57:50 +01:00
Jeff-Emmett ab2a9f6a79 board backups to R2 2025-01-28 16:42:58 +01:00
Jeff-Emmett 9b33efdcb3 Clean up tool names 2025-01-28 16:38:41 +01:00
Jeff-Emmett 86b37b9cc8 llm edges 2025-01-23 22:49:55 +01:00
Jeff-Emmett 7805a1e961 working llm util 2025-01-23 22:38:27 +01:00
Jeff-Emmett fdb96b6ae1 slidedeck shape installed, still minor difficulty in keyboard arrow transition between slides (last slide + wraparound) 2025-01-23 14:14:04 +01:00
Jeff-Emmett 1783d1b6eb added scoped propagators (with javascript object on arrow edge to control) 2025-01-21 23:25:28 +07:00
Jeff-Emmett bfbe7b8325 expand board zoom & fixed embedshape focus on mobile 2025-01-18 01:57:54 +07:00
Jeff-Emmett e3e2c474ac implemented basic board text search function, added double click to zoom 2025-01-03 10:52:04 +07:00
Jeff-Emmett 7b1fe2b803 removed padding from printtoPDF, hid mycrozine template tool (need to fix sync), cleaned up redundancies between app & board, installed marked npm package, hid markdown tool (need to fix styles) 2025-01-03 09:42:53 +07:00
Jeff-Emmett 02f816e613 updated EmbedShape to default to drag rather than interact when selected 2024-12-29 22:50:20 +07:00
Jeff Emmett 198109a919 add debug logging for videochat render 2024-12-16 17:12:40 -05:00
Jeff Emmett c6370c0fde update Daily API in worker, add debug 2024-12-16 17:00:15 -05:00
Jeff Emmett c75acca85b added TODO for broadcast, fixed videochat 2024-12-16 16:36:36 -05:00
Jeff Emmett d7f4d61b55 fix local IP for dev, fix broadcast view 2024-12-14 14:12:31 -05:00
Jeff Emmett 221a453411 adding broadcast controls for view follow, and shared iframe state while broadcasting (attempt) 2024-12-12 23:37:14 -05:00
Jeff Emmett ce3063e9ba adding selected object resizing with ctrl+arrows 2024-12-12 23:22:35 -05:00
Jeff Emmett 7987c3a8e4 default embed proportions 2024-12-12 23:00:26 -05:00
Jeff Emmett 8f94ee3a6f remove markdown element from menu until fixed. Added copy link & open in new tab options in embedded element URL 2024-12-12 20:45:37 -05:00
Jeff Emmett 201e489cef create frame shortcut dropdown on context menu 2024-12-12 20:02:56 -05:00
Jeff Emmett d23dca3ba8 leave drag selected object for later 2024-12-12 19:45:39 -05:00
Jeff Emmett 42e5afbb21 adding arrow key movements and drag functionality on selected elements 2024-12-12 18:05:35 -05:00
Jeff Emmett 997f690d22 added URL below embedded elements 2024-12-12 17:09:00 -05:00
Jeff Emmett 7978772d7b fix map embed 2024-12-10 12:28:39 -05:00
Jeff Emmett 9f54400f18 updated medium embeds to link out to new tab 2024-12-09 20:19:35 -05:00
Jeff Emmett 34681a3f4f fixed map embeds to include directions, substack embeds, twitter embeds 2024-12-09 18:55:38 -05:00
Jeff Emmett 3bb7eda655 add github action deploy 2024-12-09 04:37:01 -05:00
Jeff Emmett 72a7a54866 fix? 2024-12-09 04:19:49 -05:00
Jeff Emmett e714233f67 remove package lock from gitignore 2024-12-09 04:15:35 -05:00
Jeff Emmett cca1a06b9f install github actions 2024-12-09 03:51:54 -05:00
Jeff Emmett 84e737216d videochat working 2024-12-09 03:42:44 -05:00
Jeff Emmett bf5b3239dd fix domain url 2024-12-08 23:14:22 -05:00
Jeff Emmett 5858775483 logging bugs 2024-12-08 20:55:09 -05:00
Jeff Emmett b74ae75fa8 turn off cloud recording due to plan 2024-12-08 20:52:17 -05:00
Jeff Emmett 6e1e03d05b video debug 2024-12-08 20:47:39 -05:00
Jeff Emmett ce50366985 fix video api key 2024-12-08 20:41:45 -05:00
Jeff Emmett d9fb9637bd video bugs 2024-12-08 20:21:16 -05:00
Jeff Emmett 5d39baaea8 fix videochat 2024-12-08 20:11:05 -05:00
Jeff Emmett 9def6c52b5 fix video 2024-12-08 20:02:14 -05:00
Jeff Emmett 1f6b693ec1 videochat debug 2024-12-08 19:57:25 -05:00
Jeff Emmett b2e06ad76b fix videochat bugs 2024-12-08 19:46:29 -05:00
Jeff Emmett ac69e09aca fix url characters for videochat app 2024-12-08 19:38:28 -05:00
Jeff Emmett 08f31a0bbd fix daily domain 2024-12-08 19:35:11 -05:00
Jeff Emmett 2bdd6a8dba fix daily API 2024-12-08 19:27:18 -05:00
Jeff Emmett 9ff366c80b fixing daily api and domain 2024-12-08 19:19:19 -05:00
Jeff Emmett cc216eb07f fixing daily domain on vite config 2024-12-08 19:10:39 -05:00
Jeff Emmett d2ff445ddf fixing daily domain on vite config 2024-12-08 19:08:40 -05:00
Jeff Emmett a8ca366bb6 videochat tool worker fix 2024-12-08 18:51:23 -05:00
Jeff Emmett 4901a56d61 videochat tool worker install 2024-12-08 18:32:39 -05:00
Jeff Emmett 2d562b3e4c videochat tool update 2024-12-08 18:13:47 -05:00
Jeff Emmett a9a23e27e3 fix vitejs plugin dependency 2024-12-08 14:01:30 -05:00
Jeff Emmett cee2bfa336 update package engine 2024-12-08 13:58:40 -05:00
Jeff Emmett 5924b0cc97 update jspdf package types 2024-12-08 13:54:58 -05:00
Jeff Emmett 4ec6b73fb3 PrintToPDF working 2024-12-08 13:39:07 -05:00
Jeff Emmett ce50026cc3 PrintToPDF integration 2024-12-08 13:31:53 -05:00
Jeff Emmett 0ff9c64908 same 2024-12-08 05:45:31 -05:00
Jeff Emmett cf722c2490 everything working but page load camera initialization 2024-12-08 05:45:16 -05:00
Jeff Emmett 64d7581e6b fixed lockCameraToFrame selection 2024-12-08 05:07:09 -05:00
Jeff Emmett 1190848222 lockCamera still almost working 2024-12-08 03:01:28 -05:00
Jeff Emmett 11c88ec0de lockCameraToFrame almost working 2024-12-08 02:43:19 -05:00
Jeff Emmett 95307ed453 cleanup 2024-12-07 23:22:10 -05:00
Jeff Emmett bfe6b238e9 cleanup 2024-12-07 23:03:42 -05:00
Jeff Emmett fe4b40a3fe
Merge pull request #3 from Jeff-Emmett/markdown-textbox
cleanup
2024-12-08 11:01:55 +07:00
Jeff Emmett 4fda800e8b cleanup 2024-12-07 23:00:30 -05:00
Jeff Emmett 7c28758204 cleanup 2024-12-07 22:50:55 -05:00
Jeff Emmett 75c769a774 fix dev script 2024-12-07 22:49:39 -05:00
Jeff Emmett 5d8781462d npm 2024-12-07 22:48:02 -05:00
Jeff Emmett b2d6b1599b bun 2024-12-07 22:23:19 -05:00
Jeff Emmett c81238c45a remove deps 2024-12-07 22:15:05 -05:00
Jeff Emmett f012632cde prettify and cleanup 2024-12-07 22:01:02 -05:00
Jeff Emmett 78e396d11e cleanup 2024-12-07 21:42:31 -05:00
Jeff Emmett cba62a453b remove homepage board 2024-12-07 21:28:45 -05:00
Jeff Emmett 923f61ac9e cleanup tools/menu/actions 2024-12-07 21:16:44 -05:00
Jeff Emmett 94bec533c4
Merge pull request #2 from Jeff-Emmett/main-fixed
Main fixed
2024-12-08 04:23:27 +07:00
Jeff Emmett e286a120f1 maybe this works 2024-12-07 16:02:10 -05:00
Jeff Emmett 2e0a05ab32 fix vite config 2024-12-07 15:50:37 -05:00
Jeff Emmett 110fc19b94 one more attempt 2024-12-07 15:35:53 -05:00
Jeff Emmett 111be03907 swap persistentboard with Tldraw native sync 2024-12-07 15:23:56 -05:00
Jeff Emmett 39e6cccc3f fix CORS 2024-12-07 15:10:25 -05:00
Jeff Emmett 08175d3a7c fix CORS 2024-12-07 15:03:53 -05:00
Jeff Emmett 3006e85375 fix prod env 2024-12-07 14:57:05 -05:00
Jeff Emmett 632e7979a2 fix CORS 2024-12-07 14:39:57 -05:00
Jeff Emmett 71fc07133a fix CORS for prod env 2024-12-07 14:33:31 -05:00
Jeff Emmett 97b00c1569 fix prod env 2024-12-07 13:43:56 -05:00
Jeff Emmett c4198e1faf add vite env types 2024-12-07 13:31:37 -05:00
Jeff Emmett 6f6c924f66 fix VITE_ worker URL 2024-12-07 13:27:37 -05:00
Jeff Emmett 0eb4407219 fix worker deployment 2024-12-07 13:15:38 -05:00
Jeff Emmett 3a2a38c0b6 fix CORS policy 2024-12-07 12:58:46 -05:00
Jeff Emmett 02124ce920 fix CORS policy 2024-12-07 12:58:25 -05:00
Jeff Emmett b700846a9c fixing production env 2024-12-07 12:52:20 -05:00
Jeff Emmett f7310919f8 fix camerarevert and default to select tool 2024-11-27 13:46:41 +07:00
Jeff Emmett 949062941f fix default to hand tool 2024-11-27 13:38:54 +07:00
Jeff Emmett 7f497ae8d8 fix camera history 2024-11-27 13:30:45 +07:00
Jeff Emmett 1d817c8e0f add all function shortcuts to contextmenu 2024-11-27 13:24:11 +07:00
Jeff Emmett 7dd045bb33 fix menus 2024-11-27 13:16:52 +07:00
Jeff Emmett 11d13a03d3 fix menus 2024-11-27 13:01:45 +07:00
Jeff Emmett 3bcfa83168 fix board camera controls 2024-11-27 12:47:52 +07:00
Jeff Emmett b0a3cd7328 remove copy file creating problems 2024-11-27 12:25:04 +07:00
Jeff Emmett c71b67e24c fix vercel 2024-11-27 12:13:29 +07:00
Jeff Emmett d582be49b2 Merge branch 'add-camera-controls-for-link-to-frame-and-screen-position' 2024-11-27 11:56:36 +07:00
Jeff Emmett 46ee4e7906 fix gitignore 2024-11-27 11:54:05 +07:00
Jeff Emmett c34418e964 fix durable object reference 2024-11-27 11:34:02 +07:00
Jeff Emmett 1c8909ce69 fix worker url 2024-11-27 11:31:16 +07:00
Jeff Emmett 5f2c90219d fix board 2024-11-27 11:27:59 +07:00
Jeff Emmett fef2ca0eb3 fixing final 2024-11-27 11:26:25 +07:00
Jeff Emmett eab574e130 fix underscore 2024-11-27 11:23:46 +07:00
Jeff Emmett b2656c911b fix durableobject 2024-11-27 11:21:33 +07:00
Jeff Emmett 6ba124b038 fix env vars in vite 2024-11-27 11:17:29 +07:00
Jeff Emmett 1cd7208ddf fix vite and asset upload 2024-11-27 11:14:52 +07:00
Jeff Emmett d555910c77 fixed wrangler.toml 2024-11-27 11:07:15 +07:00
Jeff Emmett d1a8407a9b swapped in daily.co video and removed whereby sdk, finished zoom and copylink except for context menu display 2024-11-27 10:39:33 +07:00
Jeff Emmett db3205f97a almost everything working, except maybe offline storage state (and browser reload) 2024-11-25 22:09:41 +07:00
Jeff Emmett 100b88268b CRDTs working, still finalizing local board state browser storage for offline board access 2024-11-25 16:18:05 +07:00
Jeff Emmett 202971f343 checkpoint before google auth 2024-11-21 17:00:46 +07:00
Jeff Emmett b26b9e6384 final copy fix 2024-10-22 19:19:47 -04:00
Jeff Emmett 4d69340a6b update copy 2024-10-22 19:13:14 -04:00
Jeff Emmett 14e0126995 site copy update 2024-10-21 12:12:22 -04:00
Jeff Emmett 04782854d2 fix board 2024-10-19 23:30:04 -04:00
Jeff Emmett 4eff918bd3 fixed a bunch of stuff 2024-10-19 23:21:42 -04:00
Jeff Emmett 4e2103aab2 fix mobile embed 2024-10-19 16:20:54 -04:00
Jeff Emmett 895d02a19c embeds work! 2024-10-19 00:42:23 -04:00
Jeff Emmett 375f69b365 CustomMainMenu 2024-10-18 23:54:28 -04:00
Jeff Emmett 09a729c787 remove old chatboxes 2024-10-18 23:37:27 -04:00
Jeff Emmett bb8a76026e fix 2024-10-18 23:14:18 -04:00
Jeff Emmett 4319a6b1ee fix chatbox 2024-10-18 23:09:25 -04:00
Jeff Emmett 2ca6705599 update 2024-10-18 22:55:35 -04:00
Jeff Emmett 07556dd53a remove old chatbox 2024-10-18 22:47:23 -04:00
Jeff Emmett c93b3066bd serializedRoom 2024-10-18 22:31:20 -04:00
Jeff Emmett d282f6b650 deploy logs 2024-10-18 22:23:28 -04:00
Jeff Emmett c34cae40b6 fixing worker 2024-10-18 22:14:48 -04:00
Jeff Emmett 46b54394ad remove old chat rooms 2024-10-18 21:58:29 -04:00
Jeff Emmett b05aa413e3 resize 2024-10-18 21:30:16 -04:00
Jeff Emmett 2435f3f495 resize 2024-10-18 21:26:53 -04:00
Jeff Emmett 49bca38b5f it works! 2024-10-18 21:04:53 -04:00
Jeff Emmett 0d7ee5889c fixing video 2024-10-18 20:59:46 -04:00
Jeff Emmett a0bba93055 update 2024-10-18 18:59:06 -04:00
Jeff Emmett a2d7ab4af0 remove prefix 2024-10-18 18:08:05 -04:00
Jeff Emmett 99f7f131ed fix 2024-10-18 17:54:45 -04:00
Jeff Emmett c369762001 revert 2024-10-18 17:41:50 -04:00
Jeff Emmett d81ae56de0
Merge pull request #1 from Jeff-Emmett/Video-Chat-Attempt
Video chat attempt
2024-10-18 17:38:25 -04:00
Jeff Emmett f384673cf9 replace all ChatBox with chatBox 2024-10-18 17:35:05 -04:00
Jeff Emmett 670c9ff0b0 maybe 2024-10-18 17:24:43 -04:00
Jeff Emmett 2ac4ec8de3 yay 2024-10-18 14:58:54 -04:00
Jeff Emmett 7e16f6e6b0 hi 2024-10-18 14:43:31 -04:00
Jeff Emmett 63cd76e919 add editor back in 2024-10-17 17:08:55 -04:00
Jeff Emmett 91df5214c6 remove editor in board.tsx 2024-10-17 17:00:48 -04:00
Jeff Emmett 900833c06c Fix live site 2024-10-17 16:21:00 -04:00
Jeff Emmett 700875434f good hygiene commit 2024-10-17 14:54:23 -04:00
Jeff Emmett 9d5d0d6655 big mess of a commit 2024-10-16 11:20:26 -04:00
Jeff Emmett 8ce8dec8f7 video chat attempt 2024-09-04 17:52:58 +02:00
Jeff Emmett 836d37df76 update msgboard UX 2024-08-31 16:17:05 +02:00
Jeff Emmett 2c35a0c53c fix stuff 2024-08-31 15:00:06 +02:00
Jeff Emmett a8c8d62e63 fix image/asset handling 2024-08-31 13:06:13 +02:00
Jeff Emmett 807637eae0 update gitignore 2024-08-31 12:50:29 +02:00
Jeff Emmett 572608f878 multiboard 2024-08-30 12:31:52 +02:00
Jeff Emmett 6747c5df02 move 2024-08-30 10:17:36 +02:00
Jeff Emmett 2c4b2f6c91 more stuff 2024-08-30 09:44:11 +02:00
Jeff Emmett 80cda32cba change 2024-08-29 22:07:38 +02:00
Jeff Emmett 032e4e1199 conf 2024-08-29 21:40:29 +02:00
Jeff Emmett 04676b3788 fix again 2024-08-29 21:35:13 +02:00
Jeff Emmett d6f3830884 fix plz 2024-08-29 21:33:48 +02:00
Jeff Emmett 50c7c52c3d update build step 2024-08-29 21:22:40 +02:00
Jeff Emmett a6eb2abed0 fixed? 2024-08-29 21:20:33 +02:00
Jeff Emmett 1c38cb1bdb multiplayer 2024-08-29 21:15:13 +02:00
Jeff Emmett 932c9935d5 multiplayer 2024-08-29 20:20:12 +02:00
Jeff Emmett 249031619d commit cal 2024-08-15 13:48:39 -04:00
Jeff Emmett 408df0d11e commit conviction voting 2024-08-11 20:37:10 -04:00
Jeff Emmett fc602ff943
Update Contact.tsx 2024-08-11 20:28:52 -04:00
Jeff Emmett d34e586215 poll for impox updates 2024-08-11 01:13:11 -04:00
Jeff Emmett ee2484f1d0 commit goat 2024-08-11 00:55:34 -04:00
Jeff Emmett 0ac03dec60 commit Books 2024-08-11 00:06:23 -04:00
Jeff Emmett 5f3cf2800c name update 2024-08-10 10:41:35 -04:00
Jeff Emmett 206d2a57ec cooptation 2024-08-10 10:27:38 -04:00
Jeff Emmett 87118b86d5 board commit 2024-08-10 01:53:56 -04:00
Jeff Emmett 58cb4da348 board commit 2024-08-10 01:47:58 -04:00
Jeff Emmett d087b61ce5 board commit 2024-08-10 01:43:09 -04:00
Jeff Emmett 9d73295702 oriomimicry 2024-08-09 23:14:58 -04:00
Jeff Emmett 3e6db31c69 Update 2024-08-09 18:34:12 -04:00
Jeff Emmett b8038a6a97 Merge branch 'main' of https://github.com/Jeff-Emmett/canvas-website 2024-08-09 18:27:38 -04:00
Jeff Emmett ee49689416
Update and rename page.html to index.html 2024-08-09 18:18:06 -04:00
147 changed files with 490668 additions and 3 deletions

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
# Frontend (VITE) Public Variables
VITE_GOOGLE_CLIENT_ID='your_google_client_id'
VITE_GOOGLE_MAPS_API_KEY='your_google_maps_api_key'
VITE_DAILY_DOMAIN='your_daily_domain'
VITE_TLDRAW_WORKER_URL='your_worker_url'
# Worker-only Variables (Do not prefix with VITE_)
CLOUDFLARE_API_TOKEN='your_cloudflare_token'
CLOUDFLARE_ACCOUNT_ID='your_account_id'
CLOUDFLARE_ZONE_ID='your_zone_id'
R2_BUCKET_NAME='your_bucket_name'
R2_PREVIEW_BUCKET_NAME='your_preview_bucket_name'
DAILY_API_KEY=your_daily_api_key_here

5
.gitattributes vendored Normal file
View File

@ -0,0 +1,5 @@
*.pdf filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.mov filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text

34
.github/workflows/deploy-worker.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Deploy Worker
on:
push:
branches:
- main # or 'production' depending on your branch name
workflow_dispatch: # Allows manual triggering from GitHub UI
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy Worker
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install Dependencies
run: npm ci
working-directory: ./worker
- name: Deploy to Cloudflare Workers
run: |
npm install -g wrangler@3.107.3
# Uses default wrangler.toml (production config)
wrangler deploy
working-directory: ./worker
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

177
.gitignore vendored Normal file
View File

@ -0,0 +1,177 @@
dist/
.DS_Store
bun.lockb
logs
_.log
npm-debug.log_
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
.wrangler/
# Vercel
.vercel/
.dev.vars
# Environment variables
.env*
.env.development
!.env.example
.vercel
# Environment files
.env
.env.local
.env.*.local
.dev.vars
.env.production

3
.npmrc Normal file
View File

@ -0,0 +1,3 @@
legacy-peer-deps=true
strict-peer-dependencies=false
auto-install-peers=true

4
.prettierrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"semi": false,
"trailingComma": "all"
}

View File

@ -0,0 +1,30 @@
const urls = new Set();
function checkURL(request, init) {
const url =
request instanceof URL
? request
: new URL(
(typeof request === "string"
? new Request(request, init)
: request
).url
);
if (url.port && url.port !== "443" && url.protocol === "https:") {
if (!urls.has(url.toString())) {
urls.add(url.toString());
console.warn(
`WARNING: known issue with \`fetch()\` requests to custom HTTPS ports in published Workers:\n` +
` - ${url.toString()} - the custom port will be ignored when the Worker is published using the \`wrangler deploy\` command.\n`
);
}
}
}
globalThis.fetch = new Proxy(globalThis.fetch, {
apply(target, thisArg, argArray) {
const [request, init] = argArray;
checkURL(request, init);
return Reflect.apply(target, thisArg, argArray);
},
});

View File

@ -0,0 +1,11 @@
import worker, * as OTHER_EXPORTS from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\worker\\worker.ts";
import * as __MIDDLEWARE_0__ from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\node_modules\\wrangler\\templates\\middleware\\middleware-ensure-req-body-drained.ts";
import * as __MIDDLEWARE_1__ from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\node_modules\\wrangler\\templates\\middleware\\middleware-miniflare3-json-error.ts";
export * from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\worker\\worker.ts";
export const __INTERNAL_WRANGLER_MIDDLEWARE__ = [
__MIDDLEWARE_0__.default,__MIDDLEWARE_1__.default
]
export default worker;

View File

@ -0,0 +1,134 @@
// This loads all middlewares exposed on the middleware object and then starts
// the invocation chain. The big idea is that we can add these to the middleware
// export dynamically through wrangler, or we can potentially let users directly
// add them as a sort of "plugin" system.
import ENTRY, { __INTERNAL_WRANGLER_MIDDLEWARE__ } from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\.wrangler\\tmp\\bundle-VlWfGj\\middleware-insertion-facade.js";
import { __facade_invoke__, __facade_register__, Dispatcher } from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\node_modules\\wrangler\\templates\\middleware\\common.ts";
import type { WorkerEntrypointConstructor } from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\.wrangler\\tmp\\bundle-VlWfGj\\middleware-insertion-facade.js";
// Preserve all the exports from the worker
export * from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\.wrangler\\tmp\\bundle-VlWfGj\\middleware-insertion-facade.js";
class __Facade_ScheduledController__ implements ScheduledController {
readonly #noRetry: ScheduledController["noRetry"];
constructor(
readonly scheduledTime: number,
readonly cron: string,
noRetry: ScheduledController["noRetry"]
) {
this.#noRetry = noRetry;
}
noRetry() {
if (!(this instanceof __Facade_ScheduledController__)) {
throw new TypeError("Illegal invocation");
}
// Need to call native method immediately in case uncaught error thrown
this.#noRetry();
}
}
function wrapExportedHandler(worker: ExportedHandler): ExportedHandler {
// If we don't have any middleware defined, just return the handler as is
if (
__INTERNAL_WRANGLER_MIDDLEWARE__ === undefined ||
__INTERNAL_WRANGLER_MIDDLEWARE__.length === 0
) {
return worker;
}
// Otherwise, register all middleware once
for (const middleware of __INTERNAL_WRANGLER_MIDDLEWARE__) {
__facade_register__(middleware);
}
const fetchDispatcher: ExportedHandlerFetchHandler = function (
request,
env,
ctx
) {
if (worker.fetch === undefined) {
throw new Error("Handler does not export a fetch() function.");
}
return worker.fetch(request, env, ctx);
};
return {
...worker,
fetch(request, env, ctx) {
const dispatcher: Dispatcher = function (type, init) {
if (type === "scheduled" && worker.scheduled !== undefined) {
const controller = new __Facade_ScheduledController__(
Date.now(),
init.cron ?? "",
() => {}
);
return worker.scheduled(controller, env, ctx);
}
};
return __facade_invoke__(request, env, ctx, dispatcher, fetchDispatcher);
},
};
}
function wrapWorkerEntrypoint(
klass: WorkerEntrypointConstructor
): WorkerEntrypointConstructor {
// If we don't have any middleware defined, just return the handler as is
if (
__INTERNAL_WRANGLER_MIDDLEWARE__ === undefined ||
__INTERNAL_WRANGLER_MIDDLEWARE__.length === 0
) {
return klass;
}
// Otherwise, register all middleware once
for (const middleware of __INTERNAL_WRANGLER_MIDDLEWARE__) {
__facade_register__(middleware);
}
// `extend`ing `klass` here so other RPC methods remain callable
return class extends klass {
#fetchDispatcher: ExportedHandlerFetchHandler<Record<string, unknown>> = (
request,
env,
ctx
) => {
this.env = env;
this.ctx = ctx;
if (super.fetch === undefined) {
throw new Error("Entrypoint class does not define a fetch() function.");
}
return super.fetch(request);
};
#dispatcher: Dispatcher = (type, init) => {
if (type === "scheduled" && super.scheduled !== undefined) {
const controller = new __Facade_ScheduledController__(
Date.now(),
init.cron ?? "",
() => {}
);
return super.scheduled(controller);
}
};
fetch(request: Request<unknown, IncomingRequestCfProperties>) {
return __facade_invoke__(
request,
this.env,
this.ctx,
this.#dispatcher,
this.#fetchDispatcher
);
}
};
}
let WRAPPED_ENTRY: ExportedHandler | WorkerEntrypointConstructor | undefined;
if (typeof ENTRY === "object") {
WRAPPED_ENTRY = wrapExportedHandler(ENTRY);
} else if (typeof ENTRY === "function") {
WRAPPED_ENTRY = wrapWorkerEntrypoint(ENTRY);
}
export default WRAPPED_ENTRY;

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

75
GESTURES.md Normal file
View File

@ -0,0 +1,75 @@
# Gesture Recognition Tool
This document describes all available gestures in the Canvas application. Use the gesture tool (press `g` or select from toolbar) to draw these gestures and trigger their actions.
## How to Use
1. **Activate the Gesture Tool**: Press `g` or select the gesture tool from the toolbar
2. **Draw a Gesture**: Use your mouse, pen, or finger to draw one of the gestures below
3. **Release**: The gesture will be recognized and the corresponding action will be performed
## Available Gestures
### Basic Gestures (Default Mode)
| Gesture | Description | Action |
|---------|-------------|---------|
| **X** | Draw an "X" shape | Deletes selected shapes |
| **Rectangle** | Draw a rectangle outline | Creates a rectangle shape at the gesture location |
| **Circle** | Draw a circle/oval | Selects and highlights shapes under the gesture |
| **Check** | Draw a checkmark (✓) | Changes color of shapes under the gesture to green |
| **Caret** | Draw a caret (^) pointing up | Aligns selected shapes to the top |
| **V** | Draw a "V" shape pointing down | Aligns selected shapes to the bottom |
| **Delete** | Draw a delete symbol (similar to X) | Deletes selected shapes |
| **Pigtail** | Draw a pigtail/spiral shape | Selects shapes under gesture and rotates them 90° counterclockwise |
### Layout Gestures (Hold Shift + Draw)
| Gesture | Description | Action |
|---------|-------------|---------|
| **Circle Layout** | Draw a circle while holding Shift | Arranges selected shapes in a circle around the gesture center |
| **Triangle Layout** | Draw a triangle while holding Shift | Arranges selected shapes in a triangle around the gesture center |
## Gesture Tips
- **Accuracy**: Draw gestures clearly and completely for best recognition
- **Size**: Gestures work at various sizes, but avoid extremely small or large drawings
- **Speed**: Draw at a natural pace - not too fast or too slow
- **Shift Key**: Hold Shift while drawing to access layout gestures
- **Selection**: Most gestures work on selected shapes, so select shapes first if needed
## Keyboard Shortcut
- **`g`**: Activate the gesture tool
## Troubleshooting
- If a gesture isn't recognized, try drawing it more clearly or at a different size
- Make sure you're using the gesture tool (cursor should change to a cross)
- For layout gestures, remember to hold Shift while drawing
- Some gestures require shapes to be selected first
## Examples
### Deleting Shapes
1. Select the shapes you want to delete
2. Press `g` to activate gesture tool
3. Draw an "X" over the shapes
4. Release - the shapes will be deleted
### Creating a Rectangle
1. Press `g` to activate gesture tool
2. Draw a rectangle outline where you want the shape
3. Release - a rectangle will be created
### Arranging Shapes in a Circle
1. Select the shapes you want to arrange
2. Press `g` to activate gesture tool
3. Hold Shift and draw a circle
4. Release - the shapes will be arranged in a circle
### Rotating Shapes
1. Select the shapes you want to rotate
2. Press `g` to activate gesture tool
3. Draw a pigtail/spiral over the shapes
4. Release - the shapes will rotate 90° counterclockwise

3
README.md Normal file
View File

@ -0,0 +1,3 @@
A website.
Do `npm i` and `npm run dev`

272
docs/WEBCRYPTO_AUTH.md Normal file
View File

@ -0,0 +1,272 @@
# WebCryptoAPI Authentication Implementation
This document describes the complete WebCryptoAPI authentication system implemented in this project.
## Overview
The WebCryptoAPI authentication system provides cryptographic authentication using ECDSA P-256 key pairs, challenge-response authentication, and secure key storage. It integrates with the existing ODD (Open Data Directory) framework while providing a fallback authentication mechanism.
## Architecture
### Core Components
1. **Crypto Module** (`src/lib/auth/crypto.ts`)
- WebCryptoAPI wrapper functions
- Key pair generation (ECDSA P-256)
- Public key export/import
- Data signing and verification
- User credential storage
2. **CryptoAuthService** (`src/lib/auth/cryptoAuthService.ts`)
- High-level authentication service
- Challenge-response authentication
- User registration and login
- Credential verification
3. **Enhanced AuthService** (`src/lib/auth/authService.ts`)
- Integrates crypto authentication with ODD
- Fallback mechanisms
- Session management
4. **UI Components**
- `CryptoLogin.tsx` - Cryptographic authentication UI
- `CryptoTest.tsx` - Test component for verification
## Features
### ✅ Implemented
- **ECDSA P-256 Key Pairs**: Secure cryptographic key generation
- **Challenge-Response Authentication**: Prevents replay attacks
- **Public Key Infrastructure**: Store and verify public keys
- **Browser Support Detection**: Checks for WebCryptoAPI availability
- **Secure Context Validation**: Ensures HTTPS requirement
- **Fallback Authentication**: Works with existing ODD system
- **Modern UI**: Responsive design with dark mode support
- **Comprehensive Testing**: Test component for verification
### 🔧 Technical Details
#### Key Generation
```typescript
const keyPair = await crypto.generateKeyPair();
// Returns CryptoKeyPair with public and private keys
```
#### Public Key Export/Import
```typescript
const publicKeyBase64 = await crypto.exportPublicKey(keyPair.publicKey);
const importedKey = await crypto.importPublicKey(publicKeyBase64);
```
#### Data Signing and Verification
```typescript
const signature = await crypto.signData(privateKey, data);
const isValid = await crypto.verifySignature(publicKey, signature, data);
```
#### Challenge-Response Authentication
```typescript
// Generate challenge
const challenge = `${username}:${timestamp}:${random}`;
// Sign challenge during registration
const signature = await crypto.signData(privateKey, challenge);
// Verify during login
const isValid = await crypto.verifySignature(publicKey, signature, challenge);
```
## Browser Requirements
### Minimum Requirements
- **WebCryptoAPI Support**: `window.crypto.subtle`
- **Secure Context**: HTTPS or localhost
- **Modern Browser**: Chrome 37+, Firefox 34+, Safari 11+, Edge 12+
### Feature Detection
```typescript
const hasWebCrypto = typeof window.crypto !== 'undefined' &&
typeof window.crypto.subtle !== 'undefined';
const isSecure = window.isSecureContext;
```
## Security Considerations
### ✅ Implemented Security Measures
1. **Secure Context Requirement**: Only works over HTTPS
2. **ECDSA P-256**: Industry-standard elliptic curve
3. **Challenge-Response**: Prevents replay attacks
4. **Key Storage**: Public keys stored securely
5. **Input Validation**: Username format validation
6. **Error Handling**: Comprehensive error management
### ⚠️ Security Notes
1. **Private Key Storage**: Currently simplified for demo purposes
- In production, use Web Crypto API's key storage
- Consider hardware security modules (HSM)
- Implement proper key derivation
2. **Session Management**:
- Integrates with existing ODD session system
- Consider implementing JWT tokens
- Add session expiration
3. **Network Security**:
- All crypto operations happen client-side
- No private keys transmitted over network
- Consider adding server-side verification
## Usage
### Basic Authentication Flow
```typescript
import { CryptoAuthService } from './lib/auth/cryptoAuthService';
// Register a new user
const registerResult = await CryptoAuthService.register('username');
if (registerResult.success) {
console.log('User registered successfully');
}
// Login with existing user
const loginResult = await CryptoAuthService.login('username');
if (loginResult.success) {
console.log('User authenticated successfully');
}
```
### Integration with React Context
```typescript
import { useAuth } from './context/AuthContext';
const { login, register } = useAuth();
// The AuthService automatically tries crypto auth first,
// then falls back to ODD authentication
const success = await login('username');
```
### Testing the Implementation
```typescript
import CryptoTest from './components/auth/CryptoTest';
// Render the test component to verify functionality
<CryptoTest />
```
## File Structure
```
src/
├── lib/
│ ├── auth/
│ │ ├── crypto.ts # WebCryptoAPI wrapper
│ │ ├── cryptoAuthService.ts # High-level auth service
│ │ ├── authService.ts # Enhanced auth service
│ │ └── account.ts # User account management
│ └── utils/
│ └── browser.ts # Browser support detection
├── components/
│ └── auth/
│ ├── CryptoLogin.tsx # Crypto auth UI
│ └── CryptoTest.tsx # Test component
└── css/
└── crypto-auth.css # Styles for crypto components
```
## Dependencies
### Required Packages
- `one-webcrypto`: WebCryptoAPI polyfill (^1.0.3)
- `@oddjs/odd`: Open Data Directory framework (^0.37.2)
### Browser APIs Used
- `window.crypto.subtle`: WebCryptoAPI
- `window.localStorage`: Key storage
- `window.isSecureContext`: Security context check
## Testing
### Manual Testing
1. Navigate to the application
2. Use the `CryptoTest` component to run automated tests
3. Verify all test cases pass
4. Test on different browsers and devices
### Test Cases
- [x] Browser support detection
- [x] Secure context validation
- [x] Key pair generation
- [x] Public key export/import
- [x] Data signing and verification
- [x] User registration
- [x] User login
- [x] Credential verification
## Troubleshooting
### Common Issues
1. **"Browser not supported"**
- Ensure you're using a modern browser
- Check if WebCryptoAPI is available
- Verify HTTPS or localhost
2. **"Secure context required"**
- Access the application over HTTPS
- For development, use localhost
3. **"Key generation failed"**
- Check browser console for errors
- Verify WebCryptoAPI permissions
- Try refreshing the page
4. **"Authentication failed"**
- Verify user exists
- Check stored credentials
- Clear browser data and retry
### Debug Mode
Enable debug logging by setting:
```typescript
localStorage.setItem('debug_crypto', 'true');
```
## Future Enhancements
### Planned Improvements
1. **Enhanced Key Storage**: Use Web Crypto API's key storage
2. **Server-Side Verification**: Add server-side signature verification
3. **Multi-Factor Authentication**: Add additional authentication factors
4. **Key Rotation**: Implement automatic key rotation
5. **Hardware Security**: Support for hardware security modules
### Advanced Features
1. **Zero-Knowledge Proofs**: Implement ZKP for enhanced privacy
2. **Threshold Cryptography**: Distributed key management
3. **Post-Quantum Cryptography**: Prepare for quantum threats
4. **Biometric Integration**: Add biometric authentication
## Contributing
When contributing to the WebCryptoAPI authentication system:
1. **Security First**: All changes must maintain security standards
2. **Test Thoroughly**: Run the test suite before submitting
3. **Document Changes**: Update this documentation
4. **Browser Compatibility**: Test on multiple browsers
5. **Performance**: Ensure crypto operations don't block UI
## References
- [WebCryptoAPI Specification](https://www.w3.org/TR/WebCryptoAPI/)
- [ECDSA Algorithm](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm)
- [P-256 Curve](https://en.wikipedia.org/wiki/NIST_Curve_P-256)
- [Challenge-Response Authentication](https://en.wikipedia.org/wiki/Challenge%E2%80%93response_authentication)

63
fix_draw_shapes.js Normal file
View File

@ -0,0 +1,63 @@
import fs from 'fs';
const inputFile = '/home/jeffe/Github/canvas-website/src/shapes/mycofi_room.json';
const outputFile = '/home/jeffe/Github/canvas-website/src/shapes/mycofi_room_fixed.json';
try {
// Read the JSON file
const data = JSON.parse(fs.readFileSync(inputFile, 'utf8'));
let fixedCount = 0;
let removedCount = 0;
// Process all documents
data.documents = data.documents.filter(doc => {
if (doc.state && doc.state.typeName === 'shape' && doc.state.type === 'draw') {
const segments = doc.state.props?.segments;
if (segments) {
// Check each segment for single-point issues
const validSegments = segments.filter(segment => {
if (segment.points && segment.points.length === 1) {
// For single-point segments, we have two options:
// 1. Remove the segment entirely
// 2. Add a second point to make it valid
// Let's remove single-point segments as they're likely incomplete
removedCount++;
return false;
}
return true;
});
if (validSegments.length === 0) {
// If no valid segments remain, remove the entire shape
removedCount++;
return false;
} else {
// Update the segments
doc.state.props.segments = validSegments;
fixedCount++;
}
}
}
return true;
});
// Write the fixed data
fs.writeFileSync(outputFile, JSON.stringify(data, null, 2));
console.log(`Successfully fixed draw shapes:`);
console.log(`- Fixed shapes: ${fixedCount}`);
console.log(`- Removed invalid shapes: ${removedCount}`);
console.log(`- Output saved to: ${outputFile}`);
} catch (error) {
console.error('Error fixing draw shapes:', error.message);
process.exit(1);
}

96
fix_extreme_positions.js Normal file
View File

@ -0,0 +1,96 @@
import fs from 'fs';
const inputFile = '/home/jeffe/Github/canvas-website/src/shapes/mycofi_room.json';
const outputFile = '/home/jeffe/Github/canvas-website/src/shapes/mycofi_room_fixed.json';
try {
// Read the JSON file
const data = JSON.parse(fs.readFileSync(inputFile, 'utf8'));
let fixedCount = 0;
let removedCount = 0;
// Process all documents
data.documents = data.documents.filter(doc => {
if (doc.state && doc.state.typeName === 'shape') {
const state = doc.state;
const x = state.x || 0;
const y = state.y || 0;
// Check for extremely large coordinates that could cause hit testing issues
if (Math.abs(x) > 100000 || Math.abs(y) > 100000 ||
!isFinite(x) || !isFinite(y)) {
console.log(`Fixing shape ${state.id} with extreme position: (${x}, ${y})`);
// Reset to a reasonable position (center of canvas)
state.x = 0;
state.y = 0;
fixedCount++;
}
// Check for extremely large dimensions
if (state.props) {
const w = state.props.w || 0;
const h = state.props.h || 0;
if (w > 100000 || h > 100000 || !isFinite(w) || !isFinite(h)) {
console.log(`Fixing shape ${state.id} with extreme dimensions: ${w}x${h}`);
// Reset to reasonable default dimensions
state.props.w = Math.min(w, 200);
state.props.h = Math.min(h, 200);
fixedCount++;
}
}
// Check for invalid rotation values
if (state.rotation !== undefined && !isFinite(state.rotation)) {
console.log(`Fixing shape ${state.id} with invalid rotation: ${state.rotation}`);
state.rotation = 0;
fixedCount++;
}
// Check for draw shapes with problematic segments
if (state.type === 'draw' && state.props?.segments) {
const validSegments = state.props.segments.filter(segment => {
if (segment.points && segment.points.length === 1) {
// Remove single-point segments as they can cause hit testing issues
return false;
}
return true;
});
if (validSegments.length === 0) {
// If no valid segments remain, remove the entire shape
console.log(`Removing shape ${state.id} with no valid segments`);
removedCount++;
return false;
} else if (validSegments.length !== state.props.segments.length) {
// Update the segments
state.props.segments = validSegments;
fixedCount++;
}
}
}
return true;
});
// Write the fixed data
fs.writeFileSync(outputFile, JSON.stringify(data, null, 2));
console.log(`\\nSuccessfully fixed board data:`);
console.log(`- Fixed shapes: ${fixedCount}`);
console.log(`- Removed invalid shapes: ${removedCount}`);
console.log(`- Output saved to: ${outputFile}`);
} catch (error) {
console.error('Error fixing board data:', error.message);
process.exit(1);
}

44
index.html Normal file
View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<title>Jeff Emmett</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Permissions-Policy" content="midi=*, microphone=*, camera=*, autoplay=*">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap"
rel="stylesheet">
<!-- Social Meta Tags -->
<meta name="description"
content="My research investigates the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta property="og:url" content="https://jeffemmett.com">
<meta property="og:type" content="website">
<meta property="og:title" content="Jeff Emmett">
<meta property="og:description"
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta property="og:image" content="/website-embed.png">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="jeffemmett.com">
<meta property="twitter:url" content="https://jeffemmett.com">
<meta name="twitter:title" content="Jeff Emmett">
<meta name="twitter:description"
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta name="twitter:image" content="/website-embed.png">
<!-- Analytics -->
<script data-goatcounter="https://jeff.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
<meta name="mobile-web-app-capable" content="yes">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/App.tsx"></script>
</body>
</html>

16814
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

80
package.json Normal file
View File

@ -0,0 +1,80 @@
{
"name": "jeffemmett",
"version": "1.0.0",
"description": "Jeff Emmett's personal website",
"type": "module",
"scripts": {
"dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"npm run dev:client\" \"npm run dev:worker\"",
"dev:client": "vite --host --port 5173",
"dev:worker": "wrangler dev --config wrangler.dev.toml --remote --port 5172",
"dev:worker:local": "wrangler dev --config wrangler.dev.toml --port 5172 --ip 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview",
"deploy": "tsc && vite build && vercel deploy --prod && wrangler deploy",
"deploy:worker": "wrangler deploy",
"deploy:worker:dev": "wrangler deploy --config wrangler.dev.toml",
"types": "tsc --noEmit"
},
"keywords": [],
"author": "Jeff Emmett",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.33.1",
"@automerge/automerge": "^3.1.1",
"@automerge/automerge-repo": "^2.2.0",
"@automerge/automerge-repo-react-hooks": "^2.2.0",
"@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0",
"@oddjs/odd": "^0.37.2",
"@tldraw/assets": "^3.15.4",
"@tldraw/sync": "^3.15.4",
"@tldraw/sync-core": "^3.15.4",
"@tldraw/tldraw": "^3.15.4",
"@tldraw/tlschema": "^3.15.4",
"@types/markdown-it": "^14.1.1",
"@types/marked": "^5.0.2",
"@uiw/react-md-editor": "^4.0.5",
"@vercel/analytics": "^1.2.2",
"ai": "^4.1.0",
"cherry-markdown": "^0.8.57",
"cloudflare-workers-unfurl": "^0.0.7",
"gray-matter": "^4.0.3",
"html2canvas": "^1.4.1",
"itty-router": "^5.0.17",
"jotai": "^2.6.0",
"jspdf": "^2.5.2",
"lodash.throttle": "^4.1.1",
"marked": "^15.0.4",
"one-webcrypto": "^1.0.3",
"openai": "^4.79.3",
"rbush": "^4.0.1",
"react": "^18.2.0",
"react-cmdk": "^1.3.9",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.0.2",
"recoil": "^0.7.7",
"tldraw": "^3.15.4",
"vercel": "^39.1.1",
"webcola": "^3.4.0",
"webnative": "^0.36.3"
},
"devDependencies": {
"@cloudflare/types": "^6.0.0",
"@cloudflare/workers-types": "^4.20240821.1",
"@types/lodash.throttle": "^4",
"@types/rbush": "^4.0.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@vitejs/plugin-react": "^4.0.3",
"concurrently": "^9.1.0",
"typescript": "^5.6.3",
"vite": "^6.0.3",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"wrangler": "^4.33.2"
},
"engines": {
"node": ">=18.0.0"
}
}

148
src/App.tsx Normal file
View File

@ -0,0 +1,148 @@
import "tldraw/tldraw.css"
import "@/css/style.css"
import { Default } from "@/routes/Default"
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"
import { Contact } from "@/routes/Contact"
import { Board } from "./routes/Board"
import { Inbox } from "./routes/Inbox"
import { Presentations } from "./routes/Presentations"
import { Resilience } from "./routes/Resilience"
import { inject } from "@vercel/analytics"
import { createRoot } from "react-dom/client"
import { DailyProvider } from "@daily-co/daily-react"
import Daily from "@daily-co/daily-js"
import "tldraw/tldraw.css";
import "@/css/style.css";
import "@/css/auth.css"; // Import auth styles
import "@/css/crypto-auth.css"; // Import crypto auth styles
import "@/css/starred-boards.css"; // Import starred boards styles
import "@/css/user-profile.css"; // Import user profile styles
import { Dashboard } from "./routes/Dashboard";
import { useState, useEffect } from 'react';
// Import React Context providers
import { AuthProvider, useAuth } from './context/AuthContext';
import { FileSystemProvider } from './context/FileSystemContext';
import { NotificationProvider } from './context/NotificationContext';
import NotificationsDisplay from './components/NotificationsDisplay';
// Import auth components
import CryptoLogin from './components/auth/CryptoLogin';
import CryptoDebug from './components/auth/CryptoDebug';
inject();
const callObject = Daily.createCallObject();
/**
* Main App with context providers
*/
const AppWithProviders = () => {
/**
* Optional Auth Route component
* Allows guests to browse, but provides login option
*/
const OptionalAuthRoute = ({ children }: { children: React.ReactNode }) => {
const { session } = useAuth();
const [isInitialized, setIsInitialized] = useState(false);
// Wait for authentication to initialize before rendering
useEffect(() => {
if (!session.loading) {
setIsInitialized(true);
}
}, [session.loading]);
if (!isInitialized) {
return <div className="loading">Loading...</div>;
}
// Always render the content, authentication is optional
return <>{children}</>;
};
/**
* Auth page - renders login/register component (kept for direct access)
*/
const AuthPage = () => {
const { session } = useAuth();
// Redirect to home if already authenticated
if (session.authed) {
return <Navigate to="/" />;
}
return (
<div className="auth-page">
<CryptoLogin onSuccess={() => window.location.href = '/'} />
</div>
);
};
return (
<AuthProvider>
<FileSystemProvider>
<NotificationProvider>
<DailyProvider callObject={callObject}>
<BrowserRouter>
{/* Display notifications */}
<NotificationsDisplay />
<Routes>
{/* Auth routes */}
<Route path="/login" element={<AuthPage />} />
{/* Optional auth routes */}
<Route path="/" element={
<OptionalAuthRoute>
<Default />
</OptionalAuthRoute>
} />
<Route path="/contact" element={
<OptionalAuthRoute>
<Contact />
</OptionalAuthRoute>
} />
<Route path="/board/:slug" element={
<OptionalAuthRoute>
<Board />
</OptionalAuthRoute>
} />
<Route path="/inbox" element={
<OptionalAuthRoute>
<Inbox />
</OptionalAuthRoute>
} />
<Route path="/debug" element={
<OptionalAuthRoute>
<CryptoDebug />
</OptionalAuthRoute>
} />
<Route path="/dashboard" element={
<OptionalAuthRoute>
<Dashboard />
</OptionalAuthRoute>
} />
<Route path="/presentations" element={
<OptionalAuthRoute>
<Presentations />
</OptionalAuthRoute>
} />
<Route path="/presentations/resilience" element={
<OptionalAuthRoute>
<Resilience />
</OptionalAuthRoute>
} />
</Routes>
</BrowserRouter>
</DailyProvider>
</NotificationProvider>
</FileSystemProvider>
</AuthProvider>
);
};
// Initialize the app
createRoot(document.getElementById("root")!).render(<AppWithProviders />);
export default AppWithProviders;

290
src/CmdK.tsx Normal file
View File

@ -0,0 +1,290 @@
import CommandPalette, { filterItems, getItemIndex } from "react-cmdk"
import { Fragment, useEffect, useState } from "react"
import {
Editor,
TLShape,
TLShapeId,
unwrapLabel,
useActions,
useEditor,
useLocalStorageState,
useTranslation,
useValue,
} from "tldraw"
// import { generateText } from "@/utils/llmUtils"
import "@/css/style.css"
function toNearest(n: number, places = 2) {
return Math.round(n * 10 ** places) / 10 ** places
}
interface SimpleShape {
type: string
x: number
y: number
rotation: string
properties: unknown
}
function simplifiedShape(editor: Editor, shape: TLShape): SimpleShape {
const bounds = editor.getShapePageBounds(shape.id)
return {
type: shape.type,
x: toNearest(shape.x),
y: toNearest(shape.y),
rotation: `${toNearest(shape.rotation, 3)} radians`,
properties: {
...shape.props,
w: toNearest(bounds?.width || 0),
h: toNearest(bounds?.height || 0),
},
}
}
export const CmdK = () => {
const editor = useEditor()
const actions = useActions()
const trans = useTranslation()
const [inputRefs, setInputRefs] = useState<Set<string>>(new Set())
const [response, setResponse] = useLocalStorageState("response", "")
const [open, setOpen] = useState<boolean>(false)
const [input, setInput] = useLocalStorageState("input", "")
const [page, setPage] = useLocalStorageState<"search" | "llm">(
"page",
"search",
)
const availableRefs = useValue<Map<string, TLShapeId[]>>(
"avaiable refs",
() => {
const nameToShapeIdMap = new Map<string, TLShapeId[]>(
editor
.getCurrentPageShapes()
.filter((shape) => shape.meta.name)
.map((shape) => [shape.meta.name as string, [shape.id]]),
)
const selected = editor.getSelectedShapeIds()
const inView = editor
.getShapesAtPoint(editor.getViewportPageBounds().center, {
margin: 1200,
})
.map((o) => o.id)
return new Map([
...nameToShapeIdMap,
["selected", selected],
["here", inView],
])
},
[editor],
)
/** Track the shapes we are referencing in the input */
useEffect(() => {
const namesInInput = input
.split(" ")
.filter((name) => name.startsWith("@"))
.map((name) => name.slice(1).match(/^[a-zA-Z0-9]+/)?.[0])
.filter(Boolean)
setInputRefs(new Set(namesInInput as string[]))
}, [input])
/** Handle keyboard shortcuts for Opening and closing the command bar in search/llm mode */
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === " " && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
e.stopPropagation()
setPage("search")
setOpen(true)
}
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
e.stopPropagation()
setPage("llm")
setOpen(true)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [setPage])
const menuItems = filterItems(
[
{
heading: "Actions",
id: "actions",
items: Object.entries(actions).map(([key, action]) => ({
id: key,
children: trans(unwrapLabel(action.label)),
onClick: () => action.onSelect("unknown"),
itemType: "foobar",
})),
},
{
heading: "Other",
id: "other",
items: [
{
id: "llm",
children: "LLM",
icon: "ArrowRightOnRectangleIcon",
closeOnSelect: false,
onClick: () => {
setInput("")
setPage("llm")
},
},
],
},
],
input,
)
type ContextItem =
| { name: string; shape: SimpleShape; shapes?: never }
| { name: string; shape?: never; shapes: SimpleShape[] }
const handlePromptSubmit = () => {
const cleanedPrompt = input.trim()
const context: ContextItem[] = []
for (const name of inputRefs) {
if (!availableRefs.has(name)) continue
const shapes = availableRefs.get(name)?.map((id) => editor.getShape(id))
if (!shapes || shapes.length < 1) continue
if (shapes.length === 1) {
const contextShape: SimpleShape = simplifiedShape(editor, shapes[0]!)
context.push({ name, shape: contextShape })
} else {
const contextShapes: SimpleShape[] = []
for (const shape of shapes) {
contextShapes.push(simplifiedShape(editor, shape!))
}
context.push({ name, shapes: contextShapes })
}
}
const systemPrompt = `You are a helpful assistant. Respond in plaintext.
Context:
${JSON.stringify(context)}
`
setResponse("🤖...")
// generateText(cleanedPrompt, systemPrompt, (partialResponse, _) => {
// setResponse(partialResponse)
// })
}
const ContextPrefix = ({ inputRefs }: { inputRefs: Set<string> }) => {
return inputRefs.size > 0 ? (
<span>Ask with: </span>
) : (
<span style={{ opacity: 0.5 }}>No references</span>
)
}
const LLMView = () => {
return (
<>
<CommandPalette.ListItem
className="references"
index={0}
showType={false}
onClick={handlePromptSubmit}
closeOnSelect={false}
>
<ContextPrefix inputRefs={inputRefs} />
{Array.from(inputRefs).map((name, index, array) => {
const refShapeIds = availableRefs.get(name)
if (!refShapeIds) return null
return (
<Fragment key={name}>
<span
className={refShapeIds ? "reference" : "reference-missing"}
onKeyDown={() => {}}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
if (!refShapeIds) return
editor.setSelectedShapes(refShapeIds)
editor.zoomToSelection({
animation: {
duration: 200,
easing: (t: number) => t * t * (3 - 2 * t),
},
})
}}
>
{name}
</span>
{index < array.length - 1 && (
<span style={{ marginLeft: "0em" }}>,</span>
)}
</Fragment>
)
})}
</CommandPalette.ListItem>
{response && (
<>
<CommandPalette.ListItem
disabled={true}
className="llm-response"
index={1}
showType={false}
>
{response}
</CommandPalette.ListItem>
</>
)}
</>
)
}
const SearchView = () => {
return (
<>
{menuItems.length ? (
menuItems.map((list) => (
<CommandPalette.List key={list.id} heading={list.heading}>
{list.items.map(({ id, ...rest }) => (
<CommandPalette.ListItem
key={id}
index={getItemIndex(menuItems, id)}
{...rest}
/>
))}
</CommandPalette.List>
))
) : (
<CommandPalette.FreeSearchAction label="Search for" />
)}
</>
)
}
return (
<CommandPalette
placeholder={page === "search" ? "Search..." : "Ask..."}
onChangeSearch={setInput}
onChangeOpen={setOpen}
search={input}
isOpen={open}
page={page}
>
<CommandPalette.Page id="search">
<SearchView />
</CommandPalette.Page>
<CommandPalette.Page id="llm">
<LLMView />
</CommandPalette.Page>
</CommandPalette>
)
}

497
src/GestureTool.ts Normal file
View File

@ -0,0 +1,497 @@
import { DEFAULT_GESTURES, ALT_GESTURES } from "@/default_gestures"
import { DollarRecognizer } from "@/gestures"
import {
StateNode,
TLDefaultSizeStyle,
TLDrawShape,
TLDrawShapeSegment,
TLEventHandlers,
TLHighlightShape,
TLPointerEventInfo,
TLShapePartial,
TLTextShape,
Vec,
createShapeId,
uniqueId,
} from "tldraw"
const STROKE_WIDTH = 10
const SHOW_LABELS = true
const PRESSURE = 0.5
export class GestureTool extends StateNode {
static override id = "gesture"
static override initial = "idle"
static override children = () => [Idle, Drawing]
static recognizer = new DollarRecognizer(DEFAULT_GESTURES)
static recognizerAlt = new DollarRecognizer(ALT_GESTURES)
override shapeType = "draw"
override onExit = () => {
const drawingState = this.children!.drawing as Drawing
drawingState.initialShape = undefined
}
}
export class Idle extends StateNode {
static override id = "idle"
tooltipElement?: HTMLDivElement
tooltipTimeout?: NodeJS.Timeout
mouseMoveHandler?: (e: MouseEvent) => void
override onPointerDown: TLEventHandlers["onPointerDown"] = (info) => {
this.parent.transition("drawing", info)
}
override onEnter = () => {
this.editor.setCursor({ type: "cross", rotation: 0 })
// Create tooltip element
this.tooltipElement = document.createElement('div')
this.tooltipElement.style.cssText = `
position: fixed;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px 16px;
border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.4;
white-space: pre-line;
z-index: 10000;
pointer-events: none;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
`
// Set tooltip content
this.tooltipElement.innerHTML = `
<strong>Gesture Tool Active</strong><br><br>
<strong>Basic Gestures:</strong><br>
X, Rectangle, Circle, Check<br>
Caret, V, Delete, Pigtail<br><br>
<strong>Shift + Draw:</strong><br>
Circle Layout, Triangle Layout<br><br>
Press 'g' again or select another tool to exit
`
// Add tooltip to DOM
document.body.appendChild(this.tooltipElement)
// Function to update tooltip position
this.mouseMoveHandler = (e: MouseEvent) => {
if (this.tooltipElement) {
const x = e.clientX + 20
const y = e.clientY - 20
// Keep tooltip within viewport bounds
const rect = this.tooltipElement.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let finalX = x
let finalY = y
// Adjust if tooltip would go off the right edge
if (x + rect.width > viewportWidth) {
finalX = e.clientX - rect.width - 20
}
// Adjust if tooltip would go off the bottom edge
if (y + rect.height > viewportHeight) {
finalY = e.clientY - rect.height - 20
}
// Ensure tooltip doesn't go off the top or left
finalX = Math.max(10, finalX)
finalY = Math.max(10, finalY)
this.tooltipElement.style.left = `${finalX}px`
this.tooltipElement.style.top = `${finalY}px`
}
}
// Add mouse move listener
document.addEventListener('mousemove', this.mouseMoveHandler)
// Set initial position
if (this.mouseMoveHandler) {
this.mouseMoveHandler({ clientX: 100, clientY: 100 } as MouseEvent)
}
// Remove the tooltip after 5 seconds
this.tooltipTimeout = setTimeout(() => {
this.cleanupTooltip()
}, 5000)
}
override onCancel = () => {
this.editor.setCurrentTool("select")
}
override onExit = () => {
this.cleanupTooltip()
}
private cleanupTooltip = () => {
// Clear timeout
if (this.tooltipTimeout) {
clearTimeout(this.tooltipTimeout)
this.tooltipTimeout = undefined
}
// Remove mouse move listener
if (this.mouseMoveHandler) {
document.removeEventListener('mousemove', this.mouseMoveHandler)
this.mouseMoveHandler = undefined
}
// Remove tooltip element
if (this.tooltipElement) {
document.body.removeChild(this.tooltipElement)
this.tooltipElement = undefined
}
}
}
type DrawableShape = TLDrawShape | TLHighlightShape
export class Drawing extends StateNode {
static override id = "drawing"
info = {} as TLPointerEventInfo
initialShape?: DrawableShape
override shapeType =
this.parent.id === "highlight" ? ("highlight" as const) : ("draw" as const)
util = this.editor.getShapeUtil(this.shapeType)
isPen = false
isPenOrStylus = false
didJustShiftClickToExtendPreviousShapeLine = false
pagePointWhereCurrentSegmentChanged = {} as Vec
pagePointWhereNextSegmentChanged = null as Vec | null
lastRecordedPoint = {} as Vec
mergeNextPoint = false
currentLineLength = 0
canDraw = false
markId = null as null | string
override onEnter = (info: TLPointerEventInfo) => {
this.markId = null
this.info = info
this.canDraw = !this.editor.getIsMenuOpen()
this.lastRecordedPoint = this.editor.inputs.currentPagePoint.clone()
if (this.canDraw) {
this.startShape()
}
}
onGestureEnd = () => {
const shape = this.editor.getShape(this.initialShape?.id!) as TLDrawShape
const ps = shape.props.segments[0].points.map((s) => ({ x: s.x, y: s.y }))
const gesture = this.editor.inputs.shiftKey ? GestureTool.recognizerAlt.recognize(ps) : GestureTool.recognizer.recognize(ps)
const score_pass = gesture.score > 0.2
const score_confident = gesture.score > 0.65
let score_color: "green" | "red" | "yellow" = "green"
if (!score_pass) {
score_color = "red"
} else if (!score_confident) {
score_color = "yellow"
}
if (score_pass) {
gesture.onComplete?.(this.editor, shape)
}
let opacity = 1
const labelShape: TLShapePartial<TLTextShape> = {
id: createShapeId(),
type: "text",
x: this.editor.inputs.currentPagePoint.x + 20,
y: this.editor.inputs.currentPagePoint.y,
props: {
size: "xl",
text: gesture.name,
color: score_color,
} as any,
}
if (SHOW_LABELS) {
this.editor.createShape(labelShape)
}
const intervalId = setInterval(() => {
if (opacity > 0) {
this.editor.updateShape({
...shape,
opacity: opacity,
props: {
...shape.props,
color: score_color,
},
})
this.editor.updateShape({
...labelShape,
opacity: opacity,
props: {
...labelShape.props,
color: score_color,
},
})
opacity = Math.max(0, opacity - 0.025)
} else {
clearInterval(intervalId)
this.editor.deleteShape(shape.id)
if (SHOW_LABELS) {
this.editor.deleteShape(labelShape.id)
}
}
}, 20)
}
override onPointerMove: TLEventHandlers["onPointerMove"] = () => {
const { inputs } = this.editor
if (this.isPen && !inputs.isPen) {
// The user made a palm gesture before starting a pen gesture;
// ideally we'd start the new shape here but we could also just bail
// as the next interaction will work correctly
if (this.markId) {
this.editor.bailToMark(this.markId)
this.startShape()
return
}
} else {
// If we came in from a menu but have no started dragging...
if (!this.canDraw && inputs.isDragging) {
this.startShape()
this.canDraw = true // bad name
}
}
if (this.canDraw) {
if (this.isPenOrStylus) {
// Don't update the shape if we haven't moved far enough from the last time we recorded a point
if (
Vec.Dist(inputs.currentPagePoint, this.lastRecordedPoint) >=
1 / this.editor.getZoomLevel()
) {
this.lastRecordedPoint = inputs.currentPagePoint.clone()
this.mergeNextPoint = false
} else {
this.mergeNextPoint = true
}
} else {
this.mergeNextPoint = false
}
this.updateDrawingShape()
}
}
override onExit? = () => {
this.onGestureEnd()
this.editor.snaps.clearIndicators()
this.pagePointWhereCurrentSegmentChanged =
this.editor.inputs.currentPagePoint.clone()
}
canClose() {
return this.shapeType !== "highlight"
}
getIsClosed(segments: TLDrawShapeSegment[]) {
if (!this.canClose()) return false
const strokeWidth = STROKE_WIDTH
const firstPoint = segments[0].points[0]
const lastSegment = segments[segments.length - 1]
const lastPoint = lastSegment.points[lastSegment.points.length - 1]
return (
firstPoint !== lastPoint &&
this.currentLineLength > strokeWidth * 4 &&
Vec.DistMin(firstPoint, lastPoint, strokeWidth * 2)
)
}
private startShape() {
const {
inputs: { originPagePoint },
} = this.editor
this.markId = this.editor.markHistoryStoppingPoint()
this.didJustShiftClickToExtendPreviousShapeLine = false
this.lastRecordedPoint = originPagePoint.clone()
this.pagePointWhereCurrentSegmentChanged = originPagePoint.clone()
const id = createShapeId()
this.editor.createShapes<DrawableShape>([
{
id,
type: this.shapeType,
x: originPagePoint.x,
y: originPagePoint.y,
opacity: 0.5,
props: {
isPen: this.isPenOrStylus,
segments: [
{
type: "free",
points: [
{
x: 0,
y: 0,
z: PRESSURE,
},
],
},
],
},
},
])
this.currentLineLength = 0
this.initialShape = this.editor.getShape<DrawableShape>(id)
}
private updateDrawingShape() {
const { initialShape } = this
const { inputs } = this.editor
if (!initialShape) return
const {
id,
} = initialShape
const shape = this.editor.getShape<DrawableShape>(id)!
if (!shape) return
const { segments } = shape.props
const { x, y, z } = this.editor
.getPointInShapeSpace(shape, inputs.currentPagePoint)
.toFixed()
const newPoint = {
x,
y,
z: this.isPenOrStylus ? +(z! * 1.25).toFixed(2) : 0.5,
}
const newSegments = segments.slice()
const newSegment = newSegments[newSegments.length - 1]
const newPoints = [...newSegment.points]
if (newPoints.length && this.mergeNextPoint) {
const { z } = newPoints[newPoints.length - 1]
newPoints[newPoints.length - 1] = {
x: newPoint.x,
y: newPoint.y,
z: z ? Math.max(z, newPoint.z) : newPoint.z,
}
} else {
this.currentLineLength += Vec.Dist(
newPoints[newPoints.length - 1],
newPoint,
)
newPoints.push(newPoint)
}
newSegments[newSegments.length - 1] = {
...newSegment,
points: newPoints,
}
if (this.currentLineLength < STROKE_WIDTH * 4) {
this.currentLineLength = this.getLineLength(newSegments)
}
const shapePartial: TLShapePartial<DrawableShape> = {
id,
type: this.shapeType,
props: {
segments: newSegments,
},
}
if (this.canClose()) {
; (shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed =
this.getIsClosed(newSegments)
}
this.editor.updateShapes([shapePartial])
}
private getLineLength(segments: TLDrawShapeSegment[]) {
let length = 0
for (const segment of segments) {
for (let i = 0; i < segment.points.length - 1; i++) {
const A = segment.points[i]
const B = segment.points[i + 1]
length += Vec.Dist2(B, A)
}
}
return Math.sqrt(length)
}
override onPointerUp: TLEventHandlers["onPointerUp"] = () => {
this.complete()
}
override onCancel: TLEventHandlers["onCancel"] = () => {
this.cancel()
}
override onComplete: TLEventHandlers["onComplete"] = () => {
this.complete()
}
override onInterrupt: TLEventHandlers["onInterrupt"] = () => {
if (this.editor.inputs.isDragging) {
return
}
if (this.markId) {
this.editor.bailToMark(this.markId)
}
this.cancel()
}
complete() {
if (!this.canDraw) {
this.cancel()
return
}
const { initialShape } = this
if (!initialShape) return
this.editor.updateShapes([
{
id: initialShape.id,
type: initialShape.type,
props: { isComplete: true },
},
])
this.parent.transition("idle")
}
cancel() {
this.parent.transition("idle", this.info)
}
}

View File

@ -0,0 +1,107 @@
import { Editor, TLShape, TLShapeId } from '@tldraw/tldraw';
/**
* A PoC abstract collections class for @tldraw.
*/
export abstract class BaseCollection {
/** A unique identifier for the collection. */
abstract id: string;
/** A map containing the shapes that belong to this collection, keyed by their IDs. */
protected shapes: Map<TLShapeId, TLShape> = new Map();
/** A reference to the \@tldraw Editor instance. */
protected editor: Editor;
/** A set of listeners to be notified when the collection changes. */
private listeners = new Set<() => void>();
// TODO: Maybe pass callback to replace updateShape so only CollectionProvider can call it
public constructor(editor: Editor) {
this.editor = editor;
}
/**
* Called when shapes are added to the collection.
* @param shapes The shapes being added to the collection.
*/
protected onAdd(_shapes: TLShape[]): void { }
/**
* Called when shapes are removed from the collection.
* @param shapes The shapes being removed from the collection.
*/
protected onRemove(_shapes: TLShape[]) { }
/**
* Called when the membership of the collection changes (i.e., when shapes are added or removed).
*/
protected onMembershipChange() { }
/**
* Called when the properties of a shape belonging to the collection change.
* @param prev The previous version of the shape before the change.
* @param next The updated version of the shape after the change.
*/
protected onShapeChange(_prev: TLShape, _next: TLShape) { }
/**
* Adds the specified shapes to the collection.
* @param shapes The shapes to add to the collection.
*/
public add(shapes: TLShape[]) {
shapes.forEach(shape => {
this.shapes.set(shape.id, shape)
});
this.onAdd(shapes);
this.onMembershipChange();
this.notifyListeners();
}
/**
* Removes the specified shapes from the collection.
* @param shapes The shapes to remove from the collection.
*/
public remove(shapes: TLShape[]) {
shapes.forEach(shape => {
this.shapes.delete(shape.id);
});
this.onRemove(shapes);
this.onMembershipChange();
this.notifyListeners();
}
/**
* Clears all shapes from the collection.
*/
public clear() {
this.remove([...this.shapes.values()])
}
/**
* Returns the map of shapes in the collection.
* @returns The map of shapes in the collection, keyed by their IDs.
*/
public getShapes(): Map<TLShapeId, TLShape> {
return this.shapes;
}
public get size(): number {
return this.shapes.size;
}
public _onShapeChange(prev: TLShape, next: TLShape) {
this.shapes.set(next.id, next)
this.onShapeChange(prev, next)
this.notifyListeners();
}
private notifyListeners() {
for (const listener of this.listeners) {
listener();
}
}
public subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}

View File

@ -0,0 +1,111 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { TLShape, Editor } from '@tldraw/tldraw';
import { BaseCollection } from './BaseCollection';
interface CollectionContextValue {
get: (id: string) => BaseCollection | undefined;
}
type Collection = (new (editor: Editor) => BaseCollection)
interface CollectionContextWrapperProps {
editor: Editor | null;
collections: Collection[];
children: React.ReactNode;
}
const CollectionContext = createContext<CollectionContextValue | undefined>(undefined);
export const CollectionContextWrapper: React.FC<CollectionContextWrapperProps> = ({
editor,
collections: collectionClasses,
children
}) => {
const [collections, setCollections] = useState<Map<string, BaseCollection> | null>(null);
// Handle shape property changes
const handleShapeChange = (prev: TLShape, next: TLShape) => {
if (!collections) return;
for (const collection of collections.values()) {
if (collection.getShapes().has(next.id)) {
collection._onShapeChange(prev, next);
}
}
};
// Handle shape deletions
const handleShapeDelete = (shape: TLShape) => {
if (!collections) return;
for (const collection of collections.values()) {
collection.remove([shape]);
}
};
useEffect(() => {
if (editor) {
const initializedCollections = new Map<string, BaseCollection>();
for (const ColClass of collectionClasses) {
const instance = new ColClass(editor);
initializedCollections.set(instance.id, instance);
}
setCollections(initializedCollections);
}
}, [editor, collectionClasses]);
// Subscribe to shape changes in the editor
useEffect(() => {
if (editor && collections) {
editor.sideEffects.registerAfterChangeHandler('shape', (prev, next) => {
handleShapeChange(prev, next);
});
}
}, [editor, collections]);
// Subscribe to shape deletions in the editor
useEffect(() => {
if (editor && collections) {
editor.sideEffects.registerAfterDeleteHandler('shape', (prev) => {
handleShapeDelete(prev);
});
}
}, [editor, collections]);
const value = useMemo(() => ({
get: (id: string) => collections?.get(id),
}), [collections]);
return (
<CollectionContext.Provider value={value}>
{children}
</CollectionContext.Provider>
);
};
// Hook to use collection context within the wrapper
export const useCollectionContext = <T extends BaseCollection = BaseCollection>(
collectionId: string
): { collection: T | null; size: number } => {
const context = useContext(CollectionContext);
if (!context) {
return { collection: null, size: 0 };
}
const collection = context.get(collectionId);
if (!collection) {
return { collection: null, size: 0 };
}
const [size, setSize] = useState<number>(collection.size);
useEffect(() => {
const unsubscribe = collection.subscribe(() => {
setSize(collection.size);
});
setSize(collection.size);
return unsubscribe;
}, [collection]);
return { collection: collection as T, size };
};

View File

@ -0,0 +1,82 @@
import React, { createContext, useEffect, useMemo, useState } from 'react';
import { TLShape, TLRecord, Editor, useEditor } from '@tldraw/tldraw';
import { BaseCollection } from './BaseCollection';
interface CollectionContextValue {
get: (id: string) => BaseCollection | undefined;
}
type Collection = (new (editor: Editor) => BaseCollection)
interface CollectionProviderProps {
editor: Editor | null;
collections: Collection[];
children: React.ReactNode;
}
const CollectionContext = createContext<CollectionContextValue | undefined>(undefined);
const CollectionProvider: React.FC<CollectionProviderProps> = ({ editor, collections: collectionClasses, children }) => {
const [collections, setCollections] = useState<Map<string, BaseCollection> | null>(null);
// Handle shape property changes
const handleShapeChange = (prev: TLShape, next: TLShape) => {
if (!collections) return; // Ensure collections is not null
for (const collection of collections.values()) {
if (collection.getShapes().has(next.id)) {
collection._onShapeChange(prev, next);
}
}
};
// Handle shape deletions
const handleShapeDelete = (shape: TLShape) => {
if (!collections) return; // Ensure collections is not null
for (const collection of collections.values()) {
collection.remove([shape]);
}
};
useEffect(() => {
if (editor) {
const initializedCollections = new Map<string, BaseCollection>();
for (const ColClass of collectionClasses) {
const instance = new ColClass(editor);
initializedCollections.set(instance.id, instance);
}
setCollections(initializedCollections);
}
}, [editor, collectionClasses]);
// Subscribe to shape changes in the editor
useEffect(() => {
if (editor && collections) {
editor.sideEffects.registerAfterChangeHandler('shape', (prev, next) => {
handleShapeChange(prev, next);
});
}
}, [editor, collections]);
// Subscribe to shape deletions in the editor
useEffect(() => {
if (editor && collections) {
editor.sideEffects.registerAfterDeleteHandler('shape', (prev) => {
handleShapeDelete(prev);
});
}
}, [editor, collections]);
const value = useMemo(() => ({
get: (id: string) => collections?.get(id),
}), [collections]);
return (
<CollectionContext.Provider value={value}>
{collections ? children : null}
</CollectionContext.Provider>
);
};
export { CollectionContext, CollectionProvider, type Collection };

View File

@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { Editor, TLShape } from '@tldraw/tldraw';
import { BaseCollection } from './BaseCollection';
type Collection = (new (editor: Editor) => BaseCollection)
class GlobalCollectionManager {
private static instance: GlobalCollectionManager;
private collections: Map<string, BaseCollection> = new Map();
private editor: Editor | null = null;
private listeners: Set<() => void> = new Set();
static getInstance(): GlobalCollectionManager {
if (!GlobalCollectionManager.instance) {
GlobalCollectionManager.instance = new GlobalCollectionManager();
}
return GlobalCollectionManager.instance;
}
initialize(editor: Editor, collectionClasses: Collection[]) {
this.editor = editor;
this.collections.clear();
for (const ColClass of collectionClasses) {
const instance = new ColClass(editor);
this.collections.set(instance.id, instance);
}
// Subscribe to shape changes
editor.sideEffects.registerAfterChangeHandler('shape', (prev, next) => {
this.handleShapeChange(prev, next);
});
// Subscribe to shape deletions
editor.sideEffects.registerAfterDeleteHandler('shape', (prev) => {
this.handleShapeDelete(prev);
});
this.notifyListeners();
}
private handleShapeChange(prev: TLShape, next: TLShape) {
for (const collection of this.collections.values()) {
if (collection.getShapes().has(next.id)) {
collection._onShapeChange(prev, next);
}
}
}
private handleShapeDelete(shape: TLShape) {
for (const collection of this.collections.values()) {
collection.remove([shape]);
}
}
getCollection(id: string): BaseCollection | undefined {
return this.collections.get(id);
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private notifyListeners() {
this.listeners.forEach(listener => listener());
}
}
// Hook to use the global collection manager
export const useGlobalCollection = (collectionId: string) => {
const [collection, setCollection] = useState<BaseCollection | null>(null);
const [size, setSize] = useState<number>(0);
useEffect(() => {
const manager = GlobalCollectionManager.getInstance();
const unsubscribe = manager.subscribe(() => {
const newCollection = manager.getCollection(collectionId);
setCollection(newCollection || null);
setSize(newCollection?.size || 0);
});
// Initial setup
const initialCollection = manager.getCollection(collectionId);
setCollection(initialCollection || null);
setSize(initialCollection?.size || 0);
return unsubscribe;
}, [collectionId]);
useEffect(() => {
if (collection) {
const unsubscribe = collection.subscribe(() => {
setSize(collection.size);
});
return unsubscribe;
}
}, [collection]);
return { collection, size };
};
// Function to initialize the global collection manager
export const initializeGlobalCollections = (editor: Editor, collectionClasses: Collection[]) => {
const manager = GlobalCollectionManager.getInstance();
manager.initialize(editor, collectionClasses);
};

152
src/collections/README.md Normal file
View File

@ -0,0 +1,152 @@
# Collections System
This directory contains a proof-of-concept collections system for @tldraw that allows you to group and track shapes with custom logic.
## Overview
The collections system provides a way to:
- Group shapes together with custom logic
- React to shape additions, removals, and changes
- Subscribe to collection changes in React components
- Maintain collections across shape modifications
## Files
- `BaseCollection.ts` - Abstract base class for all collections
- `CollectionProvider.tsx` - React context provider for collections
- `useCollection.ts` - React hook for accessing collections
- `ExampleCollection.ts` - Example collection implementation
- `ExampleCollectionComponent.tsx` - Example React component using collections
- `index.ts` - Exports all collection-related modules
## Usage
### 1. Create a Collection
Extend `BaseCollection` to create your own collection:
```typescript
import { BaseCollection } from '@/collections';
import { TLShape } from '@tldraw/tldraw';
export class MyCollection extends BaseCollection {
id = 'my-collection';
protected onAdd(shapes: TLShape[]): void {
console.log(`Added ${shapes.length} shapes to my collection`);
// Add your custom logic here
}
protected onRemove(shapes: TLShape[]): void {
console.log(`Removed ${shapes.length} shapes from my collection`);
// Add your custom logic here
}
protected onShapeChange(prev: TLShape, next: TLShape): void {
console.log('Shape changed in my collection:', { prev, next });
// Add your custom logic here
}
protected onMembershipChange(): void {
console.log(`My collection membership changed. Total shapes: ${this.size}`);
// Add your custom logic here
}
}
```
### 2. Set up the CollectionProvider
Wrap your Tldraw component with the CollectionProvider:
```typescript
import { CollectionProvider } from '@/collections';
function MyComponent() {
const [editor, setEditor] = useState<Editor | null>(null);
return (
<div>
{editor && (
<CollectionProvider editor={editor} collections={[MyCollection]}>
<Tldraw
onMount={(editor) => setEditor(editor)}
// ... other props
/>
</CollectionProvider>
)}
</div>
);
}
```
### 3. Use Collections in React Components
Use the `useCollection` hook to access collections:
```typescript
import { useCollection } from '@/collections';
function MyComponent() {
const { collection, size } = useCollection<MyCollection>('my-collection');
const handleAddShapes = () => {
const selectedShapes = collection.editor.getSelectedShapes();
if (selectedShapes.length > 0) {
collection.add(selectedShapes);
}
};
return (
<div>
<p>Collection size: {size}</p>
<button onClick={handleAddShapes}>Add Selected Shapes</button>
</div>
);
}
```
## API Reference
### BaseCollection
#### Methods
- `add(shapes: TLShape[])` - Add shapes to the collection
- `remove(shapes: TLShape[])` - Remove shapes from the collection
- `clear()` - Remove all shapes from the collection
- `getShapes(): Map<TLShapeId, TLShape>` - Get all shapes in the collection
- `subscribe(listener: () => void): () => void` - Subscribe to collection changes
#### Properties
- `size: number` - Number of shapes in the collection
- `editor: Editor` - Reference to the tldraw editor
#### Protected Methods (Override these)
- `onAdd(shapes: TLShape[])` - Called when shapes are added
- `onRemove(shapes: TLShape[])` - Called when shapes are removed
- `onShapeChange(prev: TLShape, next: TLShape)` - Called when a shape changes
- `onMembershipChange()` - Called when collection membership changes
### useCollection Hook
```typescript
const { collection, size } = useCollection<T extends BaseCollection>(collectionId: string)
```
Returns:
- `collection: T` - The collection instance
- `size: number` - Current number of shapes in the collection
## Example
See `ExampleCollection.ts` and `ExampleCollectionComponent.tsx` for a complete working example that demonstrates:
- Creating a custom collection
- Setting up the CollectionProvider
- Using the useCollection hook
- Adding/removing shapes from collections
- Reacting to collection changes
The example is integrated into the Board component and provides a UI for testing the collection functionality.

5
src/collections/index.ts Normal file
View File

@ -0,0 +1,5 @@
export * from './BaseCollection';
export * from './CollectionProvider';
export * from './CollectionContextWrapper';
export * from './GlobalCollectionManager';
export * from './useCollection';

View File

@ -0,0 +1,32 @@
import { useContext, useEffect, useState } from "react";
import { CollectionContext } from "./CollectionProvider";
import { BaseCollection } from "./BaseCollection";
export const useCollection = <T extends BaseCollection = BaseCollection>(collectionId: string): { collection: T | null; size: number } => {
const context = useContext(CollectionContext);
if (!context) {
return { collection: null, size: 0 };
}
const collection = context.get(collectionId);
if (!collection) {
return { collection: null, size: 0 };
}
const [size, setSize] = useState<number>(collection.size);
useEffect(() => {
// Subscribe to collection changes
const unsubscribe = collection.subscribe(() => {
setSize(collection.size);
});
// Set initial size
setSize(collection.size);
return unsubscribe; // Cleanup on unmount
}, [collection]);
return { collection: collection as T, size };
};

View File

@ -0,0 +1,105 @@
import React, { useEffect, useState } from 'react';
import { useNotifications, Notification } from '../context/NotificationContext';
/**
* Component to display a single notification
*/
const NotificationItem: React.FC<{
notification: Notification;
onClose: (id: string) => void;
}> = ({ notification, onClose }) => {
const [isExiting, setIsExiting] = useState(false);
const exitDuration = 300; // ms for exit animation
// Set up automatic dismissal based on notification timeout
useEffect(() => {
if (notification.timeout > 0) {
const timer = setTimeout(() => {
setIsExiting(true);
// Wait for exit animation before removing
setTimeout(() => {
onClose(notification.id);
}, exitDuration);
}, notification.timeout);
return () => clearTimeout(timer);
}
}, [notification, onClose]);
// Handle manual close
const handleClose = () => {
setIsExiting(true);
// Wait for exit animation before removing
setTimeout(() => {
onClose(notification.id);
}, exitDuration);
};
// Determine icon based on notification type
const getIcon = () => {
switch (notification.type) {
case 'success':
return '✓';
case 'error':
return '✕';
case 'warning':
return '⚠';
case 'info':
default:
return '';
}
};
return (
<div
className={`notification ${notification.type} ${isExiting ? 'exiting' : ''}`}
style={{
animationDuration: `${exitDuration}ms`,
}}
>
<div className="notification-icon">
{getIcon()}
</div>
<div className="notification-content">
{notification.msg}
</div>
<button
className="notification-close"
onClick={handleClose}
aria-label="Close notification"
>
×
</button>
</div>
);
};
/**
* Component that displays all active notifications
*/
const NotificationsDisplay: React.FC = () => {
const { notifications, removeNotification } = useNotifications();
// Don't render anything if there are no notifications
if (notifications.length === 0) {
return null;
}
return (
<div className="notifications-container">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onClose={removeNotification}
/>
))}
</div>
);
};
export default NotificationsDisplay;

View File

@ -0,0 +1,121 @@
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useNotifications } from '../context/NotificationContext';
import { starBoard, unstarBoard, isBoardStarred } from '../lib/starredBoards';
interface StarBoardButtonProps {
className?: string;
}
const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) => {
const { slug } = useParams<{ slug: string }>();
const { session } = useAuth();
const { addNotification } = useNotifications();
const [isStarred, setIsStarred] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [showPopup, setShowPopup] = useState(false);
const [popupMessage, setPopupMessage] = useState('');
const [popupType, setPopupType] = useState<'success' | 'error' | 'info'>('success');
// Check if board is starred on mount and when session changes
useEffect(() => {
if (session.authed && session.username && slug) {
const starred = isBoardStarred(session.username, slug);
setIsStarred(starred);
} else {
setIsStarred(false);
}
}, [session.authed, session.username, slug]);
const showPopupMessage = (message: string, type: 'success' | 'error' | 'info') => {
setPopupMessage(message);
setPopupType(type);
setShowPopup(true);
// Auto-hide after 2 seconds
setTimeout(() => {
setShowPopup(false);
}, 2000);
};
const handleStarToggle = async () => {
if (!session.authed || !session.username || !slug) {
addNotification('Please log in to star boards', 'warning');
return;
}
setIsLoading(true);
try {
if (isStarred) {
// Unstar the board
const success = unstarBoard(session.username, slug);
if (success) {
setIsStarred(false);
showPopupMessage('Board removed from starred boards', 'success');
} else {
showPopupMessage('Failed to remove board from starred boards', 'error');
}
} else {
// Star the board
const success = starBoard(session.username, slug, slug);
if (success) {
setIsStarred(true);
showPopupMessage('Board added to starred boards', 'success');
} else {
showPopupMessage('Board is already starred', 'info');
}
}
} catch (error) {
console.error('Error toggling star:', error);
showPopupMessage('Failed to update starred boards', 'error');
} finally {
setIsLoading(false);
}
};
// Don't show the button if user is not authenticated
if (!session.authed) {
return null;
}
return (
<div style={{ position: 'relative' }}>
<button
onClick={handleStarToggle}
disabled={isLoading}
className={`star-board-button ${className} ${isStarred ? 'starred' : ''}`}
title={isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
>
{isLoading ? (
<span className="loading-spinner"></span>
) : isStarred ? (
<span className="star-icon starred"></span>
) : (
<span className="star-icon"></span>
)}
</button>
{/* Custom popup notification */}
{showPopup && (
<div
className={`star-popup star-popup-${popupType}`}
style={{
position: 'absolute',
bottom: '40px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 100001,
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
{popupMessage}
</div>
)}
</div>
);
};
export default StarBoardButton;

View File

@ -0,0 +1,265 @@
import React, { useState } from 'react';
import { CryptoAuthService } from '../../lib/auth/cryptoAuthService';
import * as crypto from '../../lib/auth/crypto';
const CryptoDebug: React.FC = () => {
const [testResults, setTestResults] = useState<string[]>([]);
const [testUsername, setTestUsername] = useState('testuser123');
const [isRunning, setIsRunning] = useState(false);
const addResult = (message: string) => {
setTestResults(prev => [...prev, `${new Date().toLocaleTimeString()}: ${message}`]);
};
const runCryptoTest = async () => {
setIsRunning(true);
setTestResults([]);
try {
addResult('Starting cryptographic authentication test...');
// Test 1: Key Generation
addResult('Testing key pair generation...');
const keyPair = await crypto.generateKeyPair();
if (keyPair) {
addResult('✓ Key pair generated successfully');
} else {
addResult('❌ Key pair generation failed');
return;
}
// Test 2: Public Key Export
addResult('Testing public key export...');
const publicKeyBase64 = await crypto.exportPublicKey(keyPair.publicKey);
if (publicKeyBase64) {
addResult('✓ Public key exported successfully');
} else {
addResult('❌ Public key export failed');
return;
}
// Test 3: Public Key Import
addResult('Testing public key import...');
const importedPublicKey = await crypto.importPublicKey(publicKeyBase64);
if (importedPublicKey) {
addResult('✓ Public key imported successfully');
} else {
addResult('❌ Public key import failed');
return;
}
// Test 4: Data Signing
addResult('Testing data signing...');
const testData = 'Hello, WebCryptoAPI!';
const signature = await crypto.signData(keyPair.privateKey, testData);
if (signature) {
addResult('✓ Data signed successfully');
} else {
addResult('❌ Data signing failed');
return;
}
// Test 5: Signature Verification
addResult('Testing signature verification...');
const isValid = await crypto.verifySignature(importedPublicKey, signature, testData);
if (isValid) {
addResult('✓ Signature verified successfully');
} else {
addResult('❌ Signature verification failed');
return;
}
// Test 6: User Registration
addResult(`Testing user registration for: ${testUsername}`);
const registerResult = await CryptoAuthService.register(testUsername);
if (registerResult.success) {
addResult('✓ User registration successful');
} else {
addResult(`❌ User registration failed: ${registerResult.error}`);
return;
}
// Test 7: User Login
addResult(`Testing user login for: ${testUsername}`);
const loginResult = await CryptoAuthService.login(testUsername);
if (loginResult.success) {
addResult('✓ User login successful');
} else {
addResult(`❌ User login failed: ${loginResult.error}`);
return;
}
// Test 8: Verify stored data integrity
addResult('Testing stored data integrity...');
const storedData = localStorage.getItem(`${testUsername}_authData`);
if (storedData) {
try {
const parsed = JSON.parse(storedData);
addResult(` - Challenge length: ${parsed.challenge?.length || 0}`);
addResult(` - Signature length: ${parsed.signature?.length || 0}`);
addResult(` - Timestamp: ${parsed.timestamp || 'missing'}`);
} catch (e) {
addResult(` - Data parse error: ${e}`);
}
} else {
addResult(' - No stored auth data found');
}
addResult('🎉 All cryptographic tests passed!');
} catch (error) {
addResult(`❌ Test error: ${error}`);
} finally {
setIsRunning(false);
}
};
const clearResults = () => {
setTestResults([]);
};
const checkStoredUsers = () => {
const users = crypto.getRegisteredUsers();
addResult(`Stored users: ${JSON.stringify(users)}`);
users.forEach(user => {
const publicKey = crypto.getPublicKey(user);
const authData = localStorage.getItem(`${user}_authData`);
addResult(`User: ${user}, Public Key: ${publicKey ? '✓' : '✗'}, Auth Data: ${authData ? '✓' : '✗'}`);
if (authData) {
try {
const parsed = JSON.parse(authData);
addResult(` - Challenge: ${parsed.challenge ? '✓' : '✗'}`);
addResult(` - Signature: ${parsed.signature ? '✓' : '✗'}`);
addResult(` - Timestamp: ${parsed.timestamp || '✗'}`);
} catch (e) {
addResult(` - Auth data parse error: ${e}`);
}
}
});
// Test the login popup functionality
addResult('Testing login popup user detection...');
try {
const storedUsers = JSON.parse(localStorage.getItem('registeredUsers') || '[]');
addResult(`All registered users: ${JSON.stringify(storedUsers)}`);
// Filter for users with valid keys (same logic as CryptoLogin)
const validUsers = storedUsers.filter((user: string) => {
const publicKey = localStorage.getItem(`${user}_publicKey`);
if (!publicKey) return false;
const authData = localStorage.getItem(`${user}_authData`);
if (!authData) return false;
try {
const parsed = JSON.parse(authData);
return parsed.challenge && parsed.signature && parsed.timestamp;
} catch (e) {
return false;
}
});
addResult(`Users with valid keys: ${JSON.stringify(validUsers)}`);
addResult(`Valid users count: ${validUsers.length}/${storedUsers.length}`);
if (validUsers.length > 0) {
addResult(`Login popup would suggest: ${validUsers[0]}`);
} else {
addResult('No valid users found - would default to registration mode');
}
} catch (e) {
addResult(`Error reading stored users: ${e}`);
}
};
const cleanupInvalidUsers = () => {
try {
const storedUsers = JSON.parse(localStorage.getItem('registeredUsers') || '[]');
const validUsers = storedUsers.filter((user: string) => {
const publicKey = localStorage.getItem(`${user}_publicKey`);
const authData = localStorage.getItem(`${user}_authData`);
if (!publicKey || !authData) return false;
try {
const parsed = JSON.parse(authData);
return parsed.challenge && parsed.signature && parsed.timestamp;
} catch (e) {
return false;
}
});
// Update the registered users list to only include valid users
localStorage.setItem('registeredUsers', JSON.stringify(validUsers));
addResult(`Cleaned up invalid users. Removed ${storedUsers.length - validUsers.length} invalid entries.`);
addResult(`Remaining valid users: ${JSON.stringify(validUsers)}`);
} catch (e) {
addResult(`Error cleaning up users: ${e}`);
}
};
return (
<div className="crypto-debug-container">
<h2>Cryptographic Authentication Debug</h2>
<div className="debug-controls">
<input
type="text"
value={testUsername}
onChange={(e) => setTestUsername(e.target.value)}
placeholder="Test username"
className="debug-input"
/>
<button
onClick={runCryptoTest}
disabled={isRunning}
className="debug-button"
>
{isRunning ? 'Running Tests...' : 'Run Crypto Test'}
</button>
<button
onClick={checkStoredUsers}
className="debug-button"
>
Check Stored Users
</button>
<button
onClick={cleanupInvalidUsers}
className="debug-button"
>
Cleanup Invalid Users
</button>
<button
onClick={clearResults}
disabled={isRunning}
className="debug-button"
>
Clear Results
</button>
</div>
<div className="debug-results">
<h3>Debug Results:</h3>
{testResults.length === 0 ? (
<p>No test results yet. Click "Run Crypto Test" to start.</p>
) : (
<div className="results-list">
{testResults.map((result, index) => (
<div key={index} className="result-item">
{result}
</div>
))}
</div>
)}
</div>
</div>
);
};
export default CryptoDebug;

View File

@ -0,0 +1,279 @@
import React, { useState, useEffect } from 'react';
import { CryptoAuthService } from '../../lib/auth/cryptoAuthService';
import { useAuth } from '../../context/AuthContext';
import { useNotifications } from '../../context/NotificationContext';
import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser';
interface CryptoLoginProps {
onSuccess?: () => void;
onCancel?: () => void;
}
/**
* WebCryptoAPI-based authentication component
*/
const CryptoLogin: React.FC<CryptoLoginProps> = ({ onSuccess, onCancel }) => {
const [username, setUsername] = useState('');
const [isRegistering, setIsRegistering] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [existingUsers, setExistingUsers] = useState<string[]>([]);
const [suggestedUsername, setSuggestedUsername] = useState<string>('');
const [browserSupport, setBrowserSupport] = useState<{
supported: boolean;
secure: boolean;
webcrypto: boolean;
}>({ supported: false, secure: false, webcrypto: false });
const { setSession } = useAuth();
const { addNotification } = useNotifications();
// Check browser support and existing users on mount
useEffect(() => {
const checkSupport = () => {
const supported = checkBrowserSupport();
const secure = isSecureContext();
const webcrypto = typeof window !== 'undefined' &&
typeof window.crypto !== 'undefined' &&
typeof window.crypto.subtle !== 'undefined';
setBrowserSupport({ supported, secure, webcrypto });
if (!supported) {
setError('Your browser does not support the required features for cryptographic authentication.');
addNotification('Browser not supported for cryptographic authentication', 'warning');
} else if (!secure) {
setError('Cryptographic authentication requires a secure context (HTTPS).');
addNotification('Secure context required for cryptographic authentication', 'warning');
} else if (!webcrypto) {
setError('WebCryptoAPI is not available in your browser.');
addNotification('WebCryptoAPI not available', 'warning');
}
};
const checkExistingUsers = () => {
try {
// Get registered users from localStorage
const users = JSON.parse(localStorage.getItem('registeredUsers') || '[]');
// Filter users to only include those with valid authentication keys
const validUsers = users.filter((user: string) => {
// Check if public key exists
const publicKey = localStorage.getItem(`${user}_publicKey`);
if (!publicKey) return false;
// Check if authentication data exists
const authData = localStorage.getItem(`${user}_authData`);
if (!authData) return false;
// Verify the auth data is valid JSON and has required fields
try {
const parsed = JSON.parse(authData);
return parsed.challenge && parsed.signature && parsed.timestamp;
} catch (e) {
console.warn(`Invalid auth data for user ${user}:`, e);
return false;
}
});
setExistingUsers(validUsers);
// If there are valid users, suggest the first one for login
if (validUsers.length > 0) {
setSuggestedUsername(validUsers[0]);
setUsername(validUsers[0]); // Pre-fill the username field
setIsRegistering(false); // Default to login mode if users exist
} else {
setIsRegistering(true); // Default to registration mode if no users exist
}
// Log for debugging
if (users.length !== validUsers.length) {
console.log(`Found ${users.length} registered users, but only ${validUsers.length} have valid keys`);
}
} catch (error) {
console.error('Error checking existing users:', error);
setExistingUsers([]);
}
};
checkSupport();
checkExistingUsers();
}, [addNotification]);
/**
* Handle form submission for both login and registration
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
if (!browserSupport.supported || !browserSupport.secure || !browserSupport.webcrypto) {
setError('Browser does not support cryptographic authentication');
setIsLoading(false);
return;
}
if (isRegistering) {
// Registration flow using CryptoAuthService
const result = await CryptoAuthService.register(username);
if (result.success && result.session) {
setSession(result.session);
if (onSuccess) onSuccess();
} else {
setError(result.error || 'Registration failed');
addNotification('Registration failed. Please try again.', 'error');
}
} else {
// Login flow using CryptoAuthService
const result = await CryptoAuthService.login(username);
if (result.success && result.session) {
setSession(result.session);
if (onSuccess) onSuccess();
} else {
setError(result.error || 'User not found or authentication failed');
addNotification('Login failed. Please check your username.', 'error');
}
}
} catch (err) {
console.error('Cryptographic authentication error:', err);
setError('An unexpected error occurred during authentication');
addNotification('Authentication error. Please try again later.', 'error');
} finally {
setIsLoading(false);
}
};
if (!browserSupport.supported) {
return (
<div className="crypto-login-container">
<h2>Browser Not Supported</h2>
<p>Your browser does not support the required features for cryptographic authentication.</p>
<p>Please use a modern browser with WebCryptoAPI support.</p>
{onCancel && (
<button onClick={onCancel} className="cancel-button">
Go Back
</button>
)}
</div>
);
}
if (!browserSupport.secure) {
return (
<div className="crypto-login-container">
<h2>Secure Context Required</h2>
<p>Cryptographic authentication requires a secure context (HTTPS).</p>
<p>Please access this application over HTTPS.</p>
{onCancel && (
<button onClick={onCancel} className="cancel-button">
Go Back
</button>
)}
</div>
);
}
return (
<div className="crypto-login-container">
<h2>{isRegistering ? 'Create Cryptographic Account' : 'Cryptographic Sign In'}</h2>
{/* Show existing users if available */}
{existingUsers.length > 0 && !isRegistering && (
<div className="existing-users">
<h3>Available Accounts with Valid Keys</h3>
<div className="user-list">
{existingUsers.map((user) => (
<button
key={user}
onClick={() => {
setUsername(user);
setError(null);
}}
className={`user-option ${username === user ? 'selected' : ''}`}
disabled={isLoading}
>
<span className="user-icon">🔐</span>
<span className="user-name">{user}</span>
<span className="user-status">Cryptographic keys available</span>
</button>
))}
</div>
</div>
)}
<div className="crypto-info">
<p>
{isRegistering
? 'Create a new account using WebCryptoAPI for secure authentication.'
: existingUsers.length > 0
? 'Select an account above or enter a different username to sign in.'
: 'Sign in using your cryptographic credentials.'
}
</p>
<div className="crypto-features">
<span className="feature"> ECDSA P-256 Key Pairs</span>
<span className="feature"> Challenge-Response Authentication</span>
<span className="feature"> Secure Key Storage</span>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={existingUsers.length > 0 ? "Enter username or select from above" : "Enter username"}
required
disabled={isLoading}
autoComplete="username"
minLength={3}
maxLength={20}
/>
</div>
{error && <div className="error-message">{error}</div>}
<button
type="submit"
disabled={isLoading || !username.trim()}
className="crypto-auth-button"
>
{isLoading ? 'Processing...' : isRegistering ? 'Create Account' : 'Sign In'}
</button>
</form>
<div className="auth-toggle">
<button
onClick={() => {
setIsRegistering(!isRegistering);
setError(null);
// Clear username when switching modes
if (!isRegistering) {
setUsername('');
} else if (existingUsers.length > 0) {
setUsername(existingUsers[0]);
}
}}
disabled={isLoading}
className="toggle-button"
>
{isRegistering ? 'Already have an account? Sign in' : 'Need an account? Register'}
</button>
</div>
{onCancel && (
<button onClick={onCancel} className="cancel-button">
Cancel
</button>
)}
</div>
);
};
export default CryptoLogin;

View File

@ -0,0 +1,190 @@
import React, { useState } from 'react';
import { CryptoAuthService } from '../../lib/auth/cryptoAuthService';
import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser';
import * as crypto from '../../lib/auth/crypto';
/**
* Test component to verify WebCryptoAPI authentication
*/
const CryptoTest: React.FC = () => {
const [testResults, setTestResults] = useState<string[]>([]);
const [isRunning, setIsRunning] = useState(false);
const addResult = (message: string) => {
setTestResults(prev => [...prev, `${new Date().toLocaleTimeString()}: ${message}`]);
};
const runTests = async () => {
setIsRunning(true);
setTestResults([]);
try {
addResult('Starting WebCryptoAPI authentication tests...');
// Test 1: Browser Support
addResult('Testing browser support...');
const browserSupported = checkBrowserSupport();
const secureContext = isSecureContext();
const webcryptoAvailable = typeof window !== 'undefined' &&
typeof window.crypto !== 'undefined' &&
typeof window.crypto.subtle !== 'undefined';
addResult(`Browser support: ${browserSupported ? '✓' : '✗'}`);
addResult(`Secure context: ${secureContext ? '✓' : '✗'}`);
addResult(`WebCryptoAPI available: ${webcryptoAvailable ? '✓' : '✗'}`);
if (!browserSupported || !secureContext || !webcryptoAvailable) {
addResult('❌ Browser does not meet requirements for cryptographic authentication');
return;
}
// Test 2: Key Generation
addResult('Testing key pair generation...');
const keyPair = await crypto.generateKeyPair();
if (keyPair) {
addResult('✓ Key pair generated successfully');
} else {
addResult('❌ Key pair generation failed');
return;
}
// Test 3: Public Key Export
addResult('Testing public key export...');
const publicKeyBase64 = await crypto.exportPublicKey(keyPair.publicKey);
if (publicKeyBase64) {
addResult('✓ Public key exported successfully');
} else {
addResult('❌ Public key export failed');
return;
}
// Test 4: Public Key Import
addResult('Testing public key import...');
const importedPublicKey = await crypto.importPublicKey(publicKeyBase64);
if (importedPublicKey) {
addResult('✓ Public key imported successfully');
} else {
addResult('❌ Public key import failed');
return;
}
// Test 5: Data Signing
addResult('Testing data signing...');
const testData = 'Hello, WebCryptoAPI!';
const signature = await crypto.signData(keyPair.privateKey, testData);
if (signature) {
addResult('✓ Data signed successfully');
} else {
addResult('❌ Data signing failed');
return;
}
// Test 6: Signature Verification
addResult('Testing signature verification...');
const isValid = await crypto.verifySignature(importedPublicKey, signature, testData);
if (isValid) {
addResult('✓ Signature verified successfully');
} else {
addResult('❌ Signature verification failed');
return;
}
// Test 7: User Registration
addResult('Testing user registration...');
const testUsername = `testuser_${Date.now()}`;
const registerResult = await CryptoAuthService.register(testUsername);
if (registerResult.success) {
addResult('✓ User registration successful');
} else {
addResult(`❌ User registration failed: ${registerResult.error}`);
return;
}
// Test 8: User Login
addResult('Testing user login...');
const loginResult = await CryptoAuthService.login(testUsername);
if (loginResult.success) {
addResult('✓ User login successful');
} else {
addResult(`❌ User login failed: ${loginResult.error}`);
return;
}
// Test 9: Credential Verification
addResult('Testing credential verification...');
const credentialsValid = await CryptoAuthService.verifyCredentials(testUsername);
if (credentialsValid) {
addResult('✓ Credential verification successful');
} else {
addResult('❌ Credential verification failed');
return;
}
addResult('🎉 All WebCryptoAPI authentication tests passed!');
} catch (error) {
addResult(`❌ Test error: ${error}`);
} finally {
setIsRunning(false);
}
};
const clearResults = () => {
setTestResults([]);
};
return (
<div className="crypto-test-container">
<h2>WebCryptoAPI Authentication Test</h2>
<div className="test-controls">
<button
onClick={runTests}
disabled={isRunning}
className="test-button"
>
{isRunning ? 'Running Tests...' : 'Run Tests'}
</button>
<button
onClick={clearResults}
disabled={isRunning}
className="clear-button"
>
Clear Results
</button>
</div>
<div className="test-results">
<h3>Test Results:</h3>
{testResults.length === 0 ? (
<p>No test results yet. Click "Run Tests" to start.</p>
) : (
<div className="results-list">
{testResults.map((result, index) => (
<div key={index} className="result-item">
{result}
</div>
))}
</div>
)}
</div>
<div className="test-info">
<h3>What's Being Tested:</h3>
<ul>
<li>Browser WebCryptoAPI support</li>
<li>Secure context (HTTPS)</li>
<li>ECDSA P-256 key pair generation</li>
<li>Public key export/import</li>
<li>Data signing and verification</li>
<li>User registration with cryptographic keys</li>
<li>User login with challenge-response</li>
<li>Credential verification</li>
</ul>
</div>
</div>
);
};
export default CryptoTest;

View File

@ -0,0 +1,102 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { createAccountLinkingConsumer } from '../../lib/auth/linking'
import { useAuth } from '../../context/AuthContext'
import { useNotifications } from '../../context/NotificationContext'
const LinkDevice: React.FC = () => {
const [username, setUsername] = useState('')
const [displayPin, setDisplayPin] = useState('')
const [view, setView] = useState<'enter-username' | 'show-pin' | 'load-filesystem'>('enter-username')
const [accountLinkingConsumer, setAccountLinkingConsumer] = useState<any>(null)
const navigate = useNavigate()
const { login } = useAuth()
const { addNotification } = useNotifications()
const initAccountLinkingConsumer = async () => {
try {
const consumer = await createAccountLinkingConsumer(username)
setAccountLinkingConsumer(consumer)
consumer.on('challenge', ({ pin }: { pin: number[] }) => {
setDisplayPin(pin.join(''))
setView('show-pin')
})
consumer.on('link', async ({ approved, username }: { approved: boolean, username: string }) => {
if (approved) {
setView('load-filesystem')
const success = await login(username)
if (success) {
addNotification("You're now connected!", "success")
navigate('/')
} else {
addNotification("Connection successful but login failed", "error")
navigate('/login')
}
} else {
addNotification('The connection attempt was cancelled', "warning")
navigate('/')
}
})
} catch (error) {
console.error('Error initializing account linking consumer:', error)
addNotification('Failed to initialize device linking', "error")
}
}
const handleSubmitUsername = (e: React.FormEvent) => {
e.preventDefault()
initAccountLinkingConsumer()
}
// Clean up consumer on unmount
useEffect(() => {
return () => {
if (accountLinkingConsumer) {
accountLinkingConsumer.destroy()
}
}
}, [accountLinkingConsumer])
return (
<div className="link-device-container">
{view === 'enter-username' && (
<>
<h2>Link a New Device</h2>
<form onSubmit={handleSubmitUsername}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<button type="submit" disabled={!username}>Continue</button>
</form>
</>
)}
{view === 'show-pin' && (
<div className="pin-display">
<h2>Enter this PIN on your other device</h2>
<div className="pin-code">{displayPin}</div>
</div>
)}
{view === 'load-filesystem' && (
<div className="loading">
<h2>Loading your filesystem...</h2>
<p>Please wait while we connect to your account.</p>
</div>
)}
</div>
)
}
export default LinkDevice

View File

@ -0,0 +1,18 @@
import React from 'react';
interface LoadingProps {
message?: string;
}
const Loading: React.FC<LoadingProps> = ({ message = 'Loading...' }) => {
return (
<div className="loading-container">
<div className="loading-spinner">
<div className="spinner"></div>
</div>
<p className="loading-message">{message}</p>
</div>
);
};
export default Loading;

View File

@ -0,0 +1,56 @@
import React, { useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import { useNotifications } from '../../context/NotificationContext';
import CryptoLogin from './CryptoLogin';
interface LoginButtonProps {
className?: string;
}
const LoginButton: React.FC<LoginButtonProps> = ({ className = '' }) => {
const [showLogin, setShowLogin] = useState(false);
const { session } = useAuth();
const { addNotification } = useNotifications();
const handleLoginClick = () => {
setShowLogin(true);
};
const handleLoginSuccess = () => {
setShowLogin(false);
};
const handleLoginCancel = () => {
setShowLogin(false);
};
// Don't show login button if user is already authenticated
if (session.authed) {
return null;
}
return (
<>
<button
onClick={handleLoginClick}
className={`login-button ${className}`}
title="Sign in to save your work and access additional features"
>
Sign In
</button>
{showLogin && (
<div className="login-overlay">
<div className="login-modal">
<CryptoLogin
onSuccess={handleLoginSuccess}
onCancel={handleLoginCancel}
/>
</div>
</div>
)}
</>
);
};
export default LoginButton;

View File

@ -0,0 +1,50 @@
import React from 'react';
import { useAuth } from '../../context/AuthContext';
import { clearSession } from '../../lib/init';
interface ProfileProps {
onLogout?: () => void;
}
export const Profile: React.FC<ProfileProps> = ({ onLogout }) => {
const { session, updateSession } = useAuth();
const handleLogout = () => {
// Clear the session
clearSession();
// Update the auth context
updateSession({
username: '',
authed: false,
backupCreated: null,
});
// Call the onLogout callback if provided
if (onLogout) onLogout();
};
if (!session.authed || !session.username) {
return null;
}
return (
<div className="profile-container">
<div className="profile-header">
<h3>Welcome, {session.username}!</h3>
</div>
<div className="profile-actions">
<button onClick={handleLogout} className="logout-button">
Sign Out
</button>
</div>
{!session.backupCreated && (
<div className="backup-reminder">
<p>Remember to back up your encryption keys to prevent data loss!</p>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,23 @@
import React from 'react';
import { useAuth } from '../../../src/context/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { session } = useAuth();
if (session.loading) {
// Show loading indicator while authentication is being checked
return (
<div className="auth-loading">
<p>Checking authentication...</p>
</div>
);
}
// For board routes, we'll allow access even if not authenticated
// The auth button in the toolbar will handle authentication
return <>{children}</>;
};

View File

@ -0,0 +1,64 @@
import React, { useState } from 'react'
import { register } from '../../lib/auth/account'
const Register: React.FC = () => {
const [username, setUsername] = useState('')
const [checkingUsername, setCheckingUsername] = useState(false)
const [initializingFilesystem, setInitializingFilesystem] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
if (checkingUsername) {
return
}
setInitializingFilesystem(true)
setError(null)
try {
const success = await register(username)
if (!success) {
setError('Registration failed. Username may be taken.')
setInitializingFilesystem(false)
}
} catch (err) {
setError('An error occurred during registration')
setInitializingFilesystem(false)
console.error(err)
}
}
return (
<div className="register-container">
<h2>Create an Account</h2>
<form onSubmit={handleRegister}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={initializingFilesystem}
required
/>
</div>
{error && <div className="error-message">{error}</div>}
<button
type="submit"
disabled={initializingFilesystem || !username}
>
{initializingFilesystem ? 'Creating Account...' : 'Create Account'}
</button>
</form>
</div>
)
}
export default Register

171
src/context/AuthContext.tsx Normal file
View File

@ -0,0 +1,171 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import type FileSystem from '@oddjs/odd/fs/index';
import { Session, SessionError } from '../lib/auth/types';
import { AuthService } from '../lib/auth/authService';
import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence';
interface AuthContextType {
session: Session;
setSession: (updatedSession: Partial<Session>) => void;
updateSession: (updatedSession: Partial<Session>) => void;
clearSession: () => void;
fileSystem: FileSystem | null;
setFileSystem: (fs: FileSystem | null) => void;
initialize: () => Promise<void>;
login: (username: string) => Promise<boolean>;
register: (username: string) => Promise<boolean>;
logout: () => Promise<void>;
}
const initialSession: Session = {
username: '',
authed: false,
loading: true,
backupCreated: null
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [session, setSessionState] = useState<Session>(initialSession);
const [fileSystem, setFileSystemState] = useState<FileSystem | null>(null);
// Update session with partial data
const setSession = (updatedSession: Partial<Session>) => {
setSessionState(prev => {
const newSession = { ...prev, ...updatedSession };
// Save session to localStorage if authenticated
if (newSession.authed && newSession.username) {
saveSession(newSession);
}
return newSession;
});
};
// Set file system
const setFileSystem = (fs: FileSystem | null) => {
setFileSystemState(fs);
};
/**
* Initialize the authentication state
*/
const initialize = async (): Promise<void> => {
setSession({ loading: true });
try {
const { session: newSession, fileSystem: newFs } = await AuthService.initialize();
setSession(newSession);
setFileSystem(newFs);
} catch (error) {
setSession({
loading: false,
authed: false,
error: error as SessionError
});
}
};
/**
* Login with a username
*/
const login = async (username: string): Promise<boolean> => {
setSession({ loading: true });
const result = await AuthService.login(username);
if (result.success && result.session && result.fileSystem) {
setSession(result.session);
setFileSystem(result.fileSystem);
return true;
} else {
setSession({
loading: false,
error: result.error as SessionError
});
return false;
}
};
/**
* Register a new user
*/
const register = async (username: string): Promise<boolean> => {
setSession({ loading: true });
const result = await AuthService.register(username);
if (result.success && result.session && result.fileSystem) {
setSession(result.session);
setFileSystem(result.fileSystem);
return true;
} else {
setSession({
loading: false,
error: result.error as SessionError
});
return false;
}
};
/**
* Clear the current session
*/
const clearSession = (): void => {
clearStoredSession();
setSession({
username: '',
authed: false,
loading: false,
backupCreated: null
});
setFileSystem(null);
};
/**
* Logout the current user
*/
const logout = async (): Promise<void> => {
try {
await AuthService.logout();
clearSession();
} catch (error) {
console.error('Logout error:', error);
throw error;
}
};
// Initialize on mount
useEffect(() => {
initialize();
}, []);
const contextValue: AuthContextType = {
session,
setSession,
updateSession: setSession,
clearSession,
fileSystem,
setFileSystem,
initialize,
login,
register,
logout
};
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@ -0,0 +1,183 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import * as webnative from 'webnative';
import type FileSystem from 'webnative/fs/index';
/**
* File system context interface
*/
interface FileSystemContextType {
fs: FileSystem | null;
setFs: (fs: FileSystem | null) => void;
isReady: boolean;
}
// Create context with a default undefined value
const FileSystemContext = createContext<FileSystemContextType | undefined>(undefined);
/**
* FileSystemProvider component
*
* Provides access to the webnative filesystem throughout the application.
*/
export const FileSystemProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [fs, setFs] = useState<FileSystem | null>(null);
// File system is ready when it's not null
const isReady = fs !== null;
return (
<FileSystemContext.Provider value={{ fs, setFs, isReady }}>
{children}
</FileSystemContext.Provider>
);
};
/**
* Hook to access the file system context
*
* @returns The file system context
* @throws Error if used outside of FileSystemProvider
*/
export const useFileSystem = (): FileSystemContextType => {
const context = useContext(FileSystemContext);
if (context === undefined) {
throw new Error('useFileSystem must be used within a FileSystemProvider');
}
return context;
};
/**
* Directory paths used in the application
*/
export const DIRECTORIES = {
PUBLIC: {
ROOT: ['public'],
GALLERY: ['public', 'gallery'],
DOCUMENTS: ['public', 'documents']
},
PRIVATE: {
ROOT: ['private'],
GALLERY: ['private', 'gallery'],
SETTINGS: ['private', 'settings'],
DOCUMENTS: ['private', 'documents']
}
};
/**
* Common filesystem operations
*
* @param fs The filesystem instance
* @returns An object with filesystem utility functions
*/
export const createFileSystemUtils = (fs: FileSystem) => {
return {
/**
* Creates a directory if it doesn't exist
*
* @param path Array of path segments
*/
ensureDirectory: async (path: string[]): Promise<void> => {
try {
const dirPath = webnative.path.directory(...path);
const exists = await fs.exists(dirPath as any);
if (!exists) {
await fs.mkdir(dirPath as any);
}
} catch (error) {
console.error('Error ensuring directory:', error);
}
},
/**
* Writes a file to the filesystem
*
* @param path Array of path segments
* @param fileName The name of the file
* @param content The content to write
*/
writeFile: async (path: string[], fileName: string, content: Blob | string): Promise<void> => {
try {
const filePath = webnative.path.file(...path, fileName);
// Convert content to appropriate format for webnative
const contentToWrite = typeof content === 'string' ? new TextEncoder().encode(content) : content;
await fs.write(filePath as any, contentToWrite as any);
await fs.publish();
} catch (error) {
console.error('Error writing file:', error);
}
},
/**
* Reads a file from the filesystem
*
* @param path Array of path segments
* @param fileName The name of the file
* @returns The file content
*/
readFile: async (path: string[], fileName: string): Promise<any> => {
try {
const filePath = webnative.path.file(...path, fileName);
const exists = await fs.exists(filePath as any);
if (!exists) {
throw new Error(`File doesn't exist: ${fileName}`);
}
return await fs.read(filePath as any);
} catch (error) {
console.error('Error reading file:', error);
throw error;
}
},
/**
* Checks if a file exists
*
* @param path Array of path segments
* @param fileName The name of the file
* @returns Boolean indicating if the file exists
*/
fileExists: async (path: string[], fileName: string): Promise<boolean> => {
try {
const filePath = webnative.path.file(...path, fileName);
return await fs.exists(filePath as any);
} catch (error) {
console.error('Error checking file existence:', error);
return false;
}
},
/**
* Lists files in a directory
*
* @param path Array of path segments
* @returns Object with file names as keys
*/
listDirectory: async (path: string[]): Promise<Record<string, any>> => {
try {
const dirPath = webnative.path.directory(...path);
const exists = await fs.exists(dirPath as any);
if (!exists) {
return {};
}
return await fs.ls(dirPath as any);
} catch (error) {
console.error('Error listing directory:', error);
return {};
}
}
};
};
/**
* Hook to use filesystem utilities
*
* @returns Filesystem utilities or null if filesystem is not ready
*/
export const useFileSystemUtils = () => {
const { fs, isReady } = useFileSystem();
if (!isReady || !fs) {
return null;
}
return createFileSystemUtils(fs);
};

View File

@ -0,0 +1,111 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
/**
* Types of notifications supported by the system
*/
export type NotificationType = 'success' | 'error' | 'info' | 'warning';
/**
* Notification object structure
*/
export type Notification = {
id: string;
msg: string;
type: NotificationType;
timeout: number;
};
/**
* Interface for the notification context
*/
interface NotificationContextType {
notifications: Notification[];
addNotification: (msg: string, type?: NotificationType, timeout?: number) => string;
removeNotification: (id: string) => void;
clearAllNotifications: () => void;
}
// Create context with a default undefined value
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
/**
* NotificationProvider component - provides notification functionality to the app
*/
export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [notifications, setNotifications] = useState<Notification[]>([]);
/**
* Remove a notification by ID
*/
const removeNotification = useCallback((id: string) => {
setNotifications(current => current.filter(notification => notification.id !== id));
}, []);
/**
* Add a new notification
* @param msg The message to display
* @param type The type of notification (success, error, info, warning)
* @param timeout Time in ms before notification is automatically removed
* @returns The ID of the created notification
*/
const addNotification = useCallback(
(msg: string, type: NotificationType = 'info', timeout: number = 5000): string => {
// Create a unique ID for the notification
const id = crypto.randomUUID();
// Add notification to the array
setNotifications(current => [
...current,
{
id,
msg,
type,
timeout,
}
]);
// Set up automatic removal after timeout
if (timeout > 0) {
setTimeout(() => {
removeNotification(id);
}, timeout);
}
// Return the notification ID for reference
return id;
},
[removeNotification]
);
/**
* Clear all current notifications
*/
const clearAllNotifications = useCallback(() => {
setNotifications([]);
}, []);
// Create the context value with all functions and state
const contextValue: NotificationContextType = {
notifications,
addNotification,
removeNotification,
clearAllNotifications
};
return (
<NotificationContext.Provider value={contextValue}>
{children}
</NotificationContext.Provider>
);
};
/**
* Hook to access the notification context
*/
export const useNotifications = (): NotificationContextType => {
const context = useContext(NotificationContext);
if (context === undefined) {
throw new Error('useNotifications must be used within a NotificationProvider');
}
return context;
};

176
src/css/auth.css Normal file
View File

@ -0,0 +1,176 @@
/* Authentication Page Styles */
.auth-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f5f5f5;
padding: 20px;
}
.auth-container {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 30px;
width: 100%;
max-width: 400px;
}
.auth-container h2 {
margin-top: 0;
margin-bottom: 24px;
text-align: center;
color: #333;
font-size: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: #555;
}
.form-group input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: #6366f1;
outline: none;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.error-message {
color: #dc2626;
margin-bottom: 20px;
font-size: 14px;
background-color: #fee2e2;
padding: 8px 12px;
border-radius: 4px;
border-left: 3px solid #dc2626;
}
.auth-button {
width: 100%;
background-color: #6366f1;
color: white;
border: none;
border-radius: 4px;
padding: 12px 16px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.auth-button:hover {
background-color: #4f46e5;
}
.auth-button:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
.auth-toggle {
margin-top: 20px;
text-align: center;
}
.auth-toggle button {
background: none;
border: none;
color: #6366f1;
font-size: 14px;
cursor: pointer;
text-decoration: underline;
}
.auth-toggle button:hover {
color: #4f46e5;
}
.auth-toggle button:disabled {
color: #9ca3af;
cursor: not-allowed;
text-decoration: none;
}
.auth-container.loading,
.auth-container.error {
text-align: center;
padding: 40px 30px;
}
.auth-loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f5f5f5;
}
/* Profile Component Styles */
.profile-container {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 20px;
}
.profile-header {
margin-bottom: 16px;
}
.profile-header h3 {
margin: 0;
color: #333;
font-size: 18px;
}
.profile-actions {
display: flex;
justify-content: flex-end;
}
.logout-button {
background-color: #ef4444;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.logout-button:hover {
background-color: #dc2626;
}
.backup-reminder {
margin-top: 16px;
padding: 12px;
background-color: #fffbeb;
border-radius: 4px;
border-left: 3px solid #f59e0b;
}
.backup-reminder p {
margin: 0;
color: #92400e;
font-size: 14px;
}

695
src/css/crypto-auth.css Normal file
View File

@ -0,0 +1,695 @@
/* Cryptographic Authentication Styles */
.crypto-login-container {
max-width: 400px;
margin: 0 auto;
padding: 2rem;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid #e1e5e9;
}
.crypto-login-container h2 {
margin: 0 0 1.5rem 0;
color: #1a1a1a;
font-size: 1.5rem;
font-weight: 600;
text-align: center;
}
.crypto-info {
margin-bottom: 2rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #007bff;
}
.crypto-info p {
margin: 0 0 1rem 0;
color: #6c757d;
font-size: 0.9rem;
line-height: 1.4;
}
.crypto-features {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.crypto-features .feature {
font-size: 0.8rem;
color: #28a745;
font-weight: 500;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #495057;
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s ease;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.form-group input:disabled {
background-color: #f8f9fa;
color: #6c757d;
cursor: not-allowed;
}
/* Existing Users Styles */
.existing-users {
margin-bottom: 1.5rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.existing-users h3 {
margin: 0 0 0.75rem 0;
color: #495057;
font-size: 1rem;
font-weight: 600;
}
.user-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.user-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: white;
border: 2px solid #e9ecef;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
width: 100%;
}
.user-option:hover:not(:disabled) {
border-color: #007bff;
background: #f8f9ff;
}
.user-option.selected {
border-color: #007bff;
background: #e7f3ff;
}
.user-option:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.user-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.user-name {
font-weight: 500;
color: #495057;
flex-grow: 1;
}
.user-status {
font-size: 0.8rem;
color: #6c757d;
font-style: italic;
}
.error-message {
margin-bottom: 1rem;
padding: 0.75rem;
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 6px;
font-size: 0.9rem;
}
.crypto-auth-button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 1rem;
}
.crypto-auth-button:hover:not(:disabled) {
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3);
}
.crypto-auth-button:disabled {
background: #6c757d;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.auth-toggle {
text-align: center;
margin-top: 1rem;
}
.toggle-button {
background: none;
border: none;
color: #007bff;
font-size: 0.9rem;
cursor: pointer;
text-decoration: underline;
transition: color 0.2s ease;
}
.toggle-button:hover:not(:disabled) {
color: #0056b3;
}
.toggle-button:disabled {
color: #6c757d;
cursor: not-allowed;
}
.cancel-button {
width: 100%;
padding: 0.75rem;
background: #6c757d;
color: white;
border: none;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s ease;
margin-top: 1rem;
}
.cancel-button:hover {
background: #5a6268;
}
/* Loading state */
.crypto-auth-button:disabled {
position: relative;
}
.crypto-auth-button:disabled::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border: 2px solid transparent;
border-top: 2px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive design */
@media (max-width: 480px) {
.crypto-login-container {
margin: 1rem;
padding: 1.5rem;
}
.crypto-login-container h2 {
font-size: 1.25rem;
}
.crypto-features {
font-size: 0.75rem;
}
.login-button {
padding: 4px 8px;
font-size: 0.7rem;
}
}
/* Responsive positioning for toolbar buttons */
@media (max-width: 768px) {
.toolbar-login-button {
margin-right: 0;
}
/* Adjust toolbar container position on mobile */
.toolbar-container {
right: 35px !important;
gap: 4px !important;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.crypto-login-container {
background: #2d3748;
border-color: #4a5568;
}
.crypto-login-container h2 {
color: #f7fafc;
}
.crypto-info {
background: #4a5568;
border-left-color: #63b3ed;
}
.crypto-info p {
color: #e2e8f0;
}
.form-group label {
color: #e2e8f0;
}
.form-group input {
background: #4a5568;
border-color: #718096;
color: #f7fafc;
}
.form-group input:focus {
border-color: #63b3ed;
box-shadow: 0 0 0 3px rgba(99, 179, 237, 0.1);
}
.form-group input:disabled {
background-color: #2d3748;
color: #a0aec0;
}
.existing-users {
background: #4a5568;
border-color: #718096;
}
.existing-users h3 {
color: #e2e8f0;
}
.user-option {
background: #2d3748;
border-color: #718096;
}
.user-option:hover:not(:disabled) {
border-color: #63b3ed;
background: #2c5282;
}
.user-option.selected {
border-color: #63b3ed;
background: #2c5282;
}
.user-name {
color: #e2e8f0;
}
.user-status {
color: #a0aec0;
}
}
/* Test Component Styles */
.crypto-test-container {
max-width: 800px;
margin: 2rem auto;
padding: 2rem;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid #e1e5e9;
}
.crypto-test-container h2 {
margin: 0 0 1.5rem 0;
color: #1a1a1a;
font-size: 1.5rem;
font-weight: 600;
text-align: center;
}
.test-controls {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
justify-content: center;
}
.test-button, .clear-button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.test-button {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
}
.test-button:hover:not(:disabled) {
background: linear-gradient(135deg, #218838 0%, #1ea085 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
}
.clear-button {
background: #6c757d;
color: white;
}
.clear-button:hover:not(:disabled) {
background: #5a6268;
}
.test-button:disabled, .clear-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.test-results {
margin-bottom: 2rem;
}
.test-results h3 {
margin: 0 0 1rem 0;
color: #495057;
font-size: 1.1rem;
}
.results-list {
max-height: 400px;
overflow-y: auto;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 1rem;
background: #f8f9fa;
}
.result-item {
padding: 0.5rem 0;
border-bottom: 1px solid #e9ecef;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #495057;
}
.result-item:last-child {
border-bottom: none;
}
.test-info {
background: #e3f2fd;
padding: 1rem;
border-radius: 6px;
border-left: 4px solid #2196f3;
}
.test-info h3 {
margin: 0 0 1rem 0;
color: #1976d2;
font-size: 1.1rem;
}
.test-info ul {
margin: 0;
padding-left: 1.5rem;
color: #424242;
}
.test-info li {
margin-bottom: 0.5rem;
}
/* Login Button Styles */
.login-button {
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
letter-spacing: 0.5px;
white-space: nowrap;
padding: 4px 8px;
height: 22px;
min-height: 22px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.login-button:hover {
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
}
.toolbar-login-button {
margin-right: 0;
height: 22px;
min-height: 22px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex-shrink: 0;
padding: 4px 8px;
font-size: 0.75rem;
border-radius: 4px;
transition: all 0.2s ease;
}
.toolbar-login-button:hover {
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
}
/* Login Modal Overlay */
.login-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(4px);
}
.login-modal {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 90vw;
max-height: 90vh;
overflow: auto;
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Dark mode for login button */
@media (prefers-color-scheme: dark) {
.login-button {
background: linear-gradient(135deg, #63b3ed 0%, #3182ce 100%);
}
.login-button:hover {
background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%);
}
.login-modal {
background: #2d3748;
border: 1px solid #4a5568;
}
}
/* Debug Component Styles */
.crypto-debug-container {
max-width: 600px;
margin: 1rem auto;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.crypto-debug-container h2 {
margin: 0 0 1rem 0;
color: #495057;
font-size: 1.2rem;
font-weight: 600;
}
.debug-controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
align-items: center;
}
.debug-input {
padding: 0.5rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.9rem;
min-width: 150px;
}
.debug-button {
padding: 0.5rem 1rem;
background: #6c757d;
color: white;
border: none;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.debug-button:hover:not(:disabled) {
background: #5a6268;
}
.debug-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.debug-results {
margin-top: 1rem;
}
.debug-results h3 {
margin: 0 0 0.5rem 0;
color: #495057;
font-size: 1rem;
}
/* Dark mode for test component */
@media (prefers-color-scheme: dark) {
.crypto-test-container {
background: #2d3748;
border-color: #4a5568;
}
.crypto-test-container h2 {
color: #f7fafc;
}
.test-results h3 {
color: #e2e8f0;
}
.results-list {
background: #4a5568;
border-color: #718096;
}
.result-item {
color: #e2e8f0;
border-bottom-color: #718096;
}
.test-info {
background: #2c5282;
border-left-color: #63b3ed;
}
.test-info h3 {
color: #90cdf4;
}
.test-info ul {
color: #e2e8f0;
}
.crypto-debug-container {
background: #4a5568;
border-color: #718096;
}
.crypto-debug-container h2 {
color: #e2e8f0;
}
.debug-input {
background: #2d3748;
border-color: #718096;
color: #f7fafc;
}
.debug-results h3 {
color: #e2e8f0;
}
}

34
src/css/dev-ui.css Normal file
View File

@ -0,0 +1,34 @@
.custom-layout {
position: absolute;
inset: 0px;
z-index: 300;
pointer-events: none;
}
.custom-toolbar {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
gap: 8px;
}
.custom-button {
pointer-events: all;
padding: 4px 12px;
background-color: white;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 64px;
&:hover {
background-color: rgb(240, 240, 240);
}
}
.custom-button[data-isactive="true"] {
background-color: black;
color: white;
}

32
src/css/loading.css Normal file
View File

@ -0,0 +1,32 @@
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
width: 100%;
}
.loading-spinner {
margin-bottom: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #3498db;
animation: spin 1s ease-in-out infinite;
}
.loading-message {
font-size: 1.2rem;
color: #333;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

89
src/css/reset.css Normal file
View File

@ -0,0 +1,89 @@
/* Box sizing rules */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Prevent font size inflation */
html {
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
/* Remove default margin in favour of better control in authored CSS */
body,
h1,
h2,
h3,
h4,
p,
figure,
blockquote,
dl,
dd {
margin-block-end: 0;
}
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
ul[role="list"],
ol[role="list"] {
list-style: none;
}
/* Set core body defaults */
body {
min-height: 100vh;
line-height: 1.5;
}
/* Set shorter line heights on headings and interactive elements */
h1,
h2,
h3,
h4,
button,
input,
label {
line-height: 1.1;
}
/* Balance text wrapping on headings */
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
/* A elements that don't have a class get default styles */
a:not([class]) {
text-decoration-skip-ink: auto;
color: currentColor;
}
/* Make images easier to work with */
img,
picture {
max-width: 100%;
display: block;
}
/* Inherit fonts for inputs and buttons */
input,
button,
textarea,
select {
font: inherit;
}
/* Make sure textareas without a rows attribute are not tiny */
textarea:not([rows]) {
min-height: 10em;
}
/* Anything that has been anchored to should have extra scroll margin */
:target {
scroll-margin-block: 5ex;
}

625
src/css/starred-boards.css Normal file
View File

@ -0,0 +1,625 @@
/* Star Board Button Styles */
.star-board-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
transition: all 0.2s ease;
letter-spacing: 0.5px;
white-space: nowrap;
box-sizing: border-box;
line-height: 1.1;
margin: 0;
width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
}
/* Custom popup notification styles */
.star-popup {
padding: 8px 12px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: popupSlideIn 0.3s ease-out;
max-width: 200px;
text-align: center;
}
.star-popup-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.star-popup-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.star-popup-info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
@keyframes popupSlideIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* Toolbar-specific star button styling to match login button exactly */
.toolbar-star-button {
padding: 4px 8px;
font-size: 0.75rem;
font-weight: 600;
border-radius: 4px;
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
color: white;
border: none;
transition: all 0.2s ease;
letter-spacing: 0.5px;
box-sizing: border-box;
line-height: 1.1;
margin: 0;
width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
flex-shrink: 0;
}
.star-board-button:hover {
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
}
.toolbar-star-button:hover {
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
}
.star-board-button.starred {
background: #6B7280;
color: white;
}
.star-board-button.starred:hover {
background: #4B5563;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.star-board-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.star-icon {
font-size: 0.8rem;
transition: transform 0.2s ease;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
color: inherit;
width: 16px;
height: 16px;
text-align: center;
}
.star-icon.starred {
transform: scale(1.1);
}
.loading-spinner {
animation: spin 1s linear infinite;
font-size: 12px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Dashboard Styles */
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
min-height: 100vh;
background: #f8f9fa;
}
.dashboard-header {
text-align: center;
margin-bottom: 32px;
padding: 32px 0;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.dashboard-header h1 {
font-size: 2.5rem;
font-weight: 700;
color: #212529;
margin: 0 0 8px 0;
}
.dashboard-header p {
font-size: 1.1rem;
color: #6c757d;
margin: 0;
}
.dashboard-content {
display: grid;
gap: 24px;
}
.starred-boards-section {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.section-header h2 {
font-size: 1.5rem;
font-weight: 600;
color: #212529;
margin: 0;
}
.board-count {
background: #e9ecef;
color: #6c757d;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: #6c757d;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
color: #495057;
margin: 0 0 8px 0;
}
.empty-state p {
margin: 0 0 24px 0;
font-size: 1rem;
}
.browse-link {
display: inline-block;
padding: 12px 24px;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
transition: background 0.2s ease;
}
.browse-link:hover {
background: #0056b3;
color: white;
text-decoration: none;
}
.boards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.board-card {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
transition: all 0.2s ease;
overflow: hidden;
}
.board-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-color: #dee2e6;
}
.board-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.board-title {
font-size: 1.125rem;
font-weight: 600;
color: #212529;
margin: 0;
flex: 1;
}
.unstar-button {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
color: #6B7280;
}
.unstar-button:hover {
background: #fff3cd;
transform: scale(1.1);
}
.board-card-content {
margin-bottom: 16px;
}
.board-slug {
font-family: 'Courier New', monospace;
font-size: 0.875rem;
color: #6c757d;
margin: 0 0 8px 0;
background: #e9ecef;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
}
.board-meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.75rem;
color: #6c757d;
}
.starred-date,
.last-visited {
display: block;
}
.board-card-actions {
display: flex;
gap: 8px;
}
.open-board-button {
flex: 1;
padding: 8px 16px;
background: #28a745;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
text-align: center;
transition: background 0.2s ease;
}
.open-board-button:hover {
background: #218838;
color: white;
text-decoration: none;
}
/* Board Screenshot Styles */
.board-screenshot {
margin: -20px -20px 16px -20px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
overflow: hidden;
position: relative;
}
.screenshot-image {
width: 100%;
height: 150px;
object-fit: cover;
object-position: center;
display: block;
background: #f8f9fa;
border-radius: 8px 8px 0 0;
}
.screenshot-image:hover {
transform: scale(1.02);
transition: transform 0.2s ease;
}
.quick-actions-section {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.quick-actions-section h2 {
font-size: 1.5rem;
font-weight: 600;
color: #212529;
margin: 0 0 20px 0;
}
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.action-card {
display: block;
padding: 20px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
text-align: center;
}
.action-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-color: #dee2e6;
color: inherit;
text-decoration: none;
}
.action-icon {
font-size: 2rem;
margin-bottom: 12px;
display: block;
}
.action-card h3 {
font-size: 1.125rem;
font-weight: 600;
color: #212529;
margin: 0 0 8px 0;
}
.action-card p {
font-size: 0.875rem;
color: #6c757d;
margin: 0;
}
.loading {
text-align: center;
padding: 48px;
color: #6c757d;
font-size: 1.125rem;
}
.auth-required {
text-align: center;
padding: 48px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.auth-required h2 {
font-size: 1.5rem;
font-weight: 600;
color: #212529;
margin: 0 0 16px 0;
}
.auth-required p {
color: #6c757d;
margin: 0 0 24px 0;
}
.back-link {
display: inline-block;
padding: 12px 24px;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
transition: background 0.2s ease;
}
.back-link:hover {
background: #0056b3;
color: white;
text-decoration: none;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.dashboard-container {
background: #1a1a1a;
}
.dashboard-header,
.starred-boards-section,
.quick-actions-section,
.auth-required {
background: #2d2d2d;
color: #e9ecef;
}
.dashboard-header h1,
.section-header h2,
.quick-actions-section h2,
.board-title,
.action-card h3 {
color: #e9ecef;
}
.dashboard-header p,
.empty-state,
.board-meta,
.action-card p {
color: #adb5bd;
}
.board-card,
.action-card {
background: #3a3a3a;
border-color: #495057;
}
.board-card:hover,
.action-card:hover {
border-color: #6c757d;
}
.board-slug {
background: #495057;
color: #adb5bd;
}
.star-board-button {
background: linear-gradient(135deg, #63b3ed 0%, #3182ce 100%);
color: white;
border: none;
}
.star-board-button:hover {
background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%);
color: white;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(99, 179, 237, 0.3);
}
.star-board-button.starred {
background: #6B7280;
color: white;
border: none;
}
.star-board-button.starred:hover {
background: #4B5563;
color: white;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* Dark mode popup styles */
.star-popup-success {
background: #1e4d2b;
color: #d4edda;
border: 1px solid #2d5a3d;
}
.star-popup-error {
background: #4a1e1e;
color: #f8d7da;
border: 1px solid #5a2d2d;
}
.star-popup-info {
background: #1e4a4a;
color: #d1ecf1;
border: 1px solid #2d5a5a;
}
.board-screenshot {
background: #495057;
border-bottom-color: #6c757d;
}
.screenshot-image {
background: #495057;
}
}
/* Responsive design */
@media (max-width: 768px) {
.dashboard-container {
padding: 16px;
}
.dashboard-header {
padding: 24px 16px;
}
.dashboard-header h1 {
font-size: 2rem;
}
.boards-grid {
grid-template-columns: 1fr;
}
.actions-grid {
grid-template-columns: 1fr;
}
.star-board-button {
padding: 6px 10px;
font-size: 12px;
}
.toolbar-star-button {
padding: 4px 8px;
font-size: 0.7rem;
width: 28px;
height: 24px;
min-width: 28px;
min-height: 24px;
}
.star-text {
display: none;
}
}

799
src/css/style.css Normal file
View File

@ -0,0 +1,799 @@
@import url("reset.css");
:root {
--border-radius: 10px;
}
html,
body {
padding: 0;
margin: 0;
min-height: 100vh;
min-height: -webkit-fill-available;
height: 100%;
}
video {
width: 100%;
height: auto;
}
main {
max-width: 60em;
margin: 0 auto;
padding-left: 4em;
padding-right: 4em;
padding-top: 3em;
padding-bottom: 3em;
font-family: "Recursive";
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
color: #24292e;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.5rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0;
margin-bottom: 0.5em;
}
header {
margin-bottom: 1em;
font-size: 1.5rem;
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
}
.main-nav {
margin-bottom: 2em;
display: flex;
gap: 2em;
align-items: center;
}
.nav-link {
color: #0366d6;
text-decoration: none;
font-weight: 500;
padding: 0.5em 1em;
border-radius: 6px;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.nav-link:hover {
background-color: #f6f8fa;
border-color: #e1e4e8;
text-decoration: none;
}
i {
font-variation-settings: "slnt" -15;
}
pre>code {
width: 100%;
padding: 1em;
display: block;
white-space: pre-wrap;
word-wrap: break-word;
}
code {
background-color: #e4e9ee;
width: 100%;
color: #38424c;
padding: 0.2em 0.4em;
border-radius: 4px;
}
b,
strong {
font-variation-settings: "wght" 600;
}
blockquote {
margin: -1em;
padding: 1em;
background-color: #f1f1f1;
margin-top: 1em;
margin-bottom: 1em;
border-radius: 4px;
& p {
font-variation-settings: "CASL" 1;
margin: 0;
}
}
p {
font-family: Recursive;
margin-top: 0;
margin-bottom: 1.5em;
font-size: 1.1em;
font-variation-settings: "wght" 350;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
margin-bottom: 1em;
font-variation-settings: "mono" 1;
font-variation-settings: "casl" 0;
th,
td {
padding: 0.5em;
border: 1px solid #ddd;
}
th {
background-color: #f4f4f4;
font-weight: bold;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
}
a {
font-variation-settings: "CASL" 0;
&:hover {
animation: casl-forward 0.2s ease forwards;
}
&:not(:hover) {
/* text-decoration: none; */
animation: casl-reverse 0.2s ease backwards;
}
}
@keyframes casl-forward {
from {
font-variation-settings:
"CASL" 0,
"wght" 400;
}
to {
font-variation-settings:
"CASL" 1,
"wght" 600;
}
}
@keyframes casl-reverse {
from {
font-variation-settings:
"CASL" 1,
"wght" 600;
}
to {
font-variation-settings:
"CASL" 0,
"wght" 400;
}
}
p a {
text-decoration: underline;
}
.dinkus {
display: block;
text-align: center;
font-size: 1.1rem;
margin-top: 2em;
margin-bottom: 0em;
}
ol,
ul {
padding-left: 0;
margin-top: 0;
font-size: 1rem;
& li::marker {
color: rgba(0, 0, 0, 0.322);
}
}
img {
display: block;
margin: 0 auto;
}
@media (max-width: 600px) {
main {
padding: 2em;
}
header {
margin-bottom: 1em;
}
ol {
list-style-position: inside;
}
}
/* Some conditional spacing */
table:not(:has(+ p)) {
margin-bottom: 2em;
}
p:has(+ ul) {
margin-bottom: 0.5em;
}
p:has(+ ol) {
margin-bottom: 0.5em;
}
.loading {
font-family: "Recursive";
font-variation-settings: "CASL" 1;
font-size: 1rem;
text-align: center;
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #f1f1f1;
border: 1px solid #c0c9d1;
padding: 0.5em;
border-radius: 4px;
}
/* CANVAS SHENANIGANS */
#toggle-physics,
#toggle-canvas {
position: fixed;
z-index: 999;
right: 10px;
width: 2.5rem;
height: 2.5rem;
background: none;
border: none;
cursor: pointer;
opacity: 0.25;
&:hover {
opacity: 1;
}
& img {
width: 100%;
height: 100%;
}
}
#toggle-canvas {
top: 10px;
}
#toggle-physics {
top: 60px;
display: none;
}
.tl-html-layer {
font-family: "Recursive";
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
& h1,
p,
span,
header,
ul,
ol {
margin: 0;
}
& header {
font-size: 1.5rem;
}
& p {
font-size: 1.1rem;
}
/* Markdown preview styles */
& h1 { font-size: 2em; margin: 0.67em 0; }
& h2 { font-size: 1.5em; margin: 0.75em 0; }
& h3 { font-size: 1.17em; margin: 0.83em 0; }
& h4 { margin: 1.12em 0; }
& h5 { font-size: 0.83em; margin: 1.5em 0; }
& h6 { font-size: 0.75em; margin: 1.67em 0; }
& ul, & ol {
padding-left: 2em;
margin: 1em 0;
}
& p {
margin: 1em 0;
}
& code {
background-color: #f5f5f5;
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: monospace;
}
& pre {
background-color: #f5f5f5;
padding: 1em;
border-radius: 4px;
overflow-x: auto;
}
& blockquote {
margin: 1em 0;
padding-left: 1em;
border-left: 4px solid #ddd;
color: #666;
}
& table {
border-collapse: collapse;
margin: 1em 0;
}
& th, & td {
border: 1px solid #ddd;
padding: 6px 13px;
}
& tr:nth-child(2n) {
background-color: #f8f8f8;
}
}
.transparent {
opacity: 0 !important;
transition: opacity 0.25s ease-in-out;
}
.canvas-mode {
overflow: hidden;
& #toggle-physics {
display: block;
}
}
.tldraw__editor {
overscroll-behavior: none;
position: fixed;
inset: 0px;
overflow: hidden;
touch-action: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
.tl-background {
background-color: transparent;
}
.tlui-debug-panel {
display: none;
}
.overflowing {
box-shadow: 0 0px 16px rgba(0, 0, 0, 0.15);
overflow: hidden;
background-color: white;
}
.lock-indicator {
position: absolute;
width: 24px;
height: 24px;
background: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1000;
transition: transform 0.2s ease;
}
.lock-indicator:hover {
transform: scale(1.1) !important;
background: #f0f0f0;
}
/* Presentations Page Styles */
.presentations-grid {
display: grid;
grid-template-columns: 1fr;
gap: 3em;
margin: 2em 0;
}
.presentation-card {
border: 1px solid #e1e4e8;
border-radius: 8px;
padding: 1.5em;
background-color: #fafbfc;
transition: all 0.2s ease;
}
.presentation-card:hover {
border-color: #0366d6;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.presentation-card h3 {
margin-top: 0;
margin-bottom: 0.5em;
color: #24292e;
font-size: 1.3rem;
}
.presentation-card p {
margin-bottom: 1em;
color: #586069;
font-size: 1rem;
}
.presentation-embed {
margin: 1.5em 0;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.presentation-embed iframe {
display: block;
border: none;
background-color: #fff;
}
.presentation-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1em;
padding-top: 1em;
border-top: 1px solid #e1e4e8;
font-size: 0.9rem;
}
.presentation-meta span {
color: #586069;
font-style: italic;
}
.presentation-meta a {
color: #0366d6;
text-decoration: none;
font-weight: 500;
}
.presentation-meta a:hover {
text-decoration: underline;
}
.presentations-info {
margin-top: 3em;
padding: 2em;
background-color: #f6f8fa;
border-radius: 8px;
border-left: 4px solid #0366d6;
}
.presentations-info h3 {
margin-top: 0;
color: #24292e;
}
.presentations-info p {
margin-bottom: 1em;
color: #586069;
}
.presentations-info a {
color: #0366d6;
text-decoration: none;
}
.presentations-info a:hover {
text-decoration: underline;
}
/* Responsive design for presentations */
@media (max-width: 768px) {
.presentations-grid {
gap: 2em;
}
.presentation-card {
padding: 1em;
}
.presentation-meta {
flex-direction: column;
align-items: flex-start;
gap: 0.5em;
}
.presentation-embed iframe {
height: 400px;
}
}
/* Resilience page styles */
.presentation-info {
margin-bottom: 3rem;
padding: 2rem;
background-color: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #0366d6;
}
.presentation-info h1 {
margin-bottom: 1rem;
color: #24292e;
}
.presentation-info p {
margin-bottom: 1rem;
line-height: 1.6;
}
.video-clips {
margin-top: 3rem;
}
.video-clips h2 {
margin-bottom: 2rem;
color: #24292e;
}
.video-section {
margin-bottom: 3rem;
}
.video-section h3 {
margin-bottom: 1rem;
color: #24292e;
font-size: 1.2rem;
}
.video-container {
position: relative;
width: 100%;
max-width: 560px;
margin: 0 auto;
}
.video-container iframe {
width: 100%;
height: 315px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.presentation-embed h2 {
margin-bottom: 1rem;
color: #24292e;
}
.presentation-meta {
margin-top: 3rem;
padding: 2rem;
background-color: #f8f9fa;
border-radius: 8px;
border-top: 4px solid #0366d6;
}
.presentation-meta p {
margin-bottom: 1rem;
line-height: 1.6;
}
.presentation-meta a {
color: #0366d6;
text-decoration: none;
font-weight: 500;
}
.presentation-meta a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.video-container iframe {
height: 200px;
}
.presentation-info,
.presentation-meta {
padding: 1rem;
margin-left: -1rem;
margin-right: -1rem;
}
}
/* Command Palette Styles */
[cmdk-root] {
z-index: 9999 !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
}
[cmdk-dialog] {
padding: 0.5em;
width: 100%;
max-width: 35em;
border: 1px solid #c7c7c7;
border-radius: var(--border-radius);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
background-color: white;
position: fixed;
top: 30%;
left: 50%;
transform: translate(-50%, 0);
z-index: 9999 !important;
& input {
font-size: 1.4em;
width: 100%;
background-color: transparent;
border: none;
outline: none;
padding: 0.2em;
background-color: #f8f8f8;
margin-bottom: 0.2em;
&:focus {
outline: none;
border-radius: 3px;
background-color: #f0f0f0;
}
}
}
[cmdk-group-heading] {
font-size: 1.2em;
opacity: 0.5;
padding: 0.2em;
}
[cmdk-item] {
padding: 0.2em;
font-size: 1.2em;
& .tlui-kbd {
border: 1px solid #c7c7c7;
border-radius: 3px;
padding: 0.2em;
padding-bottom: 0.1em;
font-size: 0.8em;
opacity: 0.5;
}
}
[cmdk-item]:hover {
border-radius: 3px;
background-color: #f0f0f0;
}
[cmdk-empty] {
font-size: 1.2em;
opacity: 0.5;
padding: 0.2em;
}
[cmdk-overlay] {
z-index: 9998 !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
background: rgba(0, 0, 0, 0.5) !important;
}
/* Ensure command palette renders above Tldraw canvas */
.tldraw__editor [cmdk-root] {
position: fixed !important;
z-index: 9999 !important;
}
.tldraw__editor [cmdk-dialog] {
position: fixed !important;
z-index: 9999 !important;
}
.tldraw__editor [cmdk-overlay] {
position: fixed !important;
z-index: 9998 !important;
}
/* Command Palette Specific Styles */
.command-palette .duration-300 {
transition-duration: 0s; /* Set your desired duration */
}
.command-palette .duration-200 {
transition-duration: 0s; /* Set your desired duration */
}
.command-palette .bg-opacity-80 {
display: none;
}
.command-palette .llm-response {
display: block;
height: 100%;
width: 100%;
opacity: 1;
}
.llm-response {
margin-top: 0 !important;
}
.references {
opacity: 1 !important;
}
.command-palette .llm-response div {
display: block;
height: 100%;
width: 100%;
}
.command-palette .llm-response span {
height: 500px;
white-space: pre-line;
}
.references * {
color: white;
}
.reference {
color: #40cf66;
margin-left: 0.2em !important;
padding-right: 0.1em;
padding-left: 0.1em;
&:hover {
background-color: #40cf664d;
}
border-radius: 3px;
}
.reference-missing {
margin-left: 0.2em !important;
padding-right: 0.1em;
padding-left: 0.1em;
color: #fc8958;
}

77
src/css/user-profile.css Normal file
View File

@ -0,0 +1,77 @@
/* Custom User Profile Styles */
.custom-user-profile {
position: absolute;
top: 8px;
right: 8px;
z-index: 1000;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 8px 12px;
font-size: 14px;
font-weight: 500;
color: #333;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 8px;
user-select: none;
pointer-events: none;
transition: all 0.2s ease;
animation: profileSlideIn 0.3s ease-out;
}
.custom-user-profile .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
flex-shrink: 0;
animation: pulse 2s infinite;
}
.custom-user-profile .username {
font-weight: 600;
letter-spacing: 0.5px;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.custom-user-profile {
background: rgba(45, 45, 45, 0.9);
border-color: rgba(255, 255, 255, 0.1);
color: #e9ecef;
}
}
/* Animations */
@keyframes profileSlideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* Responsive design */
@media (max-width: 768px) {
.custom-user-profile {
top: 4px;
right: 4px;
padding: 6px 10px;
font-size: 12px;
}
}

802
src/default_gestures.ts Normal file
View File

@ -0,0 +1,802 @@
import { Gesture } from "@/gestures"
import { Editor, TLDrawShape, TLShape, VecLike, createShapeId } from "tldraw"
const getShapesUnderGesture = (editor: Editor, gesture: TLDrawShape) => {
const bounds = editor.getShapePageBounds(gesture.id)
return editor.getShapesAtPoint(bounds?.center!, {
margin: (bounds?.width! + bounds?.height!) / 4,
}).filter((shape) => shape.id !== gesture.id)
}
/** Returns shapes arranged in a circle around the given origin */
const circleDistribution = (editor: Editor, shapes: TLShape[], origin: VecLike, radius: number): TLShape[] => {
const angleStep = (2 * Math.PI) / shapes.length;
return shapes.map((shape, index) => {
const { w, h } = editor.getShapeGeometry(shape.id).bounds
const angle = index * angleStep;
const pointOnCircle = {
x: origin.x + radius * Math.cos(angle),
y: origin.y + radius * Math.sin(angle),
};
const shapeAngle = angle + Math.PI / 2;
const pos = posFromRotatedCenter(pointOnCircle, w, h, shapeAngle);
return {
...shape,
x: pos.x,
y: pos.y,
rotation: shapeAngle,
};
});
}
/** Returns shapes arranged in a triangle around the given origin */
const triangleDistribution = (editor: Editor, shapes: TLShape[], origin: VecLike, radius: number): TLShape[] => {
const vertices = [
{ x: origin.x - radius, y: origin.y + radius }, // Bottom left
{ x: origin.x + radius, y: origin.y + radius }, // Bottom right
{ x: origin.x, y: origin.y - radius }, // Top middle
];
const totalShapes = shapes.length;
const shapesPerEdge = Math.ceil(totalShapes / 3);
return shapes.map((shape, index) => {
const edgeIndex = Math.floor(index / shapesPerEdge);
const edgeStart = vertices[edgeIndex];
const edgeEnd = vertices[(edgeIndex + 1) % 3];
const t = (index % shapesPerEdge) / shapesPerEdge;
const pointOnEdge = {
x: edgeStart.x + t * (edgeEnd.x - edgeStart.x),
y: edgeStart.y + t * (edgeEnd.y - edgeStart.y),
};
let shapeAngle;
if (index % shapesPerEdge === 0) {
// Shape is at a vertex, adjust angle to face away from the triangle
const vertex = vertices[edgeIndex];
shapeAngle = Math.atan2(vertex.y - origin.y, vertex.x - origin.x);
} else {
// Shape is on an edge
shapeAngle = Math.atan2(edgeEnd.y - edgeStart.y, edgeEnd.x - edgeStart.x);
}
const { w, h } = editor.getShapeGeometry(shape.id).bounds;
const pos = posFromRotatedCenter(pointOnEdge, w, h, shapeAngle);
return {
...shape,
x: pos.x,
y: pos.y,
rotation: shapeAngle,
};
});
}
/** Calculates the top-left position of a shape given a center point, width, height, and rotation (radians) */
/** its origin x/y and rotation are around the top-left corner of the shape */
const posFromRotatedCenter = (center: VecLike, w: number, h: number, rotation: number): VecLike => {
const halfWidth = w / 2;
const halfHeight = h / 2;
const cosTheta = Math.cos(rotation);
const sinTheta = Math.sin(rotation);
const topLeftX = center.x - (halfWidth * cosTheta - halfHeight * sinTheta);
const topLeftY = center.y - (halfWidth * sinTheta + halfHeight * cosTheta);
return { x: topLeftX, y: topLeftY };
}
export const DEFAULT_GESTURES: Gesture[] = [
{
name: "x",
onComplete(editor) {
editor.deleteShapes(editor.getSelectedShapes())
},
points: [
{ x: 87, y: 142 },
{ x: 89, y: 145 },
{ x: 91, y: 148 },
{ x: 93, y: 151 },
{ x: 96, y: 155 },
{ x: 98, y: 157 },
{ x: 100, y: 160 },
{ x: 102, y: 162 },
{ x: 106, y: 167 },
{ x: 108, y: 169 },
{ x: 110, y: 171 },
{ x: 115, y: 177 },
{ x: 119, y: 183 },
{ x: 123, y: 189 },
{ x: 127, y: 193 },
{ x: 129, y: 196 },
{ x: 133, y: 200 },
{ x: 137, y: 206 },
{ x: 140, y: 209 },
{ x: 143, y: 212 },
{ x: 146, y: 215 },
{ x: 151, y: 220 },
{ x: 153, y: 222 },
{ x: 155, y: 223 },
{ x: 157, y: 225 },
{ x: 158, y: 223 },
{ x: 157, y: 218 },
{ x: 155, y: 211 },
{ x: 154, y: 208 },
{ x: 152, y: 200 },
{ x: 150, y: 189 },
{ x: 148, y: 179 },
{ x: 147, y: 170 },
{ x: 147, y: 158 },
{ x: 147, y: 148 },
{ x: 147, y: 141 },
{ x: 147, y: 136 },
{ x: 144, y: 135 },
{ x: 142, y: 137 },
{ x: 140, y: 139 },
{ x: 135, y: 145 },
{ x: 131, y: 152 },
{ x: 124, y: 163 },
{ x: 116, y: 177 },
{ x: 108, y: 191 },
{ x: 100, y: 206 },
{ x: 94, y: 217 },
{ x: 91, y: 222 },
{ x: 89, y: 225 },
{ x: 87, y: 226 },
{ x: 87, y: 224 },
],
},
{
name: "rectangle",
onComplete(editor, gesture?: TLDrawShape) {
const bounds = editor.getShapePageBounds(gesture?.id!)
const { w, h, center } = bounds!
editor.createShape({
id: createShapeId(),
type: "geo",
x: center?.x! - w / 2,
y: center?.y! - h / 2,
props: {
fill: "solid",
w: w,
h: h,
},
})
},
points: [
{ x: 78, y: 149 },
{ x: 78, y: 153 },
{ x: 78, y: 157 },
{ x: 78, y: 160 },
{ x: 79, y: 162 },
{ x: 79, y: 164 },
{ x: 79, y: 167 },
{ x: 79, y: 169 },
{ x: 79, y: 173 },
{ x: 79, y: 178 },
{ x: 79, y: 183 },
{ x: 80, y: 189 },
{ x: 80, y: 193 },
{ x: 80, y: 198 },
{ x: 80, y: 202 },
{ x: 81, y: 208 },
{ x: 81, y: 210 },
{ x: 81, y: 216 },
{ x: 82, y: 222 },
{ x: 82, y: 224 },
{ x: 82, y: 227 },
{ x: 83, y: 229 },
{ x: 83, y: 231 },
{ x: 85, y: 230 },
{ x: 88, y: 232 },
{ x: 90, y: 233 },
{ x: 92, y: 232 },
{ x: 94, y: 233 },
{ x: 99, y: 232 },
{ x: 102, y: 233 },
{ x: 106, y: 233 },
{ x: 109, y: 234 },
{ x: 117, y: 235 },
{ x: 123, y: 236 },
{ x: 126, y: 236 },
{ x: 135, y: 237 },
{ x: 142, y: 238 },
{ x: 145, y: 238 },
{ x: 152, y: 238 },
{ x: 154, y: 239 },
{ x: 165, y: 238 },
{ x: 174, y: 237 },
{ x: 179, y: 236 },
{ x: 186, y: 235 },
{ x: 191, y: 235 },
{ x: 195, y: 233 },
{ x: 197, y: 233 },
{ x: 200, y: 233 },
{ x: 201, y: 235 },
{ x: 201, y: 233 },
{ x: 199, y: 231 },
{ x: 198, y: 226 },
{ x: 198, y: 220 },
{ x: 196, y: 207 },
{ x: 195, y: 195 },
{ x: 195, y: 181 },
{ x: 195, y: 173 },
{ x: 195, y: 163 },
{ x: 194, y: 155 },
{ x: 192, y: 145 },
{ x: 192, y: 143 },
{ x: 192, y: 138 },
{ x: 191, y: 135 },
{ x: 191, y: 133 },
{ x: 191, y: 130 },
{ x: 190, y: 128 },
{ x: 188, y: 129 },
{ x: 186, y: 129 },
{ x: 181, y: 132 },
{ x: 173, y: 131 },
{ x: 162, y: 131 },
{ x: 151, y: 132 },
{ x: 149, y: 132 },
{ x: 138, y: 132 },
{ x: 136, y: 132 },
{ x: 122, y: 131 },
{ x: 120, y: 131 },
{ x: 109, y: 130 },
{ x: 107, y: 130 },
{ x: 90, y: 132 },
{ x: 81, y: 133 },
{ x: 76, y: 133 },
],
},
{
name: "circle",
onComplete(editor, gesture?: TLDrawShape) {
const selection = getShapesUnderGesture(editor, gesture!)
editor.setSelectedShapes(selection)
editor.setHintingShapes(selection)
},
points: [
{ x: 127, y: 141 },
{ x: 124, y: 140 },
{ x: 120, y: 139 },
{ x: 118, y: 139 },
{ x: 116, y: 139 },
{ x: 111, y: 140 },
{ x: 109, y: 141 },
{ x: 104, y: 144 },
{ x: 100, y: 147 },
{ x: 96, y: 152 },
{ x: 93, y: 157 },
{ x: 90, y: 163 },
{ x: 87, y: 169 },
{ x: 85, y: 175 },
{ x: 83, y: 181 },
{ x: 82, y: 190 },
{ x: 82, y: 195 },
{ x: 83, y: 200 },
{ x: 84, y: 205 },
{ x: 88, y: 213 },
{ x: 91, y: 216 },
{ x: 96, y: 219 },
{ x: 103, y: 222 },
{ x: 108, y: 224 },
{ x: 111, y: 224 },
{ x: 120, y: 224 },
{ x: 133, y: 223 },
{ x: 142, y: 222 },
{ x: 152, y: 218 },
{ x: 160, y: 214 },
{ x: 167, y: 210 },
{ x: 173, y: 204 },
{ x: 178, y: 198 },
{ x: 179, y: 196 },
{ x: 182, y: 188 },
{ x: 182, y: 177 },
{ x: 178, y: 167 },
{ x: 170, y: 150 },
{ x: 163, y: 138 },
{ x: 152, y: 130 },
{ x: 143, y: 129 },
{ x: 140, y: 131 },
{ x: 129, y: 136 },
{ x: 126, y: 139 },
],
},
{
name: "check",
onComplete(editor, gesture?: TLDrawShape) {
const originPoint = { x: gesture?.x!, y: gesture?.y! }
const shapeAtOrigin = editor.getShapesAtPoint(originPoint, {
hitInside: true,
margin: 10,
})
for (const shape of shapeAtOrigin) {
if (shape.id === gesture?.id) continue
editor.updateShape({
...shape,
props: {
...shape.props,
color: "green",
},
})
}
},
points: [
{ x: 91, y: 185 },
{ x: 93, y: 185 },
{ x: 95, y: 185 },
{ x: 97, y: 185 },
{ x: 100, y: 188 },
{ x: 102, y: 189 },
{ x: 104, y: 190 },
{ x: 106, y: 193 },
{ x: 108, y: 195 },
{ x: 110, y: 198 },
{ x: 112, y: 201 },
{ x: 114, y: 204 },
{ x: 115, y: 207 },
{ x: 117, y: 210 },
{ x: 118, y: 212 },
{ x: 120, y: 214 },
{ x: 121, y: 217 },
{ x: 122, y: 219 },
{ x: 123, y: 222 },
{ x: 124, y: 224 },
{ x: 126, y: 226 },
{ x: 127, y: 229 },
{ x: 129, y: 231 },
{ x: 130, y: 233 },
{ x: 129, y: 231 },
{ x: 129, y: 228 },
{ x: 129, y: 226 },
{ x: 129, y: 224 },
{ x: 129, y: 221 },
{ x: 129, y: 218 },
{ x: 129, y: 212 },
{ x: 129, y: 208 },
{ x: 130, y: 198 },
{ x: 132, y: 189 },
{ x: 134, y: 182 },
{ x: 137, y: 173 },
{ x: 143, y: 164 },
{ x: 147, y: 157 },
{ x: 151, y: 151 },
{ x: 155, y: 144 },
{ x: 161, y: 137 },
{ x: 165, y: 131 },
{ x: 171, y: 122 },
{ x: 174, y: 118 },
{ x: 176, y: 114 },
{ x: 177, y: 112 },
{ x: 177, y: 114 },
{ x: 175, y: 116 },
{ x: 173, y: 118 },
],
},
{
name: "caret",
onComplete(editor) {
editor.alignShapes(editor.getSelectedShapes(), "top")
},
points: [
{ x: 79, y: 245 },
{ x: 79, y: 242 },
{ x: 79, y: 239 },
{ x: 80, y: 237 },
{ x: 80, y: 234 },
{ x: 81, y: 232 },
{ x: 82, y: 230 },
{ x: 84, y: 224 },
{ x: 86, y: 220 },
{ x: 86, y: 218 },
{ x: 87, y: 216 },
{ x: 88, y: 213 },
{ x: 90, y: 207 },
{ x: 91, y: 202 },
{ x: 92, y: 200 },
{ x: 93, y: 194 },
{ x: 94, y: 192 },
{ x: 96, y: 189 },
{ x: 97, y: 186 },
{ x: 100, y: 179 },
{ x: 102, y: 173 },
{ x: 105, y: 165 },
{ x: 107, y: 160 },
{ x: 109, y: 158 },
{ x: 112, y: 151 },
{ x: 115, y: 144 },
{ x: 117, y: 139 },
{ x: 119, y: 136 },
{ x: 119, y: 134 },
{ x: 120, y: 132 },
{ x: 121, y: 129 },
{ x: 122, y: 127 },
{ x: 124, y: 125 },
{ x: 126, y: 124 },
{ x: 129, y: 125 },
{ x: 131, y: 127 },
{ x: 132, y: 130 },
{ x: 136, y: 139 },
{ x: 141, y: 154 },
{ x: 145, y: 166 },
{ x: 151, y: 182 },
{ x: 156, y: 193 },
{ x: 157, y: 196 },
{ x: 161, y: 209 },
{ x: 162, y: 211 },
{ x: 167, y: 223 },
{ x: 169, y: 229 },
{ x: 170, y: 231 },
{ x: 173, y: 237 },
{ x: 176, y: 242 },
{ x: 177, y: 244 },
{ x: 179, y: 250 },
{ x: 181, y: 255 },
{ x: 182, y: 257 },
],
},
// {
// name: "zig-zag",
// points: [
// { x: 307, y: 216 },
// { x: 333, y: 186 },
// { x: 356, y: 215 },
// { x: 375, y: 186 },
// { x: 399, y: 216 },
// { x: 418, y: 186 },
// ],
// },
{
name: "v",
onComplete(editor) {
editor.alignShapes(editor.getSelectedShapes(), "bottom")
},
points: [
{ x: 89, y: 164 },
{ x: 90, y: 162 },
{ x: 92, y: 162 },
{ x: 94, y: 164 },
{ x: 95, y: 166 },
{ x: 96, y: 169 },
{ x: 97, y: 171 },
{ x: 99, y: 175 },
{ x: 101, y: 178 },
{ x: 103, y: 182 },
{ x: 106, y: 189 },
{ x: 108, y: 194 },
{ x: 111, y: 199 },
{ x: 114, y: 204 },
{ x: 117, y: 209 },
{ x: 119, y: 214 },
{ x: 122, y: 218 },
{ x: 124, y: 222 },
{ x: 126, y: 225 },
{ x: 128, y: 228 },
{ x: 130, y: 229 },
{ x: 133, y: 233 },
{ x: 134, y: 236 },
{ x: 136, y: 239 },
{ x: 138, y: 240 },
{ x: 139, y: 242 },
{ x: 140, y: 244 },
{ x: 142, y: 242 },
{ x: 142, y: 240 },
{ x: 142, y: 237 },
{ x: 143, y: 235 },
{ x: 143, y: 233 },
{ x: 145, y: 229 },
{ x: 146, y: 226 },
{ x: 148, y: 217 },
{ x: 149, y: 208 },
{ x: 149, y: 205 },
{ x: 151, y: 196 },
{ x: 151, y: 193 },
{ x: 153, y: 182 },
{ x: 155, y: 172 },
{ x: 157, y: 165 },
{ x: 159, y: 160 },
{ x: 162, y: 155 },
{ x: 164, y: 150 },
{ x: 165, y: 148 },
{ x: 166, y: 146 },
],
},
{
name: "delete",
onComplete(editor) {
editor.deleteShapes(editor.getSelectedShapes())
},
points: [
{ x: 123, y: 129 },
{ x: 123, y: 131 },
{ x: 124, y: 133 },
{ x: 125, y: 136 },
{ x: 127, y: 140 },
{ x: 129, y: 142 },
{ x: 133, y: 148 },
{ x: 137, y: 154 },
{ x: 143, y: 158 },
{ x: 145, y: 161 },
{ x: 148, y: 164 },
{ x: 153, y: 170 },
{ x: 158, y: 176 },
{ x: 160, y: 178 },
{ x: 164, y: 183 },
{ x: 168, y: 188 },
{ x: 171, y: 191 },
{ x: 175, y: 196 },
{ x: 178, y: 200 },
{ x: 180, y: 202 },
{ x: 181, y: 205 },
{ x: 184, y: 208 },
{ x: 186, y: 210 },
{ x: 187, y: 213 },
{ x: 188, y: 215 },
{ x: 186, y: 212 },
{ x: 183, y: 211 },
{ x: 177, y: 208 },
{ x: 169, y: 206 },
{ x: 162, y: 205 },
{ x: 154, y: 207 },
{ x: 145, y: 209 },
{ x: 137, y: 210 },
{ x: 129, y: 214 },
{ x: 122, y: 217 },
{ x: 118, y: 218 },
{ x: 111, y: 221 },
{ x: 109, y: 222 },
{ x: 110, y: 219 },
{ x: 112, y: 217 },
{ x: 118, y: 209 },
{ x: 120, y: 207 },
{ x: 128, y: 196 },
{ x: 135, y: 187 },
{ x: 138, y: 183 },
{ x: 148, y: 167 },
{ x: 157, y: 153 },
{ x: 163, y: 145 },
{ x: 165, y: 142 },
{ x: 172, y: 133 },
{ x: 177, y: 127 },
{ x: 179, y: 127 },
{ x: 180, y: 125 },
],
},
{
name: "pigtail",
onComplete(editor, gesture?: TLDrawShape) {
const shapes = getShapesUnderGesture(editor, gesture!)
editor.setSelectedShapes(shapes)
editor.setHintingShapes(shapes)
editor.animateShapes(shapes.map((shape) => ({
...shape,
rotation: shape.rotation + (Math.PI / -2),
})),
{
animation: {
duration: 600,
easing: (t) => t * t * (3 - 2 * t),
},
},
)
},
points: [
{ x: 81, y: 219 },
{ x: 84, y: 218 },
{ x: 86, y: 220 },
{ x: 88, y: 220 },
{ x: 90, y: 220 },
{ x: 92, y: 219 },
{ x: 95, y: 220 },
{ x: 97, y: 219 },
{ x: 99, y: 220 },
{ x: 102, y: 218 },
{ x: 105, y: 217 },
{ x: 107, y: 216 },
{ x: 110, y: 216 },
{ x: 113, y: 214 },
{ x: 116, y: 212 },
{ x: 118, y: 210 },
{ x: 121, y: 208 },
{ x: 124, y: 205 },
{ x: 126, y: 202 },
{ x: 129, y: 199 },
{ x: 132, y: 196 },
{ x: 136, y: 191 },
{ x: 139, y: 187 },
{ x: 142, y: 182 },
{ x: 144, y: 179 },
{ x: 146, y: 174 },
{ x: 148, y: 170 },
{ x: 149, y: 168 },
{ x: 151, y: 162 },
{ x: 152, y: 160 },
{ x: 152, y: 157 },
{ x: 152, y: 155 },
{ x: 152, y: 151 },
{ x: 152, y: 149 },
{ x: 152, y: 146 },
{ x: 149, y: 142 },
{ x: 148, y: 139 },
{ x: 145, y: 137 },
{ x: 141, y: 135 },
{ x: 139, y: 135 },
{ x: 134, y: 136 },
{ x: 130, y: 140 },
{ x: 128, y: 142 },
{ x: 126, y: 145 },
{ x: 122, y: 150 },
{ x: 119, y: 158 },
{ x: 117, y: 163 },
{ x: 115, y: 170 },
{ x: 114, y: 175 },
{ x: 117, y: 184 },
{ x: 120, y: 190 },
{ x: 125, y: 199 },
{ x: 129, y: 203 },
{ x: 133, y: 208 },
{ x: 138, y: 213 },
{ x: 145, y: 215 },
{ x: 155, y: 218 },
{ x: 164, y: 219 },
{ x: 166, y: 219 },
{ x: 177, y: 219 },
{ x: 182, y: 218 },
{ x: 192, y: 216 },
{ x: 196, y: 213 },
{ x: 199, y: 212 },
{ x: 201, y: 211 },
],
},
]
export const ALT_GESTURES: Gesture[] = [
{
name: "circle layout",
onComplete(editor, gesture?: TLDrawShape) {
const bounds = editor.getShapePageBounds(gesture?.id!)
const center = bounds?.center
const radius = Math.max(bounds?.width || 0, bounds?.height || 0) / 2
const selected = editor.getSelectedShapes()
const radialShapes = circleDistribution(editor, selected, center!, radius)
editor.animateShapes(radialShapes, {
animation: {
duration: 600,
easing: (t) => t * t * (3 - 2 * t),
},
})
},
points: [
{ x: 127, y: 141 },
{ x: 124, y: 140 },
{ x: 120, y: 139 },
{ x: 118, y: 139 },
{ x: 116, y: 139 },
{ x: 111, y: 140 },
{ x: 109, y: 141 },
{ x: 104, y: 144 },
{ x: 100, y: 147 },
{ x: 96, y: 152 },
{ x: 93, y: 157 },
{ x: 90, y: 163 },
{ x: 87, y: 169 },
{ x: 85, y: 175 },
{ x: 83, y: 181 },
{ x: 82, y: 190 },
{ x: 82, y: 195 },
{ x: 83, y: 200 },
{ x: 84, y: 205 },
{ x: 88, y: 213 },
{ x: 91, y: 216 },
{ x: 96, y: 219 },
{ x: 103, y: 222 },
{ x: 108, y: 224 },
{ x: 111, y: 224 },
{ x: 120, y: 224 },
{ x: 133, y: 223 },
{ x: 142, y: 222 },
{ x: 152, y: 218 },
{ x: 160, y: 214 },
{ x: 167, y: 210 },
{ x: 173, y: 204 },
{ x: 178, y: 198 },
{ x: 179, y: 196 },
{ x: 182, y: 188 },
{ x: 182, y: 177 },
{ x: 178, y: 167 },
{ x: 170, y: 150 },
{ x: 163, y: 138 },
{ x: 152, y: 130 },
{ x: 143, y: 129 },
{ x: 140, y: 131 },
{ x: 129, y: 136 },
{ x: 126, y: 139 },
],
},
{
name: "triangle layout",
onComplete(editor, gesture?: TLDrawShape) {
const bounds = editor.getShapePageBounds(gesture?.id!)
const center = bounds?.center
const radius = Math.max(bounds?.width || 0, bounds?.height || 0) / 2
const selected = editor.getSelectedShapes()
const radialShapes = triangleDistribution(editor, selected, center!, radius)
editor.animateShapes(radialShapes, {
animation: {
duration: 600,
easing: (t) => t * t * (3 - 2 * t),
},
})
},
points: [
{ x: 137, y: 139 },
{ x: 135, y: 141 },
{ x: 133, y: 144 },
{ x: 132, y: 146 },
{ x: 130, y: 149 },
{ x: 128, y: 151 },
{ x: 126, y: 155 },
{ x: 123, y: 160 },
{ x: 120, y: 166 },
{ x: 116, y: 171 },
{ x: 112, y: 177 },
{ x: 107, y: 183 },
{ x: 102, y: 188 },
{ x: 100, y: 191 },
{ x: 95, y: 195 },
{ x: 90, y: 199 },
{ x: 86, y: 203 },
{ x: 82, y: 206 },
{ x: 80, y: 209 },
{ x: 75, y: 213 },
{ x: 73, y: 213 },
{ x: 70, y: 216 },
{ x: 67, y: 219 },
{ x: 64, y: 221 },
{ x: 61, y: 223 },
{ x: 60, y: 225 },
{ x: 62, y: 226 },
{ x: 65, y: 225 },
{ x: 67, y: 226 },
{ x: 74, y: 226 },
{ x: 77, y: 227 },
{ x: 85, y: 229 },
{ x: 91, y: 230 },
{ x: 99, y: 231 },
{ x: 108, y: 232 },
{ x: 116, y: 233 },
{ x: 125, y: 233 },
{ x: 134, y: 234 },
{ x: 145, y: 233 },
{ x: 153, y: 232 },
{ x: 160, y: 233 },
{ x: 170, y: 234 },
{ x: 177, y: 235 },
{ x: 179, y: 236 },
{ x: 186, y: 237 },
{ x: 193, y: 238 },
{ x: 198, y: 239 },
{ x: 200, y: 237 },
{ x: 202, y: 239 },
{ x: 204, y: 238 },
{ x: 206, y: 234 },
{ x: 205, y: 230 },
{ x: 202, y: 222 },
{ x: 197, y: 216 },
{ x: 192, y: 207 },
{ x: 186, y: 198 },
{ x: 179, y: 189 },
{ x: 174, y: 183 },
{ x: 170, y: 178 },
{ x: 164, y: 171 },
{ x: 161, y: 168 },
{ x: 154, y: 160 },
{ x: 148, y: 155 },
{ x: 143, y: 150 },
{ x: 138, y: 148 },
{ x: 136, y: 148 },
],
},
]

322
src/gestures.ts Normal file
View File

@ -0,0 +1,322 @@
/** Modified $1 for TS & tldraw */
/**
* The $1 Unistroke Recognizer (JavaScript version)
*
* Jacob O. Wobbrock, Ph.D.
* The Information School
* University of Washington
* Seattle, WA 98195-2840
* wobbrock@uw.edu
*
* Andrew D. Wilson, Ph.D.
* Microsoft Research
* One Microsoft Way
* Redmond, WA 98052
* awilson@microsoft.com
*
* Yang Li, Ph.D.
* Department of Computer Science and Engineering
* University of Washington
* Seattle, WA 98195-2840
* yangli@cs.washington.edu
*
* The academic publication for the $1 recognizer, and what should be
* used to cite it, is:
*
* Wobbrock, J.O., Wilson, A.D. and Li, Y. (2007). Gestures without
* libraries, toolkits or training: A $1 recognizer for user interface
* prototypes. Proceedings of the ACM Symposium on User Interface
* Software and Technology (UIST '07). Newport, Rhode Island (October
* 7-10, 2007). New York: ACM Press, pp. 159-168.
* https://dl.acm.org/citation.cfm?id=1294238
*
* The Protractor enhancement was separately published by Yang Li and programmed
* here by Jacob O. Wobbrock:
*
* Li, Y. (2010). Protractor: A fast and accurate gesture
* recognizer. Proceedings of the ACM Conference on Human
* Factors in Computing Systems (CHI '10). Atlanta, Georgia
* (April 10-15, 2010). New York: ACM Press, pp. 2169-2172.
* https://dl.acm.org/citation.cfm?id=1753654
*
* This software is distributed under the "New BSD License" agreement:
*
* Copyright (C) 2007-2012, Jacob O. Wobbrock, Andrew D. Wilson and Yang Li.
* All rights reserved. Last updated July 14, 2018.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* * Neither the names of the University of Washington nor Microsoft,
* nor the names of its contributors may be used to endorse or promote
* products derived from this software without specific prior written
* permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
* IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Jacob O. Wobbrock OR Andrew D. Wilson
* OR Yang Li BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
**/
import { Editor, TLDrawShape, VecLike, BoxModel } from "tldraw"
const NUM_POINTS = 64
const SQUARE_SIZE = 250.0
const ORIGIN = { x: 0, y: 0 }
interface Result {
name: string
score: number
time: number
onComplete?: (editor: Editor, gesture?: TLDrawShape) => void
}
export interface Gesture {
name: string
points: VecLike[]
onComplete?: (editor: Editor, gesture?: TLDrawShape) => void
}
class Unistroke {
name: string
points: VecLike[]
vector: number[]
private _originalPoints: VecLike[]
onComplete?: (editor: Editor, gesture?: TLDrawShape) => void
constructor(
name: string,
points: VecLike[],
onComplete?: (editor: Editor, gesture?: TLDrawShape) => void,
) {
this.name = name
this.onComplete = onComplete
this._originalPoints = points
this.points = Resample(points, NUM_POINTS)
const radians = IndicativeAngle(this.points)
this.points = RotateBy(this.points, -radians)
this.points = ScaleTo(this.points, SQUARE_SIZE)
this.points = TranslateTo(this.points, ORIGIN)
this.vector = Vectorize(this.points) // for Protractor
}
originalPoints(): VecLike[] {
return this._originalPoints
}
}
export class DollarRecognizer {
unistrokes: Unistroke[] = []
constructor(gestures: Gesture[]) {
for (const gesture of gestures) {
this.unistrokes.push(
new Unistroke(gesture.name, gesture.points, gesture.onComplete),
)
}
}
/**
* Recognize a gesture
* @param points The points of the gesture
* @returns The result
*/
recognize(points: VecLike[]): Result {
const t0 = Date.now()
const candidate = new Unistroke("", points)
let u = -1
let b = +Infinity
for (
let i = 0;
i < this.unistrokes.length;
i++ // for each unistroke template
) {
const d = OptimalCosineDistance(
this.unistrokes[i].vector,
candidate.vector,
) // Protractor
if (d < b) {
b = d // best (least) distance
u = i // unistroke index
}
}
const t1 = Date.now()
return u === -1
? { name: "No match.", score: 0.0, time: t1 - t0 }
: {
name: this.unistrokes[u].name,
score: 1.0 - b,
time: t1 - t0,
onComplete: this.unistrokes[u].onComplete,
}
}
/**
* Add a gesture to the recognizer
* @param name The name of the gesture
* @param points The points of the gesture
* @returns The number of gestures
*/
addGesture(name: string, points: VecLike[]): number {
this.unistrokes[this.unistrokes.length] = new Unistroke(name, points) // append new unistroke
let num = 0
for (let i = 0; i < this.unistrokes.length; i++) {
if (this.unistrokes[i].name === name) num++
}
return num
}
/**
* Remove a gesture from the recognizer
* @param name The name of the gesture
* @returns The number of gestures after removal
*/
removeGesture(name: string): number {
this.unistrokes = this.unistrokes.filter((gesture) => gesture.name !== name)
return this.unistrokes.length
}
}
//
// Private helper functions from here on down
//
function Resample(points: VecLike[], n: number): VecLike[] {
const I = PathLength(points) / (n - 1) // interval length
let D = 0.0
const newpoints = new Array(points[0])
for (let i = 1; i < points.length; i++) {
const d = Distance(points[i - 1], points[i])
if (D + d >= I) {
const qx =
points[i - 1].x + ((I - D) / d) * (points[i].x - points[i - 1].x)
const qy =
points[i - 1].y + ((I - D) / d) * (points[i].y - points[i - 1].y)
const q = { x: qx, y: qy }
newpoints[newpoints.length] = q // append new point 'q'
points.splice(i, 0, q) // insert 'q' at position i in points s.t. 'q' will be the next i
D = 0.0
} else D += d
}
if (newpoints.length === n - 1)
// somtimes we fall a rounding-error short of adding the last point, so add it if so
newpoints[newpoints.length] = {
x: points[points.length - 1].x,
y: points[points.length - 1].y,
}
return newpoints
}
function IndicativeAngle(points: VecLike[]): number {
const c = Centroid(points)
return Math.atan2(c.y - points[0].y, c.x - points[0].x)
}
function RotateBy(points: VecLike[], radians: number): VecLike[] {
// rotates points around centroid
const c = Centroid(points)
const cos = Math.cos(radians)
const sin = Math.sin(radians)
const newpoints = new Array()
for (let i = 0; i < points.length; i++) {
const qx = (points[i].x - c.x) * cos - (points[i].y - c.y) * sin + c.x
const qy = (points[i].x - c.x) * sin + (points[i].y - c.y) * cos + c.y
newpoints[newpoints.length] = { x: qx, y: qy }
}
return newpoints
}
function ScaleTo(points: VecLike[], size: number): VecLike[] {
// non-uniform scale; assumes 2D gestures (i.e., no lines)
const B = BoundingBox(points)
const newpoints = new Array()
for (let i = 0; i < points.length; i++) {
const qx = points[i].x * (size / B.w)
const qy = points[i].y * (size / B.h)
newpoints[newpoints.length] = { x: qx, y: qy }
}
return newpoints
}
function TranslateTo(points: VecLike[], pt: VecLike): VecLike[] {
// translates points' centroid
const c = Centroid(points)
const newpoints = new Array()
for (let i = 0; i < points.length; i++) {
const qx = points[i].x + pt.x - c.x
const qy = points[i].y + pt.y - c.y
newpoints[newpoints.length] = { x: qx, y: qy }
}
return newpoints
}
function Vectorize(points: VecLike[]): number[] {
let sum = 0.0
const vector = new Array()
for (let i = 0; i < points.length; i++) {
vector[vector.length] = points[i].x
vector[vector.length] = points[i].y
sum += points[i].x * points[i].x + points[i].y * points[i].y
}
const magnitude = Math.sqrt(sum)
for (let i = 0; i < vector.length; i++) vector[i] /= magnitude
return vector
}
function OptimalCosineDistance(v1: number[], v2: number[]): number {
let a = 0.0
let b = 0.0
for (let i = 0; i < v1.length; i += 2) {
a += v1[i] * v2[i] + v1[i + 1] * v2[i + 1]
b += v1[i] * v2[i + 1] - v1[i + 1] * v2[i]
}
const angle = Math.atan(b / a)
return Math.acos(a * Math.cos(angle) + b * Math.sin(angle))
}
function Centroid(points: VecLike[]): VecLike {
let x = 0.0
let y = 0.0
for (let i = 0; i < points.length; i++) {
x += points[i].x
y += points[i].y
}
x /= points.length
y /= points.length
return { x: x, y: y }
}
function BoundingBox(points: VecLike[]): BoxModel {
let minX = +Infinity
let maxX = -Infinity
let minY = +Infinity
let maxY = -Infinity
for (let i = 0; i < points.length; i++) {
minX = Math.min(minX, points[i].x)
minY = Math.min(minY, points[i].y)
maxX = Math.max(maxX, points[i].x)
maxY = Math.max(maxY, points[i].y)
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
}
function PathLength(points: VecLike[]): number {
let d = 0.0
for (let i = 1; i < points.length; i++)
d += Distance(points[i - 1], points[i])
return d
}
function Distance(p1: VecLike, p2: VecLike): number {
const dx = p2.x - p1.x
const dy = p2.y - p1.y
return Math.sqrt(dx * dx + dy * dy)
}

View File

@ -0,0 +1,277 @@
import { Layout } from 'webcola';
import { BaseCollection } from '../collections';
import { Editor, TLArrowShape, TLGeoShape, TLShape, TLShapeId } from '@tldraw/tldraw';
type ColaNode = {
id: TLShapeId;
x: number;
y: number;
width: number;
height: number;
rotation: number;
color?: string;
};
type ColaIdLink = {
source: TLShapeId
target: TLShapeId
};
type ColaNodeLink = {
source: ColaNode
target: ColaNode
};
type AlignmentConstraint = {
type: 'alignment',
axis: 'x' | 'y',
offsets: { node: TLShapeId, offset: number }[]
}
type ColaConstraint = AlignmentConstraint
export class GraphLayoutCollection extends BaseCollection {
override id = 'graph';
graphSim: Layout;
animFrame = -1;
colaNodes: Map<TLShapeId, ColaNode> = new Map();
colaLinks: Map<TLShapeId, ColaIdLink> = new Map();
colaConstraints: ColaConstraint[] = [];
constructor(editor: Editor) {
super(editor)
this.graphSim = new Layout();
const simLoop = () => {
this.step();
this.animFrame = requestAnimationFrame(simLoop);
};
simLoop();
}
override onAdd(shapes: TLShape[]) {
for (const shape of shapes) {
if (shape.type !== "arrow") {
this.addGeo(shape);
}
else {
this.addArrow(shape as TLArrowShape);
}
}
this.refreshGraph();
}
override onRemove(shapes: TLShape[]) {
const removedShapeIds = new Set(shapes.map(shape => shape.id));
for (const shape of shapes) {
this.colaNodes.delete(shape.id);
this.colaLinks.delete(shape.id);
}
// Filter out links where either source or target has been removed
for (const [key, link] of this.colaLinks) {
if (removedShapeIds.has(link.source) || removedShapeIds.has(link.target)) {
this.colaLinks.delete(key);
}
}
this.refreshGraph();
}
override onShapeChange(prev: TLShape, next: TLShape) {
if (prev.type === 'geo' && next.type === 'geo') {
const prevShape = prev as TLGeoShape
const nextShape = next as TLGeoShape
// update color if its changed and refresh constraints which use this
if (prevShape.props.color !== nextShape.props.color) {
const existingNode = this.colaNodes.get(next.id);
if (existingNode) {
this.colaNodes.set(next.id, {
...existingNode,
color: nextShape.props.color,
});
}
this.refreshGraph();
}
}
}
step = () => {
this.graphSim.start(1, 0, 0, 0, true, false);
for (const node of this.graphSim.nodes() as ColaNode[]) {
const shape = this.editor.getShape(node.id);
const { w, h } = this.editor.getShapeGeometry(node.id).bounds
if (!shape) continue;
const { x, y } = getCornerToCenterOffset(w, h, shape.rotation);
// Fix positions if we're dragging them
if (this.editor.getSelectedShapeIds().includes(node.id)) {
node.x = shape.x + x;
node.y = shape.y + y;
}
// Update shape props
node.width = w;
node.height = h;
node.rotation = shape.rotation;
this.editor.updateShape({
id: node.id,
type: "geo",
x: node.x - x,
y: node.y - y,
});
}
};
addArrow = (arrow: TLArrowShape) => {
const bindings = this.editor.getBindingsInvolvingShape(arrow.id);
if (bindings.length !== 2) return;
const startBinding = bindings.find(binding => (binding.props as any).terminal === 'start');
const endBinding = bindings.find(binding => (binding.props as any).terminal === 'end');
if (startBinding && endBinding) {
const source = this.editor.getShape(startBinding.toId);
const target = this.editor.getShape(endBinding.toId);
if (source && target) {
const link: ColaIdLink = {
source: source.id,
target: target.id
};
this.colaLinks.set(arrow.id, link);
}
}
}
addGeo = (shape: TLShape) => {
const { w, h } = this.editor.getShapeGeometry(shape).bounds
const { x, y } = getCornerToCenterOffset(w, h, shape.rotation)
const node: ColaNode = {
id: shape.id,
x: shape.x + x,
y: shape.y + y,
width: w,
height: h,
rotation: shape.rotation,
color: (shape.props as any).color
};
this.colaNodes.set(shape.id, node);
}
refreshGraph() {
// TODO: remove this hardcoded behaviour
this.editor.selectNone()
this.refreshConstraints();
const nodes = [...this.colaNodes.values()];
const nodeIdToIndex = new Map(nodes.map((n, i) => [n.id, i]));
// Convert the Map values to an array for processing
const links = Array.from(this.colaLinks.values()).map(l => ({
source: nodeIdToIndex.get(l.source),
target: nodeIdToIndex.get(l.target)
}));
const constraints = this.colaConstraints.map(constraint => {
if (constraint.type === 'alignment') {
return {
...constraint,
offsets: constraint.offsets.map(offset => ({
node: nodeIdToIndex.get(offset.node),
offset: offset.offset
}))
};
}
return constraint;
});
this.graphSim
.nodes(nodes)
// @ts-ignore
.links(links)
.constraints(constraints)
// you could use .linkDistance(250) too, which is stable but does not handle size/rotation
.linkDistance((edge) => calcEdgeDistance(edge as ColaNodeLink))
.avoidOverlaps(true)
.handleDisconnected(true)
}
refreshConstraints() {
const alignmentConstraintX: AlignmentConstraint = {
type: 'alignment',
axis: 'x',
offsets: [],
};
const alignmentConstraintY: AlignmentConstraint = {
type: 'alignment',
axis: 'y',
offsets: [],
};
// Iterate over shapes and generate constraints based on conditions
for (const node of this.colaNodes.values()) {
if (node.color === "red") {
// Add alignment offset for red shapes
alignmentConstraintX.offsets.push({ node: node.id, offset: 0 });
}
if (node.color === "blue") {
// Add alignment offset for red shapes
alignmentConstraintY.offsets.push({ node: node.id, offset: 0 });
}
}
const constraints = [];
if (alignmentConstraintX.offsets.length > 0) {
constraints.push(alignmentConstraintX);
}
if (alignmentConstraintY.offsets.length > 0) {
constraints.push(alignmentConstraintY);
}
this.colaConstraints = constraints;
}
}
function getCornerToCenterOffset(w: number, h: number, rotation: number) {
// Calculate the center coordinates relative to the top-left corner
const centerX = w / 2;
const centerY = h / 2;
// Apply rotation to the center coordinates
const rotatedCenterX = centerX * Math.cos(rotation) - centerY * Math.sin(rotation);
const rotatedCenterY = centerX * Math.sin(rotation) + centerY * Math.cos(rotation);
return { x: rotatedCenterX, y: rotatedCenterY };
}
function calcEdgeDistance(edge: ColaNodeLink) {
const LINK_DISTANCE = 100;
// horizontal and vertical distances between centers
const dx = edge.target.x - edge.source.x;
const dy = edge.target.y - edge.source.y;
// the angles of the nodes in radians
const sourceAngle = edge.source.rotation;
const targetAngle = edge.target.rotation;
// Calculate the rotated dimensions of the nodes
const sourceWidth = Math.abs(edge.source.width * Math.cos(sourceAngle)) + Math.abs(edge.source.height * Math.sin(sourceAngle));
const sourceHeight = Math.abs(edge.source.width * Math.sin(sourceAngle)) + Math.abs(edge.source.height * Math.cos(sourceAngle));
const targetWidth = Math.abs(edge.target.width * Math.cos(targetAngle)) + Math.abs(edge.target.height * Math.sin(targetAngle));
const targetHeight = Math.abs(edge.target.width * Math.sin(targetAngle)) + Math.abs(edge.target.height * Math.cos(targetAngle));
// Calculate edge-to-edge distances
const horizontalGap = Math.max(0, Math.abs(dx) - (sourceWidth + targetWidth) / 2);
const verticalGap = Math.max(0, Math.abs(dy) - (sourceHeight + targetHeight) / 2);
// Calculate straight-line distance between the centers of the nodes
const centerToCenterDistance = Math.sqrt(dx * dx + dy * dy);
// Adjust the distance by subtracting the edge-to-edge distance and adding the desired travel distance
const adjustedDistance = centerToCenterDistance -
Math.sqrt(horizontalGap * horizontalGap + verticalGap * verticalGap) +
LINK_DISTANCE;
return adjustedDistance;
};

90
src/graph/GraphUi.tsx Normal file
View File

@ -0,0 +1,90 @@
import { useEditor } from "@tldraw/tldraw";
import { useEffect } from "react";
import "../css/dev-ui.css";
import { useCollection } from "@/collections";
export const GraphUi = () => {
const editor = useEditor();
const { collection, size } = useCollection('graph')
const handleAdd = () => {
if (collection) {
collection.add(editor.getSelectedShapes())
editor.selectNone()
}
}
const handleRemove = () => {
if (collection) {
collection.remove(editor.getSelectedShapes())
editor.selectNone()
}
}
const handleShortcut = () => {
if (!collection) return
const empty = collection.getShapes().size === 0
if (empty)
collection.add(editor.getCurrentPageShapes())
else
collection.clear()
};
const handleHighlight = () => {
if (collection) {
editor.setHintingShapes([...collection.getShapes().values()])
}
}
const handleHelp = () => {
alert("Use the 'Add' and 'Remove' buttons to add/remove selected shapes, or hit 'G' to add/remove all shapes. \n\nUse the highlight button (🔦) to visualize shapes in the simulation. \n\nBLUE shapes are constrained horizontally, RED shapes are constrained vertically. This is just to demo basic constraints, I plan to demo more interesting constraints in the future. \n\nFor more details, check the project's README.");
}
useEffect(() => {
window.addEventListener('toggleGraphLayoutEvent', handleShortcut);
return () => {
window.removeEventListener('toggleGraphLayoutEvent', handleShortcut);
};
}, [handleShortcut]);
return (
<div className="custom-layout">
<div className="custom-toolbar">
<div>{size} shapes</div>
<button
type="button"
title="Add Selected"
className="custom-button"
onClick={handleAdd}
>
Add
</button>
<button
type="button"
title="Remove Selected"
className="custom-button"
onClick={handleRemove}
>
Remove
</button>
<button
type="button"
title="Highlight Collection"
className="custom-button"
onClick={handleHighlight}
>
🔦
</button>
<button
type="button"
title="Show Help"
className="custom-button"
onClick={handleHelp}
>
</button>
</div>
</div>
);
};

20
src/graph/uiOverrides.ts Normal file
View File

@ -0,0 +1,20 @@
import {
TLUiEventSource,
TLUiOverrides,
TLUiTranslationKey,
} from "@tldraw/tldraw";
export const uiOverrides: TLUiOverrides = {
actions(_editor, actions) {
actions['toggle-graph-layout'] = {
id: 'toggle-graph-layout',
label: 'Toggle Graph Layout' as TLUiTranslationKey,
readonlyOk: true,
onSelect(_source: TLUiEventSource) {
const event = new CustomEvent('toggleGraphLayoutEvent');
window.dispatchEvent(event);
},
}
return actions
}
}

View File

@ -0,0 +1,87 @@
import { useEffect } from "react"
import { Editor, TLEventMap, TLFrameShape, TLParentId } from "tldraw"
import { cameraHistory } from "@/ui/cameraUtils"
// Define camera state interface
interface CameraState {
x: number
y: number
z: number
}
const MAX_HISTORY = 10
// Track camera changes
const trackCameraChange = (editor: Editor) => {
const currentCamera = editor.getCamera()
const lastPosition = cameraHistory[cameraHistory.length - 1]
if (
!lastPosition ||
currentCamera.x !== lastPosition.x ||
currentCamera.y !== lastPosition.y ||
currentCamera.z !== lastPosition.z
) {
cameraHistory.push({ ...currentCamera })
if (cameraHistory.length > MAX_HISTORY) {
cameraHistory.shift()
}
}
}
export function useCameraControls(editor: Editor | null) {
// Track camera changes
useEffect(() => {
if (!editor) return
const handler = () => {
trackCameraChange(editor)
}
editor.on("viewportChange" as keyof TLEventMap, handler)
editor.on("userChangeEnd" as keyof TLEventMap, handler)
return () => {
editor.off("viewportChange" as keyof TLEventMap, handler)
editor.off("userChangeEnd" as keyof TLEventMap, handler)
}
}, [editor])
// Camera control functions
return {
zoomToFrame: (frameId: string) => {
if (!editor) return
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape
if (!frame) return
editor.zoomToBounds(editor.getShapePageBounds(frame)!, {
inset: 32,
animation: { duration: 500 },
})
},
copyLocationLink: () => {
if (!editor) return
const camera = editor.getCamera()
const url = new URL(window.location.href)
url.searchParams.set("x", camera.x.toFixed(2))
url.searchParams.set("y", camera.y.toFixed(2))
url.searchParams.set("zoom", camera.z.toFixed(2))
navigator.clipboard.writeText(url.toString())
},
copyFrameLink: (frameId: string) => {
const url = new URL(window.location.href)
url.searchParams.set("frameId", frameId)
navigator.clipboard.writeText(url.toString())
},
revertCamera: () => {
if (!editor || cameraHistory.length === 0) return
const previousCamera = cameraHistory.pop()
if (previousCamera) {
editor.setCamera(previousCamera, { animation: { duration: 200 } })
}
},
}
}

259
src/lib/auth/account.ts Normal file
View File

@ -0,0 +1,259 @@
import * as odd from '@oddjs/odd';
import type FileSystem from '@oddjs/odd/fs/index';
import { asyncDebounce } from '../utils/asyncDebounce';
import * as browser from '../utils/browser';
import { DIRECTORIES } from '../../context/FileSystemContext';
/**
* Constants for filesystem paths
*/
export const ACCOUNT_SETTINGS_DIR = ['private', 'settings'];
export const GALLERY_DIRS = {
PUBLIC: ['public', 'gallery'],
PRIVATE: ['private', 'gallery']
};
export const AREAS = {
PUBLIC: 'public',
PRIVATE: 'private'
};
/**
* Checks if a username is valid according to ODD's rules
* @param username The username to check
* @returns A boolean indicating if the username is valid
*/
export const isUsernameValid = async (username: string): Promise<boolean> => {
console.log('Checking if username is valid:', username);
try {
// Fallback if ODD account functions are not available
if (odd.account && odd.account.isUsernameValid) {
const isValid = await odd.account.isUsernameValid(username);
console.log('Username validity check result:', isValid);
return Boolean(isValid);
}
// Default validation if ODD is not available
const usernameRegex = /^[a-zA-Z0-9_-]{3,20}$/;
const isValid = usernameRegex.test(username);
console.log('Username validity check result (fallback):', isValid);
return isValid;
} catch (error) {
console.error('Error checking username validity:', error);
return false;
}
};
/**
* Debounced function to check if a username is available
*/
const debouncedIsUsernameAvailable = asyncDebounce(
(username: string) => {
// Fallback if ODD account functions are not available
if (odd.account && odd.account.isUsernameAvailable) {
return odd.account.isUsernameAvailable(username);
}
// Default to true if ODD is not available
return Promise.resolve(true);
},
300
);
/**
* Checks if a username is available
* @param username The username to check
* @returns A boolean indicating if the username is available
*/
export const isUsernameAvailable = async (
username: string
): Promise<boolean> => {
console.log('Checking if username is available:', username);
try {
// In a local development environment, simulate the availability check
// by checking if the username exists in localStorage
if (browser.isBrowser()) {
const isAvailable = await browser.isUsernameAvailable(username);
console.log('Username availability check result:', isAvailable);
return isAvailable;
} else {
// If not in a browser (SSR), use the ODD API
const isAvailable = await debouncedIsUsernameAvailable(username);
console.log('Username availability check result:', isAvailable);
return Boolean(isAvailable);
}
} catch (error) {
console.error('Error checking username availability:', error);
return false;
}
};
/**
* Create additional directories and files needed by the app
* @param fs FileSystem
*/
export const initializeFilesystem = async (fs: FileSystem): Promise<void> => {
try {
// Create required directories
console.log('Creating required directories...');
// Fallback if ODD path is not available
if (!odd.path || !odd.path.directory) {
console.log('ODD path not available, skipping filesystem initialization');
return;
}
// Public directories
await fs.mkdir(odd.path.directory(...DIRECTORIES.PUBLIC.ROOT));
await fs.mkdir(odd.path.directory(...DIRECTORIES.PUBLIC.GALLERY));
await fs.mkdir(odd.path.directory(...DIRECTORIES.PUBLIC.DOCUMENTS));
// Private directories
await fs.mkdir(odd.path.directory(...DIRECTORIES.PRIVATE.ROOT));
await fs.mkdir(odd.path.directory(...DIRECTORIES.PRIVATE.GALLERY));
await fs.mkdir(odd.path.directory(...DIRECTORIES.PRIVATE.SETTINGS));
await fs.mkdir(odd.path.directory(...DIRECTORIES.PRIVATE.DOCUMENTS));
console.log('Filesystem initialized successfully');
} catch (error) {
console.error('Error during filesystem initialization:', error);
throw error;
}
};
/**
* Checks data root for a username with retries
* @param username The username to check
*/
export const checkDataRoot = async (username: string): Promise<void> => {
console.log('Looking up data root for username:', username);
// Fallback if ODD dataRoot is not available
if (!odd.dataRoot || !odd.dataRoot.lookup) {
console.log('ODD dataRoot not available, skipping data root lookup');
return;
}
let dataRoot = await odd.dataRoot.lookup(username);
console.log('Initial data root lookup result:', dataRoot ? 'found' : 'not found');
if (dataRoot) return;
console.log('Data root not found, starting retry process...');
return new Promise((resolve, reject) => {
const maxRetries = 20;
let attempt = 0;
const dataRootInterval = setInterval(async () => {
console.warn(`Could not fetch filesystem data root. Retrying (${attempt + 1}/${maxRetries})`);
dataRoot = await odd.dataRoot.lookup(username);
console.log(`Retry ${attempt + 1} result:`, dataRoot ? 'found' : 'not found');
if (!dataRoot && attempt < maxRetries) {
attempt++;
return;
}
console.log(`Retry process completed. Data root ${dataRoot ? 'found' : 'not found'} after ${attempt + 1} attempts`);
clearInterval(dataRootInterval);
if (dataRoot) {
resolve();
} else {
reject(new Error(`Data root not found after ${maxRetries} attempts`));
}
}, 500);
});
};
/**
* Generate a cryptographic key pair and store in localStorage during registration
* @param username The username being registered
*/
export const generateUserCredentials = async (username: string): Promise<boolean> => {
if (!browser.isBrowser()) return false;
try {
console.log('Generating cryptographic keys for user...');
// Generate a key pair using Web Crypto API
const keyPair = await browser.generateKeyPair();
if (!keyPair) {
console.error('Failed to generate key pair');
return false;
}
// Export the public key
const publicKeyBase64 = await browser.exportPublicKey(keyPair.publicKey);
if (!publicKeyBase64) {
console.error('Failed to export public key');
return false;
}
console.log('Keys generated successfully');
// Store the username and public key
browser.addRegisteredUser(username);
browser.storePublicKey(username, publicKeyBase64);
return true;
} catch (error) {
console.error('Error generating user credentials:', error);
return false;
}
};
/**
* Validate a user's stored credentials (for development mode)
* @param username The username to validate
*/
export const validateStoredCredentials = (username: string): boolean => {
if (!browser.isBrowser()) return false;
try {
const users = browser.getRegisteredUsers();
const publicKey = browser.getPublicKey(username);
return users.includes(username) && Boolean(publicKey);
} catch (error) {
console.error('Error validating stored credentials:', error);
return false;
}
};
/**
* Register a new user with the specified username
* @param username The username to register
* @returns A boolean indicating if registration was successful
*/
export const register = async (username: string): Promise<boolean> => {
try {
console.log('Registering user:', username);
// Check if username is valid
const isValid = await isUsernameValid(username);
if (!isValid) {
console.error('Invalid username format');
return false;
}
// Check if username is available
const isAvailable = await isUsernameAvailable(username);
if (!isAvailable) {
console.error('Username is not available');
return false;
}
// Generate user credentials
const credentialsGenerated = await generateUserCredentials(username);
if (!credentialsGenerated) {
console.error('Failed to generate user credentials');
return false;
}
console.log('User registration successful');
return true;
} catch (error) {
console.error('Error during user registration:', error);
return false;
}
};

312
src/lib/auth/authService.ts Normal file
View File

@ -0,0 +1,312 @@
import * as odd from '@oddjs/odd';
import type FileSystem from '@oddjs/odd/fs/index';
import { checkDataRoot, initializeFilesystem, isUsernameValid, isUsernameAvailable } from './account';
import { getBackupStatus } from './backup';
import { Session } from './types';
import { CryptoAuthService } from './cryptoAuthService';
import { loadSession, saveSession, clearStoredSession, getStoredUsername } from './sessionPersistence';
export class AuthService {
/**
* Initialize the authentication state
*/
static async initialize(): Promise<{
session: Session;
fileSystem: FileSystem | null;
}> {
// First try to load stored session
const storedSession = loadSession();
let session: Session;
let fileSystem: FileSystem | null = null;
if (storedSession && storedSession.authed && storedSession.username) {
// Try to restore ODD session with stored username
try {
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' },
username: storedSession.username
});
if (program.session) {
// ODD session restored successfully
fileSystem = program.session.fs;
const backupStatus = await getBackupStatus(fileSystem);
session = {
username: storedSession.username,
authed: true,
loading: false,
backupCreated: backupStatus.created
};
} else {
// ODD session not available, but we have crypto auth
session = {
username: storedSession.username,
authed: true,
loading: false,
backupCreated: storedSession.backupCreated
};
}
} catch (oddError) {
// ODD session restoration failed, using stored session
session = {
username: storedSession.username,
authed: true,
loading: false,
backupCreated: storedSession.backupCreated
};
}
} else {
// No stored session, try ODD initialization
try {
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' }
});
if (program.session) {
fileSystem = program.session.fs;
const backupStatus = await getBackupStatus(fileSystem);
session = {
username: program.session.username,
authed: true,
loading: false,
backupCreated: backupStatus.created
};
} else {
session = {
username: '',
authed: false,
loading: false,
backupCreated: null
};
}
} catch (error) {
session = {
username: '',
authed: false,
loading: false,
backupCreated: null,
error: String(error)
};
}
}
return { session, fileSystem };
}
/**
* Login with a username using cryptographic authentication
*/
static async login(username: string): Promise<{
success: boolean;
session?: Session;
fileSystem?: FileSystem;
error?: string;
}> {
try {
// First try cryptographic authentication
const cryptoResult = await CryptoAuthService.login(username);
if (cryptoResult.success && cryptoResult.session) {
// If crypto auth succeeds, also try to load ODD session
try {
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' },
username
});
if (program.session) {
const fs = program.session.fs;
const backupStatus = await getBackupStatus(fs);
return {
success: true,
session: {
username,
authed: true,
loading: false,
backupCreated: backupStatus.created
},
fileSystem: fs
};
}
} catch (oddError) {
// ODD session not available, using crypto auth only
}
// Return crypto auth result if ODD is not available
const session = cryptoResult.session;
if (session) {
saveSession(session);
}
return {
success: true,
session: cryptoResult.session,
fileSystem: undefined
};
}
// Fallback to ODD authentication
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' },
username
});
if (program.session) {
const fs = program.session.fs;
const backupStatus = await getBackupStatus(fs);
const session = {
username,
authed: true,
loading: false,
backupCreated: backupStatus.created
};
saveSession(session);
return {
success: true,
session,
fileSystem: fs
};
} else {
return {
success: false,
error: cryptoResult.error || 'Failed to authenticate'
};
}
} catch (error) {
return {
success: false,
error: String(error)
};
}
}
/**
* Register a new user with cryptographic authentication
*/
static async register(username: string): Promise<{
success: boolean;
session?: Session;
fileSystem?: FileSystem;
error?: string;
}> {
try {
// Validate username
const valid = await isUsernameValid(username);
if (!valid) {
return {
success: false,
error: 'Invalid username format'
};
}
// First try cryptographic registration
const cryptoResult = await CryptoAuthService.register(username);
if (cryptoResult.success && cryptoResult.session) {
// If crypto registration succeeds, also try to create ODD session
try {
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' },
username
});
if (program.session) {
const fs = program.session.fs;
// Initialize filesystem with required directories
await initializeFilesystem(fs);
// Check backup status
const backupStatus = await getBackupStatus(fs);
return {
success: true,
session: {
username,
authed: true,
loading: false,
backupCreated: backupStatus.created
},
fileSystem: fs
};
}
} catch (oddError) {
// ODD session creation failed, using crypto auth only
}
// Return crypto registration result if ODD is not available
const session = cryptoResult.session;
if (session) {
saveSession(session);
}
return {
success: true,
session: cryptoResult.session,
fileSystem: undefined
};
}
// Fallback to ODD-only registration
const program = await odd.program({
namespace: { creator: 'mycrozine', name: 'app' },
username
});
if (program.session) {
const fs = program.session.fs;
// Initialize filesystem with required directories
await initializeFilesystem(fs);
// Check backup status
const backupStatus = await getBackupStatus(fs);
const session = {
username,
authed: true,
loading: false,
backupCreated: backupStatus.created
};
saveSession(session);
return {
success: true,
session,
fileSystem: fs
};
} else {
return {
success: false,
error: cryptoResult.error || 'Failed to create account'
};
}
} catch (error) {
return {
success: false,
error: String(error)
};
}
}
/**
* Logout the current user
*/
static async logout(): Promise<boolean> {
try {
// Clear stored session
clearStoredSession();
// Try to destroy ODD session
try {
await odd.session.destroy();
} catch (oddError) {
// ODD session destroy failed
}
return true;
} catch (error) {
return false;
}
}
}

22
src/lib/auth/backup.ts Normal file
View File

@ -0,0 +1,22 @@
import * as odd from '@oddjs/odd'
export type BackupStatus = {
created: boolean | null
}
export const getBackupStatus = async (fs: odd.FileSystem): Promise<BackupStatus> => {
try {
// Check if the required methods exist
if ((fs as any).exists && odd.path && (odd.path as any).backups) {
const backupStatus = await (fs as any).exists((odd.path as any).backups());
return { created: backupStatus };
}
// Fallback if methods don't exist
console.warn('Backup methods not available in current ODD version');
return { created: null };
} catch (error) {
console.error('Error checking backup status:', error);
return { created: null };
}
}

211
src/lib/auth/crypto.ts Normal file
View File

@ -0,0 +1,211 @@
// This module contains browser-specific WebCrypto API utilities
// Check if we're in a browser environment
export const isBrowser = (): boolean => typeof window !== 'undefined';
// Use the polyfill if available, otherwise fall back to native WebCrypto
const getCrypto = (): Crypto => {
if (typeof window !== 'undefined' && window.crypto) {
return window.crypto;
}
// Fallback to native WebCrypto if polyfill is not available
return window.crypto;
};
// Get registered users from localStorage
export const getRegisteredUsers = (): string[] => {
if (!isBrowser()) return [];
try {
return JSON.parse(window.localStorage.getItem('registeredUsers') || '[]');
} catch (error) {
console.error('Error getting registered users:', error);
return [];
}
};
// Add a user to the registered users list
export const addRegisteredUser = (username: string): void => {
if (!isBrowser()) return;
try {
const users = getRegisteredUsers();
if (!users.includes(username)) {
users.push(username);
window.localStorage.setItem('registeredUsers', JSON.stringify(users));
}
} catch (error) {
console.error('Error adding registered user:', error);
}
};
// Check if a username is available
export const isUsernameAvailable = async (username: string): Promise<boolean> => {
console.log('Checking if username is available:', username);
try {
// Get the list of registered users
const users = getRegisteredUsers();
// Check if the username is already taken
const isAvailable = !users.includes(username);
console.log('Username availability result:', isAvailable);
return isAvailable;
} catch (error) {
console.error('Error checking username availability:', error);
return false;
}
};
// Check if username is valid format (letters, numbers, underscores, hyphens)
export const isUsernameValid = (username: string): boolean => {
const usernameRegex = /^[a-zA-Z0-9_-]{3,20}$/;
return usernameRegex.test(username);
};
// Store a public key for a user
export const storePublicKey = (username: string, publicKey: string): void => {
if (!isBrowser()) return;
try {
window.localStorage.setItem(`${username}_publicKey`, publicKey);
} catch (error) {
console.error('Error storing public key:', error);
}
};
// Get a user's public key
export const getPublicKey = (username: string): string | null => {
if (!isBrowser()) return null;
try {
return window.localStorage.getItem(`${username}_publicKey`);
} catch (error) {
console.error('Error getting public key:', error);
return null;
}
};
// Generate a key pair using Web Crypto API
export const generateKeyPair = async (): Promise<CryptoKeyPair | null> => {
if (!isBrowser()) return null;
try {
const crypto = getCrypto();
return await crypto.subtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true,
['sign', 'verify']
);
} catch (error) {
console.error('Error generating key pair:', error);
return null;
}
};
// Export a public key to a base64 string
export const exportPublicKey = async (publicKey: CryptoKey): Promise<string | null> => {
if (!isBrowser()) return null;
try {
const crypto = getCrypto();
const publicKeyBuffer = await crypto.subtle.exportKey(
'raw',
publicKey
);
return btoa(
String.fromCharCode.apply(null, Array.from(new Uint8Array(publicKeyBuffer)))
);
} catch (error) {
console.error('Error exporting public key:', error);
return null;
}
};
// Import a public key from a base64 string
export const importPublicKey = async (base64Key: string): Promise<CryptoKey | null> => {
if (!isBrowser()) return null;
try {
const crypto = getCrypto();
const binaryString = atob(base64Key);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return await crypto.subtle.importKey(
'raw',
bytes,
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true,
['verify']
);
} catch (error) {
console.error('Error importing public key:', error);
return null;
}
};
// Sign data with a private key
export const signData = async (privateKey: CryptoKey, data: string): Promise<string | null> => {
if (!isBrowser()) return null;
try {
const crypto = getCrypto();
const encoder = new TextEncoder();
const encodedData = encoder.encode(data);
const signature = await crypto.subtle.sign(
{
name: 'ECDSA',
hash: { name: 'SHA-256' },
},
privateKey,
encodedData
);
return btoa(
String.fromCharCode.apply(null, Array.from(new Uint8Array(signature)))
);
} catch (error) {
console.error('Error signing data:', error);
return null;
}
};
// Verify a signature
export const verifySignature = async (
publicKey: CryptoKey,
signature: string,
data: string
): Promise<boolean> => {
if (!isBrowser()) return false;
try {
const crypto = getCrypto();
const encoder = new TextEncoder();
const encodedData = encoder.encode(data);
const binarySignature = atob(signature);
const signatureBytes = new Uint8Array(binarySignature.length);
for (let i = 0; i < binarySignature.length; i++) {
signatureBytes[i] = binarySignature.charCodeAt(i);
}
return await crypto.subtle.verify(
{
name: 'ECDSA',
hash: { name: 'SHA-256' },
},
publicKey,
signatureBytes,
encodedData
);
} catch (error) {
console.error('Error verifying signature:', error);
return false;
}
};

View File

@ -0,0 +1,269 @@
import * as crypto from './crypto';
import { isBrowser } from '../utils/browser';
export interface CryptoAuthResult {
success: boolean;
session?: {
username: string;
authed: boolean;
loading: boolean;
backupCreated: boolean | null;
};
error?: string;
}
export interface ChallengeResponse {
challenge: string;
signature: string;
publicKey: string;
}
/**
* Enhanced authentication service using WebCryptoAPI
*/
export class CryptoAuthService {
/**
* Generate a cryptographic challenge for authentication
*/
static async generateChallenge(username: string): Promise<string> {
if (!isBrowser()) {
throw new Error('Challenge generation requires browser environment');
}
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2);
return `${username}:${timestamp}:${random}`;
}
/**
* Register a new user with cryptographic authentication
*/
static async register(username: string): Promise<CryptoAuthResult> {
try {
if (!isBrowser()) {
return {
success: false,
error: 'Registration requires browser environment'
};
}
// Check if username is available
const isAvailable = await crypto.isUsernameAvailable(username);
if (!isAvailable) {
return {
success: false,
error: 'Username is already taken'
};
}
// Generate cryptographic key pair
const keyPair = await crypto.generateKeyPair();
if (!keyPair) {
return {
success: false,
error: 'Failed to generate cryptographic keys'
};
}
// Export public key
const publicKeyBase64 = await crypto.exportPublicKey(keyPair.publicKey);
if (!publicKeyBase64) {
return {
success: false,
error: 'Failed to export public key'
};
}
// Generate a challenge and sign it to prove key ownership
const challenge = await this.generateChallenge(username);
const signature = await crypto.signData(keyPair.privateKey, challenge);
if (!signature) {
return {
success: false,
error: 'Failed to sign challenge'
};
}
// Store user credentials
crypto.addRegisteredUser(username);
crypto.storePublicKey(username, publicKeyBase64);
// Store the authentication data securely (in a real app, this would be more secure)
localStorage.setItem(`${username}_authData`, JSON.stringify({
challenge,
signature,
timestamp: Date.now()
}));
return {
success: true,
session: {
username,
authed: true,
loading: false,
backupCreated: null
}
};
} catch (error) {
console.error('Registration error:', error);
return {
success: false,
error: String(error)
};
}
}
/**
* Login with cryptographic authentication
*/
static async login(username: string): Promise<CryptoAuthResult> {
try {
if (!isBrowser()) {
return {
success: false,
error: 'Login requires browser environment'
};
}
// Check if user exists
const users = crypto.getRegisteredUsers();
if (!users.includes(username)) {
return {
success: false,
error: 'User not found'
};
}
// Get stored public key
const publicKeyBase64 = crypto.getPublicKey(username);
if (!publicKeyBase64) {
return {
success: false,
error: 'User credentials not found'
};
}
// Check if authentication data exists
const storedData = localStorage.getItem(`${username}_authData`);
if (!storedData) {
return {
success: false,
error: 'Authentication data not found'
};
}
// For now, we'll use a simpler approach - just verify the user exists
// and has the required data. In a real implementation, you'd want to
// implement proper challenge-response or biometric authentication.
try {
const authData = JSON.parse(storedData);
if (!authData.challenge || !authData.signature) {
return {
success: false,
error: 'Invalid authentication data'
};
}
} catch (parseError) {
return {
success: false,
error: 'Corrupted authentication data'
};
}
// Import public key to verify it's valid
const publicKey = await crypto.importPublicKey(publicKeyBase64);
if (!publicKey) {
return {
success: false,
error: 'Invalid public key'
};
}
// For demonstration purposes, we'll skip the signature verification
// since the challenge-response approach has issues with key storage
// In a real implementation, you'd implement proper key management
return {
success: true,
session: {
username,
authed: true,
loading: false,
backupCreated: null
}
};
} catch (error) {
console.error('Login error:', error);
return {
success: false,
error: String(error)
};
}
}
/**
* Verify a user's cryptographic credentials
*/
static async verifyCredentials(username: string): Promise<boolean> {
try {
if (!isBrowser()) return false;
const users = crypto.getRegisteredUsers();
if (!users.includes(username)) return false;
const publicKeyBase64 = crypto.getPublicKey(username);
if (!publicKeyBase64) return false;
const publicKey = await crypto.importPublicKey(publicKeyBase64);
if (!publicKey) return false;
return true;
} catch (error) {
console.error('Credential verification error:', error);
return false;
}
}
/**
* Sign data with user's private key (if available)
*/
static async signData(username: string): Promise<string | null> {
try {
if (!isBrowser()) return null;
// In a real implementation, you would retrieve the private key securely
// For now, we'll use a simplified approach
const storedData = localStorage.getItem(`${username}_authData`);
if (!storedData) return null;
// This is a simplified implementation
// In a real app, you'd need to securely store and retrieve the private key
return null;
} catch (error) {
console.error('Sign data error:', error);
return null;
}
}
/**
* Verify a signature with user's public key
*/
static async verifySignature(username: string, signature: string, data: string): Promise<boolean> {
try {
if (!isBrowser()) return false;
const publicKeyBase64 = crypto.getPublicKey(username);
if (!publicKeyBase64) return false;
const publicKey = await crypto.importPublicKey(publicKeyBase64);
if (!publicKey) return false;
return await crypto.verifySignature(publicKey, signature, data);
} catch (error) {
console.error('Verify signature error:', error);
return false;
}
}
}

58
src/lib/auth/linking.ts Normal file
View File

@ -0,0 +1,58 @@
import * as odd from '@oddjs/odd';
/**
* Creates an account linking consumer for the specified username
* @param username The username to create a consumer for
* @returns A Promise resolving to an AccountLinkingConsumer-like object
*/
export const createAccountLinkingConsumer = async (
username: string
): Promise<any> => {
// Check if the method exists in the current ODD version
if (odd.account && typeof (odd.account as any).createConsumer === 'function') {
return await (odd.account as any).createConsumer({ username });
}
// Fallback: create a mock consumer for development
console.warn('Account linking consumer not available in current ODD version, using mock implementation');
return {
on: (event: string, callback: Function) => {
// Mock event handling
if (event === 'challenge') {
// Simulate PIN challenge
setTimeout(() => callback({ pin: [1, 2, 3, 4] }), 1000);
} else if (event === 'link') {
// Simulate successful link
setTimeout(() => callback({ approved: true, username }), 2000);
}
},
destroy: () => {
// Cleanup mock consumer
}
};
};
/**
* Creates an account linking producer for the specified username
* @param username The username to create a producer for
* @returns A Promise resolving to an AccountLinkingProducer-like object
*/
export const createAccountLinkingProducer = async (
username: string
): Promise<any> => {
// Check if the method exists in the current ODD version
if (odd.account && typeof (odd.account as any).createProducer === 'function') {
return await (odd.account as any).createProducer({ username });
}
// Fallback: create a mock producer for development
console.warn('Account linking producer not available in current ODD version, using mock implementation');
return {
on: (_event: string, _callback: Function) => {
// Mock event handling - parameters unused in mock implementation
},
destroy: () => {
// Cleanup mock producer
}
};
};

View File

@ -0,0 +1,88 @@
// Session persistence service for maintaining authentication state across browser sessions
import { Session } from './types';
const SESSION_STORAGE_KEY = 'canvas_auth_session';
export interface StoredSession {
username: string;
authed: boolean;
timestamp: number;
backupCreated: boolean | null;
}
/**
* Save session to localStorage
*/
export const saveSession = (session: Session): boolean => {
if (typeof window === 'undefined') return false;
try {
const storedSession: StoredSession = {
username: session.username,
authed: session.authed,
timestamp: Date.now(),
backupCreated: session.backupCreated
};
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(storedSession));
return true;
} catch (error) {
return false;
}
};
/**
* Load session from localStorage
*/
export const loadSession = (): StoredSession | null => {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem(SESSION_STORAGE_KEY);
if (!stored) return null;
const parsed = JSON.parse(stored) as StoredSession;
// Check if session is not too old (7 days)
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
if (Date.now() - parsed.timestamp > maxAge) {
localStorage.removeItem(SESSION_STORAGE_KEY);
return null;
}
return parsed;
} catch (error) {
return null;
}
};
/**
* Clear stored session
*/
export const clearStoredSession = (): boolean => {
if (typeof window === 'undefined') return false;
try {
localStorage.removeItem(SESSION_STORAGE_KEY);
return true;
} catch (error) {
return false;
}
};
/**
* Check if user has valid stored session
*/
export const hasValidStoredSession = (): boolean => {
const session = loadSession();
return session !== null && session.authed && session.username !== null;
};
/**
* Get stored username
*/
export const getStoredUsername = (): string | null => {
const session = loadSession();
return session?.username || null;
};

34
src/lib/auth/types.ts Normal file
View File

@ -0,0 +1,34 @@
export interface Session {
username: string;
authed: boolean;
loading: boolean;
backupCreated: boolean | null;
error?: string;
}
export enum SessionError {
PROGRAM_FAILURE = 'PROGRAM_FAILURE',
FILESYSTEM_INIT_FAILURE = 'FILESYSTEM_INIT_FAILURE',
DATAROOT_NOT_FOUND = 'DATAROOT_NOT_FOUND',
UNKNOWN = 'UNKNOWN'
}
export const errorToMessage = (error: SessionError): string | undefined => {
switch (error) {
case SessionError.PROGRAM_FAILURE:
return `Program failure occurred`;
case SessionError.FILESYSTEM_INIT_FAILURE:
return `Failed to initialize filesystem`;
case SessionError.DATAROOT_NOT_FOUND:
return `Data root not found`;
case SessionError.UNKNOWN:
return `An unknown error occurred`;
default:
return undefined;
}
};

8
src/lib/init.ts Normal file
View File

@ -0,0 +1,8 @@
import { clearStoredSession } from './auth/sessionPersistence';
/**
* Clear the current session and stored data
*/
export const clearSession = (): void => {
clearStoredSession();
};

View File

@ -0,0 +1,156 @@
import { Editor } from 'tldraw';
import { exportToBlob } from 'tldraw';
export interface BoardScreenshot {
slug: string;
dataUrl: string;
timestamp: number;
}
/**
* Generates a screenshot of the current canvas state
*/
export const generateCanvasScreenshot = async (editor: Editor): Promise<string | null> => {
try {
// Get all shapes on the current page
const shapes = editor.getCurrentPageShapes();
console.log('Found shapes:', shapes.length);
if (shapes.length === 0) {
console.log('No shapes found, no screenshot generated');
return null;
}
// Get all shape IDs for export
const allShapeIds = shapes.map(shape => shape.id);
console.log('Exporting all shapes:', allShapeIds.length);
// Calculate bounds of all shapes to fit everything in view
const bounds = editor.getCurrentPageBounds();
console.log('Canvas bounds:', bounds);
// Use Tldraw's export functionality to get a blob with all content
const blob = await exportToBlob({
editor,
ids: allShapeIds,
format: "png",
opts: {
scale: 0.5, // Reduced scale to make image smaller
background: true,
padding: 20, // Increased padding to show full canvas
preserveAspectRatio: "xMidYMid meet",
bounds: bounds, // Export the entire canvas bounds
},
});
if (!blob) {
console.warn('Failed to export blob, no screenshot generated');
return null;
}
// Convert blob to data URL with compression
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => {
// Create a canvas to compress the image
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Could not get 2D context'));
return;
}
// Set canvas size for compression (max 400x300 for dashboard)
canvas.width = 400;
canvas.height = 300;
// Draw and compress the image
ctx.drawImage(img, 0, 0, 400, 300);
const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.6); // Use JPEG with 60% quality
resolve(compressedDataUrl);
};
img.onerror = reject;
img.src = reader.result as string;
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
console.log('Successfully exported board to data URL');
console.log('Screenshot data URL:', dataUrl);
return dataUrl;
} catch (error) {
console.error('Error generating screenshot:', error);
return null;
}
};
/**
* Stores a screenshot for a board
*/
export const storeBoardScreenshot = (slug: string, dataUrl: string): void => {
try {
const screenshot: BoardScreenshot = {
slug,
dataUrl,
timestamp: Date.now(),
};
localStorage.setItem(`board_screenshot_${slug}`, JSON.stringify(screenshot));
} catch (error) {
console.error('Error storing screenshot:', error);
}
};
/**
* Retrieves a stored screenshot for a board
*/
export const getBoardScreenshot = (slug: string): BoardScreenshot | null => {
try {
const stored = localStorage.getItem(`board_screenshot_${slug}`);
if (!stored) return null;
return JSON.parse(stored);
} catch (error) {
console.error('Error retrieving screenshot:', error);
return null;
}
};
/**
* Removes a stored screenshot for a board
*/
export const removeBoardScreenshot = (slug: string): void => {
try {
localStorage.removeItem(`board_screenshot_${slug}`);
} catch (error) {
console.error('Error removing screenshot:', error);
}
};
/**
* Checks if a screenshot exists for a board
*/
export const hasBoardScreenshot = (slug: string): boolean => {
return getBoardScreenshot(slug) !== null;
};
/**
* Generates and stores a screenshot for the current board
* This should be called when the board content changes significantly
*/
export const captureBoardScreenshot = async (editor: Editor, slug: string): Promise<void> => {
console.log('Starting screenshot capture for:', slug);
const dataUrl = await generateCanvasScreenshot(editor);
if (dataUrl) {
console.log('Screenshot generated successfully for:', slug);
storeBoardScreenshot(slug, dataUrl);
console.log('Screenshot stored for:', slug);
} else {
console.warn('Failed to generate screenshot for:', slug);
}
};

61
src/lib/settings.tsx Normal file
View File

@ -0,0 +1,61 @@
import { atom } from 'tldraw'
import { SYSTEM_PROMPT } from '@/prompt'
export const PROVIDERS = [
{
id: 'openai',
name: 'OpenAI',
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'], // 'o1-preview', 'o1-mini'],
help: 'https://tldraw.notion.site/Make-Real-Help-93be8b5273d14f7386e14eb142575e6e#a9b75e58b1824962a1a69a2f29ace9be',
validate: (key: string) => key.startsWith('sk-'),
},
{
id: 'anthropic',
name: 'Anthropic',
models: [
'claude-3-5-sonnet-20241022',
'claude-3-5-sonnet-20240620',
'claude-3-opus-20240229',
'claude-3-sonnet-20240229',
'claude-3-haiku-20240307',
],
help: 'https://tldraw.notion.site/Make-Real-Help-93be8b5273d14f7386e14eb142575e6e#3444b55a2ede405286929956d0be6e77',
validate: (key: string) => key.startsWith('sk-'),
},
// { id: 'google', name: 'Google', model: 'Gemeni 1.5 Flash', validate: (key: string) => true },
]
export const makeRealSettings = atom('make real settings', {
provider: 'openai' as (typeof PROVIDERS)[number]['id'] | 'all',
models: Object.fromEntries(PROVIDERS.map((provider) => [provider.id, provider.models[0]])),
keys: {
openai: '',
anthropic: '',
google: '',
},
prompts: {
system: SYSTEM_PROMPT,
},
})
export function applySettingsMigrations(settings: any) {
const { keys, prompts, ...rest } = settings
const settingsWithModelsProperty = {
provider: 'openai',
models: Object.fromEntries(PROVIDERS.map((provider) => [provider.id, provider.models[0]])),
keys: {
openai: '',
anthropic: '',
google: '',
...keys,
},
prompts: {
system: SYSTEM_PROMPT,
...prompts,
},
...rest,
}
return settingsWithModelsProperty
}

141
src/lib/starredBoards.ts Normal file
View File

@ -0,0 +1,141 @@
// Service for managing starred boards
export interface StarredBoard {
slug: string;
title: string;
starredAt: number;
lastVisited?: number;
}
export interface StarredBoardsData {
boards: StarredBoard[];
lastUpdated: number;
}
/**
* Get starred boards for a user
*/
export const getStarredBoards = (username: string): StarredBoard[] => {
if (typeof window === 'undefined') return [];
try {
const data = localStorage.getItem(`starred_boards_${username}`);
if (!data) return [];
const parsed: StarredBoardsData = JSON.parse(data);
return parsed.boards || [];
} catch (error) {
console.error('Error getting starred boards:', error);
return [];
}
};
/**
* Add a board to starred boards
*/
export const starBoard = (username: string, slug: string, title?: string): boolean => {
if (typeof window === 'undefined') return false;
try {
const boards = getStarredBoards(username);
// Check if already starred
const existingIndex = boards.findIndex(board => board.slug === slug);
if (existingIndex !== -1) {
return false; // Already starred
}
// Add new starred board
const newBoard: StarredBoard = {
slug,
title: title || slug,
starredAt: Date.now(),
};
boards.push(newBoard);
// Save to localStorage
const data: StarredBoardsData = {
boards,
lastUpdated: Date.now(),
};
localStorage.setItem(`starred_boards_${username}`, JSON.stringify(data));
return true;
} catch (error) {
console.error('Error starring board:', error);
return false;
}
};
/**
* Remove a board from starred boards
*/
export const unstarBoard = (username: string, slug: string): boolean => {
if (typeof window === 'undefined') return false;
try {
const boards = getStarredBoards(username);
const filteredBoards = boards.filter(board => board.slug !== slug);
if (filteredBoards.length === boards.length) {
return false; // Board wasn't starred
}
// Save to localStorage
const data: StarredBoardsData = {
boards: filteredBoards,
lastUpdated: Date.now(),
};
localStorage.setItem(`starred_boards_${username}`, JSON.stringify(data));
return true;
} catch (error) {
console.error('Error unstarring board:', error);
return false;
}
};
/**
* Check if a board is starred
*/
export const isBoardStarred = (username: string, slug: string): boolean => {
const boards = getStarredBoards(username);
return boards.some(board => board.slug === slug);
};
/**
* Update last visited time for a board
*/
export const updateLastVisited = (username: string, slug: string): void => {
if (typeof window === 'undefined') return;
try {
const boards = getStarredBoards(username);
const boardIndex = boards.findIndex(board => board.slug === slug);
if (boardIndex !== -1) {
boards[boardIndex].lastVisited = Date.now();
const data: StarredBoardsData = {
boards,
lastUpdated: Date.now(),
};
localStorage.setItem(`starred_boards_${username}`, JSON.stringify(data));
}
} catch (error) {
console.error('Error updating last visited:', error);
}
};
/**
* Get recently visited starred boards (sorted by last visited)
*/
export const getRecentlyVisitedStarredBoards = (username: string, limit: number = 5): StarredBoard[] => {
const boards = getStarredBoards(username);
return boards
.filter(board => board.lastVisited)
.sort((a, b) => (b.lastVisited || 0) - (a.lastVisited || 0))
.slice(0, limit);
};

View File

@ -0,0 +1,188 @@
/**
* Creates a debounced version of an async function.
*
* A debounced function will only execute after a specified delay has passed
* without the function being called again. This is particularly useful for
* functions that make API calls in response to user input, to avoid making
* too many calls when a user is actively typing or interacting.
*
* @param fn The async function to debounce
* @param wait The time to wait in milliseconds before the function is called
* @returns A debounced version of the input function
*
* @example
* // Create a debounced version of an API call function
* const debouncedFetch = asyncDebounce(fetchFromAPI, 300);
*
* // Use the debounced function in an input handler
* const handleInputChange = (e) => {
* debouncedFetch(e.target.value)
* .then(result => setData(result))
* .catch(error => setError(error));
* };
*/
export function asyncDebounce<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
wait: number
): (...args: A) => Promise<R> {
let lastTimeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
return (...args: A): Promise<R> => {
// Clear any existing timeout to cancel pending executions
clearTimeout(lastTimeoutId);
// Return a promise that will resolve with the function's result
return new Promise((resolve, reject) => {
// Create a new timeout
const currentTimeoutId = setTimeout(async () => {
try {
// Only execute if this is still the most recent timeout
if (currentTimeoutId === lastTimeoutId) {
const result = await fn(...args);
resolve(result);
}
} catch (err) {
reject(err);
}
}, wait);
// Store the current timeout ID
lastTimeoutId = currentTimeoutId;
});
};
}
/**
* Throttles an async function to be called at most once per specified period.
*
* Unlike debounce which resets the timer on each call, throttle will ensure the
* function is called at most once in the specified period, regardless of how many
* times the throttled function is called.
*
* @param fn The async function to throttle
* @param limit The minimum time in milliseconds between function executions
* @returns A throttled version of the input function
*
* @example
* // Create a throttled version of an API call function
* const throttledSave = asyncThrottle(saveToAPI, 1000);
*
* // Use the throttled function in an input handler
* const handleInputChange = (e) => {
* throttledSave(e.target.value)
* .then(() => setSaveStatus('Saved'))
* .catch(error => setSaveStatus('Error saving'));
* };
*/
export function asyncThrottle<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
limit: number
): (...args: A) => Promise<R> {
let lastRun = 0;
let lastPromise: Promise<R> | null = null;
let pending = false;
let lastArgs: A | null = null;
const execute = async (...args: A): Promise<R> => {
lastRun = Date.now();
pending = false;
return await fn(...args);
};
return (...args: A): Promise<R> => {
lastArgs = args;
// If we're not pending and it's been longer than the limit since the last run,
// execute immediately
if (!pending && Date.now() - lastRun >= limit) {
return execute(...args);
}
// If we don't have a promise or we're not pending, create a new promise
if (!lastPromise || !pending) {
pending = true;
lastPromise = new Promise<R>((resolve, reject) => {
setTimeout(async () => {
try {
// Make sure we're using the most recent args
if (lastArgs) {
const result = await execute(...lastArgs);
resolve(result);
}
} catch (err) {
reject(err);
}
}, limit - (Date.now() - lastRun));
});
}
return lastPromise;
};
}
/**
* Extracts a search parameter from a URL and removes it from the URL.
*
* Useful for handling one-time parameters like auth tokens or invite codes.
*
* @param url The URL object
* @param param The parameter name to extract
* @returns The parameter value or null if not found
*
* @example
* // Extract an invite code from the current URL
* const url = new URL(window.location.href);
* const inviteCode = extractSearchParam(url, 'invite');
* // The parameter is now removed from the URL
*/
export const extractSearchParam = (url: URL, param: string): string | null => {
// Get the parameter value
const val = url.searchParams.get(param);
// Remove the parameter from the URL
url.searchParams.delete(param);
// Update the browser history to reflect the URL change without reloading
if (typeof history !== 'undefined') {
history.replaceState(null, document.title, url.toString());
}
return val;
};
/**
* Checks if a function execution is taking too long and returns a timeout result if so.
*
* @param fn The async function to execute with timeout
* @param timeout The maximum time in milliseconds to wait
* @param timeoutResult The result to return if timeout occurs
* @returns The function result or timeout result
*
* @example
* // Execute a function with a 5-second timeout
* const result = await withTimeout(
* fetchDataFromSlowAPI,
* 5000,
* { error: 'Request timed out' }
* );
*/
export async function withTimeout<T, R>(
fn: () => Promise<T>,
timeout: number,
timeoutResult: R
): Promise<T | R> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<R>((resolve) => {
timeoutId = setTimeout(() => resolve(timeoutResult), timeout);
});
try {
const result = await Promise.race([fn(), timeoutPromise]);
if (timeoutId) clearTimeout(timeoutId);
return result;
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
throw error;
}
}

242
src/lib/utils/browser.ts Normal file
View File

@ -0,0 +1,242 @@
/**
* Browser-specific utility functions
*
* This module contains browser-specific functionality for environment detection
* and other browser-related operations.
*/
/**
* Check if we're in a browser environment
*/
export const isBrowser = (): boolean => typeof window !== 'undefined';
/**
* Check if the browser supports the required features for the application
*/
export const checkBrowserSupport = (): boolean => {
if (!isBrowser()) return false;
// Check for IndexedDB support
const hasIndexedDB = typeof window.indexedDB !== 'undefined';
// Check for WebCrypto API support
const hasWebCrypto = typeof window.crypto !== 'undefined' &&
typeof window.crypto.subtle !== 'undefined';
// Check for other required browser features
const hasLocalStorage = typeof window.localStorage !== 'undefined';
const hasServiceWorker = 'serviceWorker' in navigator;
return hasIndexedDB && hasWebCrypto && hasLocalStorage && hasServiceWorker;
};
/**
* Check if we're in a secure context (HTTPS)
*/
export const isSecureContext = (): boolean => {
if (!isBrowser()) return false;
return window.isSecureContext;
};
/**
* Get a URL parameter value
* @param name The parameter name
* @returns The parameter value or null if not found
*/
export const getUrlParameter = (name: string): string | null => {
if (!isBrowser()) return null;
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(name);
};
/**
* Set a cookie
* @param name The cookie name
* @param value The cookie value
* @param days Number of days until expiration
*/
export const setCookie = (name: string, value: string, days: number = 7): void => {
if (!isBrowser()) return;
const expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Strict`;
};
/**
* Get a cookie value
* @param name The cookie name
* @returns The cookie value or null if not found
*/
export const getCookie = (name: string): string | null => {
if (!isBrowser()) return null;
const nameEQ = `${name}=`;
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
};
/**
* Delete a cookie
* @param name The cookie name
*/
export const deleteCookie = (name: string): void => {
if (!isBrowser()) return;
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Strict`;
};
/**
* Check if the device is mobile
*/
export const isMobileDevice = (): boolean => {
if (!isBrowser()) return false;
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
};
/**
* Get the browser name
*/
export const getBrowserName = (): string => {
if (!isBrowser()) return 'unknown';
const userAgent = navigator.userAgent;
if (userAgent.indexOf('Firefox') > -1) return 'Firefox';
if (userAgent.indexOf('Chrome') > -1) return 'Chrome';
if (userAgent.indexOf('Safari') > -1) return 'Safari';
if (userAgent.indexOf('Edge') > -1) return 'Edge';
if (userAgent.indexOf('MSIE') > -1 || userAgent.indexOf('Trident') > -1) return 'Internet Explorer';
return 'unknown';
};
/**
* Check if local storage is available
*/
export const isLocalStorageAvailable = (): boolean => {
if (!isBrowser()) return false;
try {
const test = '__test__';
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch (e) {
return false;
}
};
/**
* Safely get an item from local storage
* @param key The storage key
* @returns The stored value or null if not found
*/
export const getLocalStorageItem = (key: string): string | null => {
if (!isBrowser() || !isLocalStorageAvailable()) return null;
try {
return localStorage.getItem(key);
} catch (error) {
console.error('Error getting item from localStorage:', error);
return null;
}
};
/**
* Safely set an item in local storage
* @param key The storage key
* @param value The value to store
* @returns True if successful, false otherwise
*/
export const setLocalStorageItem = (key: string, value: string): boolean => {
if (!isBrowser() || !isLocalStorageAvailable()) return false;
try {
localStorage.setItem(key, value);
return true;
} catch (error) {
console.error('Error setting item in localStorage:', error);
return false;
}
};
/**
* Safely remove an item from local storage
* @param key The storage key
* @returns True if successful, false otherwise
*/
export const removeLocalStorageItem = (key: string): boolean => {
if (!isBrowser() || !isLocalStorageAvailable()) return false;
try {
localStorage.removeItem(key);
return true;
} catch (error) {
console.error('Error removing item from localStorage:', error);
return false;
}
};
// Crypto-related functions (re-exported from crypto module)
export const generateKeyPair = async (): Promise<CryptoKeyPair | null> => {
const { generateKeyPair } = await import('../auth/crypto');
return generateKeyPair();
};
export const exportPublicKey = async (publicKey: CryptoKey): Promise<string | null> => {
const { exportPublicKey } = await import('../auth/crypto');
return exportPublicKey(publicKey);
};
export const importPublicKey = async (base64Key: string): Promise<CryptoKey | null> => {
const { importPublicKey } = await import('../auth/crypto');
return importPublicKey(base64Key);
};
export const signData = async (privateKey: CryptoKey, data: string): Promise<string | null> => {
const { signData } = await import('../auth/crypto');
return signData(privateKey, data);
};
export const verifySignature = async (
publicKey: CryptoKey,
signature: string,
data: string
): Promise<boolean> => {
const { verifySignature } = await import('../auth/crypto');
return verifySignature(publicKey, signature, data);
};
export const isUsernameAvailable = async (username: string): Promise<boolean> => {
const { isUsernameAvailable } = await import('../auth/crypto');
return isUsernameAvailable(username);
};
export const addRegisteredUser = (username: string): void => {
const { addRegisteredUser } = require('../auth/crypto');
return addRegisteredUser(username);
};
export const storePublicKey = (username: string, publicKey: string): void => {
const { storePublicKey } = require('../auth/crypto');
return storePublicKey(username, publicKey);
};
export const getPublicKey = (username: string): string | null => {
const { getPublicKey } = require('../auth/crypto');
return getPublicKey(username);
};
export const getRegisteredUsers = (): string[] => {
const { getRegisteredUsers } = require('../auth/crypto');
return getRegisteredUsers();
};

24
src/prompt.ts Normal file
View File

@ -0,0 +1,24 @@
export const SYSTEM_PROMPT = `You are an expert web developer who specializes in building working website prototypes from low-fidelity wireframes. Your job is to accept low-fidelity designs and turn them into high-fidelity interactive and responsive working prototypes. When sent new designs, you should reply with a high-fidelity working prototype as a single HTML file.
- Use tailwind (via \`cdn.tailwindcss.com\`) for styling.
- Put any JavaScript in a script tag with \`type="module"\`.
- Use unpkg or skypack to import any required JavaScript dependencies.
- Use Google fonts to pull in any open source fonts you require.
- If you have any images, load them from Unsplash or use solid colored rectangles as placeholders.
- Create SVGs as needed for any icons.
The designs may include flow charts, diagrams, labels, arrows, sticky notes, screenshots of other applications, or even previous designs. Treat all of these as references for your prototype.
The designs may include structural elements (such as boxes that represent buttons or content) as well as annotations or figures that describe interactions, behavior, or appearance. Use your best judgement to determine what is an annotation and what should be included in the final result. Annotations are commonly made in the color red. Do NOT include any of those annotations in your final result.
If there are any questions or underspecified features, use what you know about applications, user experience, and website design patterns to "fill in the blanks". If you're unsure of how the designs should work, take a guess—it's better for you to get it wrong than to leave things incomplete.
Your prototype should look and feel much more complete and advanced than the wireframes provided. Flesh it out, make it real!
Remember: you love your designers and want them to be happy. The more complete and impressive your prototype, the happier they will be. You are evaluated on 1) whether your prototype resembles the designs, 2) whether your prototype is interactive and responsive, and 3) whether your prototype is complete and impressive.`
export const USER_PROMPT =
'Here are the latest wireframes. Please reply with a high-fidelity working prototype as a single HTML file.'
export const USER_PROMPT_WITH_PREVIOUS_DESIGN =
"Here are the latest wireframes. There are also some previous outputs here. We have run their code through an 'HTML to screenshot' library to generate a screenshot of the page. The generated screenshot may have some inaccuracies so please use your knowledge of HTML and web development to figure out what any annotations are referring to, which may be different to what is visible in the generated screenshot. Make a new high-fidelity prototype based on your previous work and any new designs or annotations. Again, you should reply with a high-fidelity working prototype as a single HTML file."

View File

@ -0,0 +1,23 @@
export class DeltaTime {
private static lastTime = Date.now()
private static initialized = false
private static _dt = 0
static get dt(): number {
if (!DeltaTime.initialized) {
DeltaTime.lastTime = Date.now()
DeltaTime.initialized = true
window.requestAnimationFrame(DeltaTime.tick)
return 0
}
const clamp = (min: number, max: number, value: number) => Math.min(max, Math.max(min, value))
return clamp(0, 100, DeltaTime._dt)
}
static tick(nowish: number) {
DeltaTime._dt = nowish - DeltaTime.lastTime
DeltaTime.lastTime = nowish
window.requestAnimationFrame(DeltaTime.tick)
}
}

119
src/propagators/Geo.ts Normal file
View File

@ -0,0 +1,119 @@
import { SpatialIndex } from "@/propagators/SpatialIndex"
import { Box, Editor, TLShape, TLShapeId, VecLike, polygonsIntersect } from "tldraw"
export class Geo {
editor: Editor
spatialIndex: SpatialIndex
constructor(editor: Editor) {
this.editor = editor
this.spatialIndex = new SpatialIndex(editor)
}
intersects(shape: TLShape | TLShapeId): boolean {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (!id) return false
const sourceTransform = this.editor.getShapePageTransform(id)
const sourceGeo = this.editor.getShapeGeometry(id)
const sourcePagespace = sourceTransform.applyToPoints(sourceGeo.vertices)
const sourceBounds = this.editor.getShapePageBounds(id)
const shapesInBounds = this.spatialIndex.getShapeIdsInsideBounds(sourceBounds as Box)
for (const boundsShapeId of shapesInBounds) {
if (boundsShapeId === id) continue
const pageShape = this.editor.getShape(boundsShapeId)
if (!pageShape) continue
if (pageShape.type === 'arrow') continue
const pageShapeGeo = this.editor.getShapeGeometry(pageShape)
const pageShapeTransform = this.editor.getShapePageTransform(pageShape)
const pageShapePagespace = pageShapeTransform.applyToPoints(pageShapeGeo.vertices)
const pageShapeBounds = this.editor.getShapePageBounds(boundsShapeId)
if (polygonsIntersect(sourcePagespace, pageShapePagespace) || sourceBounds?.contains(pageShapeBounds as Box) || pageShapeBounds?.contains(sourceBounds as Box)) {
return true
}
}
return false
}
distance(a: TLShape | TLShapeId, b: TLShape | TLShapeId): VecLike {
const idA = typeof a === 'string' ? a : a?.id ?? null
const idB = typeof b === 'string' ? b : b?.id ?? null
if (!idA || !idB) return { x: 0, y: 0 }
const shapeA = this.editor.getShape(idA)
const shapeB = this.editor.getShape(idB)
if (!shapeA || !shapeB) return { x: 0, y: 0 }
return { x: shapeA.x - shapeB.x, y: shapeA.y - shapeB.y }
}
distanceCenter(a: TLShape | TLShapeId, b: TLShape | TLShapeId): VecLike {
const idA = typeof a === 'string' ? a : a?.id ?? null
const idB = typeof b === 'string' ? b : b?.id ?? null
if (!idA || !idB) return { x: 0, y: 0 }
const aBounds = this.editor.getShapePageBounds(idA)
const bBounds = this.editor.getShapePageBounds(idB)
if (!aBounds || !bBounds) return { x: 0, y: 0 }
const aCenter = aBounds.center
const bCenter = bBounds.center
return { x: aCenter.x - bCenter.x, y: aCenter.y - bCenter.y }
}
getIntersects(shape: TLShape | TLShapeId): TLShape[] {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (!id) return []
const sourceTransform = this.editor.getShapePageTransform(id)
const sourceGeo = this.editor.getShapeGeometry(id)
const sourcePagespace = sourceTransform.applyToPoints(sourceGeo.vertices)
const sourceBounds = this.editor.getShapePageBounds(id)
const boundsShapes = this.spatialIndex.getShapeIdsInsideBounds(sourceBounds as Box)
const overlaps: TLShape[] = []
for (const boundsShapeId of boundsShapes) {
if (boundsShapeId === id) continue
const pageShape = this.editor.getShape(boundsShapeId)
if (!pageShape) continue
if (pageShape.type === 'arrow') continue
const pageShapeGeo = this.editor.getShapeGeometry(pageShape)
const pageShapeTransform = this.editor.getShapePageTransform(pageShape)
const pageShapePagespace = pageShapeTransform.applyToPoints(pageShapeGeo.vertices)
const pageShapeBounds = this.editor.getShapePageBounds(boundsShapeId)
if (polygonsIntersect(sourcePagespace, pageShapePagespace) || sourceBounds?.contains(pageShapeBounds as Box) || pageShapeBounds?.contains(sourceBounds as Box )) {
overlaps.push(pageShape)
}
}
return overlaps
}
contains(shape: TLShape | TLShapeId): boolean {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (!id) return false
const sourceBounds = this.editor.getShapePageBounds(id)
const boundsShapes = this.spatialIndex.getShapeIdsInsideBounds(sourceBounds as Box)
for (const boundsShapeId of boundsShapes) {
if (boundsShapeId === id) continue
const pageShape = this.editor.getShape(boundsShapeId)
if (!pageShape) continue
if (pageShape.type !== 'geo') continue
const pageShapeBounds = this.editor.getShapePageBounds(boundsShapeId)
if (sourceBounds?.contains(pageShapeBounds as Box)) {
return true
}
}
return false
}
getContains(shape: TLShape | TLShapeId): TLShape[] {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (!id) return []
const sourceBounds = this.editor.getShapePageBounds(id)
const boundsShapes = this.spatialIndex.getShapeIdsInsideBounds(sourceBounds as Box)
const contains: TLShape[] = []
for (const boundsShapeId of boundsShapes) {
if (boundsShapeId === id) continue
const pageShape = this.editor.getShape(boundsShapeId)
if (!pageShape) continue
if (pageShape.type !== 'geo') continue
const pageShapeBounds = this.editor.getShapePageBounds(boundsShapeId)
if (sourceBounds?.contains(pageShapeBounds as Box)) {
contains.push(pageShape)
}
}
return contains
}
}

View File

@ -0,0 +1,314 @@
import { DeltaTime } from "@/propagators/DeltaTime"
import { Geo } from "@/propagators/Geo"
import { Edge, getArrowsFromShape, getEdge } from "@/propagators/tlgraph"
import { isShapeOfType, updateProps } from "@/propagators/utils"
import { Editor, TLArrowShape, TLBinding, TLGroupShape, TLShape, TLShapeId } from "tldraw"
type Prefix = 'click' | 'tick' | 'geo' | ''
export function registerDefaultPropagators(editor: Editor) {
registerPropagators(editor, [
ChangePropagator,
ClickPropagator,
TickPropagator,
SpatialPropagator,
])
}
function isPropagatorOfType(arrow: TLShape, prefix: Prefix) {
if (!isShapeOfType<TLArrowShape>(arrow, 'arrow')) return false
const regex = new RegExp(`^\\s*${prefix}\\s*\\{`)
return regex.test(arrow.props.text)
}
function isExpandedPropagatorOfType(arrow: TLShape, prefix: Prefix) {
if (!isShapeOfType<TLArrowShape>(arrow, 'arrow')) return false
const regex = new RegExp(`^\\s*${prefix}\\s*\\(\\)\\s*\\{`)
return regex.test(arrow.props.text)
}
class ArrowFunctionCache {
private cache: Map<string, Function | null> = new Map<string, Function | null>()
/** returns undefined if the function could not be found or created */
get(editor: Editor, edge: Edge, prefix: Prefix): Function | undefined {
if (this.cache.has(edge.arrowId)) {
return this.cache.get(edge.arrowId) as Function | undefined
}
return this.set(editor, edge, prefix)
}
/** returns undefined if the function could not be created */
set(editor: Editor, edge: Edge, prefix: Prefix): Function | undefined {
try {
const arrowShape = editor.getShape(edge.arrowId)
if (!arrowShape) throw new Error('Arrow shape not found')
const textWithoutPrefix = edge.text?.replace(prefix, '')
const isExpanded = isExpandedPropagatorOfType(arrowShape, prefix)
const body = isExpanded ? textWithoutPrefix?.trim().replace(/^\s*\(\)\s*{|}$/g, '') : `
const mapping = ${textWithoutPrefix}
editor.updateShape(_unpack({...to, ...mapping}))
`
const func = new Function('editor', 'from', 'to', 'G', 'bounds', 'dt', '_unpack', body as string);
this.cache.set(edge.arrowId, func)
return func
} catch (error) {
this.cache.set(edge.arrowId, null)
return undefined
}
}
delete(edge: Edge): void {
this.cache.delete(edge.arrowId)
}
}
const packShape = (shape: TLShape) => {
return {
id: shape.id,
type: shape.type,
x: shape.x,
y: shape.y,
rotation: shape.rotation,
...shape.props,
m: shape.meta,
}
}
const unpackShape = (shape: any) => {
const { id, type, x, y, rotation, m, ...props } = shape
const cast = (prop: any, constructor: (value: any) => any) => {
return prop !== undefined ? constructor(prop) : undefined;
};
// Handle text shapes properly - convert text property to richText if needed
const shapeProps = { ...props }
if (type === 'text') {
// Remove any text property as it's not valid for TLDraw text shapes
if ('text' in shapeProps) {
delete shapeProps.text
}
// Ensure richText exists for text shapes
if (!shapeProps.richText) {
shapeProps.richText = {
content: [],
type: 'doc'
}
}
}
return {
id,
type,
x: Number(x),
y: Number(y),
rotation: Number(rotation),
props: shapeProps,
meta: m,
}
}
function setArrowColor(editor: Editor, arrow: TLArrowShape, color: TLArrowShape['props']['color']): void {
editor.updateShape({
...arrow,
props: {
...arrow.props,
color: color,
}
})
}
export function registerPropagators(editor: Editor, propagators: (new (editor: Editor) => Propagator)[]) {
const _propagators = propagators.map((PropagatorClass) => new PropagatorClass(editor))
for (const prop of _propagators) {
for (const shape of editor.getCurrentPageShapes()) {
if (isShapeOfType<TLArrowShape>(shape, 'arrow')) {
prop.onArrowChange(editor, shape)
}
}
editor.sideEffects.registerAfterChangeHandler<"shape">("shape", (_, next) => {
if (isShapeOfType<TLGroupShape>(next, 'group')) {
const childIds = editor.getSortedChildIdsForParent(next.id)
for (const childId of childIds) {
const child = editor.getShape(childId)
prop.afterChangeHandler?.(editor, child as TLShape)
}
return
}
prop.afterChangeHandler?.(editor, next)
if (isShapeOfType<TLArrowShape>(next, 'arrow')) {
prop.onArrowChange(editor, next)
}
})
function updateOnBindingChange(editor: Editor, binding: TLBinding) {
if (binding.type !== 'arrow') return
const arrow = editor.getShape(binding.fromId)
if (!arrow) return
if (!isShapeOfType<TLArrowShape>(arrow, 'arrow')) return
prop.onArrowChange(editor, arrow)
}
// TODO: remove this when binding creation
editor.sideEffects.registerAfterCreateHandler<"binding">("binding", (binding) => {
updateOnBindingChange(editor, binding)
})
// TODO: remove this when binding creation
editor.sideEffects.registerAfterDeleteHandler<"binding">("binding", (binding) => {
updateOnBindingChange(editor, binding)
})
editor.on('event', (event) => {
prop.eventHandler?.(event)
})
editor.on('tick', () => {
prop.tickHandler?.()
})
}
}
// TODO: separate generic propagator setup from scope registration
// TODO: handle cycles
export abstract class Propagator {
abstract prefix: Prefix
protected listenerArrows: Set<TLShapeId> = new Set<TLShapeId>()
protected listenerShapes: Set<TLShapeId> = new Set<TLShapeId>()
protected arrowFunctionCache: ArrowFunctionCache = new ArrowFunctionCache()
protected editor: Editor
protected geo: Geo
protected validateOnArrowChange: boolean = false
constructor(editor: Editor) {
this.editor = editor
this.geo = new Geo(editor)
}
/** function to check if any listeners need to be added/removed
* called on mount and when an arrow changes
*/
onArrowChange(editor: Editor, arrow: TLArrowShape): void {
const edge = getEdge(arrow, editor)
if (!edge) return
const isPropagator = isPropagatorOfType(arrow, this.prefix) || isExpandedPropagatorOfType(arrow, this.prefix)
if (isPropagator) {
if (this.validateOnArrowChange && !this.propagate(editor, arrow.id)) {
this.removeListener(arrow.id, edge)
return
}
this.addListener(arrow.id, edge)
// TODO: find a way to do this properly so we can run arrow funcs on change without chaos...
// this.arrowFunc(editor, arrow.id)
} else {
this.removeListener(arrow.id, edge)
}
}
private addListener(arrowId: TLShapeId, edge: Edge): void {
this.listenerArrows.add(arrowId)
this.listenerShapes.add(edge.from)
this.listenerShapes.add(edge.to)
this.arrowFunctionCache.set(this.editor, edge, this.prefix)
}
private removeListener(arrowId: TLShapeId, edge: Edge): void {
this.listenerArrows.delete(arrowId)
this.arrowFunctionCache.delete(edge)
}
/** the function to be called when side effect / event is triggered */
propagate(editor: Editor, arrow: TLShapeId): boolean {
const edge = getEdge(editor.getShape(arrow), editor)
if (!edge) return false
const arrowShape = editor.getShape(arrow) as TLArrowShape
const fromShape = editor.getShape(edge.from)
const toShape = editor.getShape(edge.to)
const fromShapePacked = packShape(fromShape as TLShape)
const toShapePacked = packShape(toShape as TLShape)
const bounds = (shape: TLShape) => editor.getShapePageBounds(shape.id)
try {
const func = this.arrowFunctionCache.get(editor, edge, this.prefix)
const result = func?.(editor, fromShapePacked, toShapePacked, this.geo, bounds, DeltaTime.dt, unpackShape);
if (result) {
editor.updateShape(unpackShape({ ...toShapePacked, ...result }))
}
setArrowColor(editor, arrowShape, 'black')
return true
} catch (error) {
setArrowColor(editor, arrowShape, 'orange')
return false
}
}
/** called after every shape change */
afterChangeHandler?(editor: Editor, next: TLShape): void
/** called on every editor event */
eventHandler?(event: any): void
/** called every tick */
tickHandler?(): void
}
export class ClickPropagator extends Propagator {
prefix: Prefix = 'click'
eventHandler(event: any): void {
if (event.type !== 'pointer' || event.name !== 'pointer_down') return;
const shapeAtPoint = this.editor.getShapeAtPoint(this.editor.inputs.currentPagePoint, { filter: (shape) => shape.type === 'geo' });
if (!shapeAtPoint) return
if (!this.listenerShapes.has(shapeAtPoint.id)) return
const edgesFromHovered = getArrowsFromShape(this.editor, shapeAtPoint.id)
const visited = new Set<TLShapeId>()
for (const edge of edgesFromHovered) {
if (this.listenerArrows.has(edge) && !visited.has(edge)) {
this.propagate(this.editor, edge)
visited.add(edge)
}
}
}
}
export class ChangePropagator extends Propagator {
prefix: Prefix = ''
afterChangeHandler(editor: Editor, next: TLShape): void {
if (this.listenerShapes.has(next.id)) {
const arrowsFromShape = getArrowsFromShape(editor, next.id)
for (const arrow of arrowsFromShape) {
if (this.listenerArrows.has(arrow)) {
const bindings = editor.getBindingsInvolvingShape(arrow)
if (bindings.length !== 2) continue
// don't run func if its pointing to itself to avoid change-induced recursion error
if (bindings[0].toId === bindings[1].toId) continue
this.propagate(editor, arrow)
}
}
}
}
}
export class TickPropagator extends Propagator {
prefix: Prefix = 'tick'
validateOnArrowChange = true
tickHandler(): void {
for (const arrow of this.listenerArrows) {
this.propagate(this.editor, arrow)
}
}
}
export class SpatialPropagator extends Propagator {
prefix: Prefix = 'geo'
// TODO: make this smarter, and scale sublinearly
afterChangeHandler(editor: Editor, next: TLShape): void {
if (next.type === 'arrow') return
for (const arrowId of this.listenerArrows) {
this.propagate(editor, arrowId)
}
}
}

View File

@ -0,0 +1,165 @@
import { RESET_VALUE, computed, isUninitialized } from '@tldraw/state'
import { TLPageId, TLShapeId, isShape, isShapeId } from '@tldraw/tlschema'
import RBush from 'rbush'
import { Box, Editor } from 'tldraw'
type Element = {
minX: number
minY: number
maxX: number
maxY: number
id: TLShapeId
}
export class SpatialIndex {
private readonly spatialIndex: ReturnType<typeof this.createSpatialIndex>
private lastPageId: TLPageId | null = null
private shapesInTree: Map<TLShapeId, Element>
private rBush: RBush<Element>
constructor(private editor: Editor) {
this.spatialIndex = this.createSpatialIndex()
this.shapesInTree = new Map<TLShapeId, Element>()
this.rBush = new RBush<Element>()
}
private addElement(id: TLShapeId, a: Element[], existingBounds?: Box) {
const e = this.getElement(id, existingBounds)
if (!e) return
a.push(e)
this.shapesInTree.set(id, e)
}
private getElement(id: TLShapeId, existingBounds?: Box): Element | null {
const bounds = existingBounds ?? this.editor.getShapeMaskedPageBounds(id)
if (!bounds) return null
return {
minX: bounds.minX,
minY: bounds.minY,
maxX: bounds.maxX,
maxY: bounds.maxY,
id,
}
}
private fromScratch(lastComputedEpoch: number) {
this.lastPageId = this.editor.getCurrentPageId()
this.shapesInTree = new Map<TLShapeId, Element>()
const elementsToAdd: Element[] = []
this.editor.getCurrentPageShapeIds().forEach((id) => {
this.addElement(id, elementsToAdd)
})
this.rBush = new RBush<Element>().load(elementsToAdd)
return lastComputedEpoch
}
private createSpatialIndex() {
const shapeHistory = this.editor.store.query.filterHistory('shape')
return computed<number>('spatialIndex', (prevValue, lastComputedEpoch) => {
if (isUninitialized(prevValue)) {
return this.fromScratch(lastComputedEpoch)
}
const diff = shapeHistory.getDiffSince(lastComputedEpoch)
if (diff === RESET_VALUE) {
return this.fromScratch(lastComputedEpoch)
}
const currentPageId = this.editor.getCurrentPageId()
if (!this.lastPageId || this.lastPageId !== currentPageId) {
return this.fromScratch(lastComputedEpoch)
}
let isDirty = false
for (const changes of diff) {
const elementsToAdd: Element[] = []
for (const record of Object.values(changes.added)) {
if (isShape(record)) {
this.addElement(record.id, elementsToAdd)
}
}
for (const [_from, to] of Object.values(changes.updated)) {
if (isShape(to)) {
const currentElement = this.shapesInTree.get(to.id)
const newBounds = this.editor.getShapeMaskedPageBounds(to.id)
if (currentElement) {
if (
newBounds?.minX === currentElement.minX &&
newBounds.minY === currentElement.minY &&
newBounds.maxX === currentElement.maxX &&
newBounds.maxY === currentElement.maxY
) {
continue
}
this.shapesInTree.delete(to.id)
this.rBush.remove(currentElement)
isDirty = true
}
this.addElement(to.id, elementsToAdd, newBounds)
}
}
if (elementsToAdd.length) {
this.rBush.load(elementsToAdd)
isDirty = true
}
for (const id of Object.keys(changes.removed)) {
if (isShapeId(id)) {
const currentElement = this.shapesInTree.get(id)
if (currentElement) {
this.shapesInTree.delete(id)
this.rBush.remove(currentElement)
isDirty = true
}
}
}
}
return isDirty ? lastComputedEpoch : prevValue
})
}
private _getVisibleShapes() {
return computed<Set<TLShapeId>>('visible shapes', (prevValue) => {
// Make sure the spatial index is up to date
const _index = this.spatialIndex.get()
const newValue = this.rBush.search(this.editor.getViewportPageBounds()).map((s: Element) => s.id)
if (isUninitialized(prevValue)) {
return new Set(newValue)
}
const isSame = prevValue.size === newValue.length && newValue.every((id: TLShapeId) => prevValue.has(id))
return isSame ? prevValue : new Set(newValue)
})
}
getVisibleShapes() {
return this._getVisibleShapes().get()
}
_getNotVisibleShapes() {
return computed<Set<TLShapeId>>('not visible shapes', (prevValue) => {
const visibleShapes = this._getVisibleShapes().get()
const pageShapes = this.editor.getCurrentPageShapeIds()
const nonVisibleShapes = [...pageShapes].filter((id) => !visibleShapes.has(id))
if (isUninitialized(prevValue)) return new Set(nonVisibleShapes)
const isSame =
prevValue.size === nonVisibleShapes.length &&
nonVisibleShapes.every((id) => prevValue.has(id))
return isSame ? prevValue : new Set(nonVisibleShapes)
})
}
getNotVisibleShapes() {
return this._getNotVisibleShapes().get()
}
getShapeIdsInsideBounds(bounds: Box) {
// Make sure the spatial index is up to date
const _index = this.spatialIndex.get()
return this.rBush.search(bounds).map((s: Element) => s.id)
}
}

115
src/propagators/tlgraph.ts Normal file
View File

@ -0,0 +1,115 @@
import { isShapeOfType } from "@/propagators/utils";
import { Editor, TLArrowBinding, TLArrowShape, TLShape, TLShapeId } from "tldraw";
export interface Edge {
arrowId: TLShapeId
from: TLShapeId
to: TLShapeId
text?: string
}
export interface Graph {
nodes: TLShapeId[]
edges: Edge[]
}
export function getEdge(shape: TLShape | undefined, editor: Editor): Edge | undefined {
if (!shape || !isShapeOfType<TLArrowShape>(shape, 'arrow')) return undefined
const bindings = editor.getBindingsInvolvingShape<TLArrowBinding>(shape.id)
if (!bindings || bindings.length !== 2) return undefined
if (bindings[0].props.terminal === "end") {
return {
arrowId: shape.id,
from: bindings[1].toId,
to: bindings[0].toId,
text: shape.props.text
}
}
return {
arrowId: shape.id,
from: bindings[0].toId,
to: bindings[1].toId,
text: shape.props.text
}
}
/**
* Returns the graph(s) of edges and nodes from a list of shapes
*/
export function getGraph(shapes: TLShape[], editor: Editor): Graph {
const nodes: Set<TLShapeId> = new Set<TLShapeId>()
const edges: Edge[] = []
for (const shape of shapes) {
const edge = getEdge(shape, editor)
if (edge) {
edges.push({
arrowId: edge.arrowId,
from: edge.from,
to: edge.to,
text: edge.text
})
nodes.add(edge.from)
nodes.add(edge.to)
}
}
return { nodes: Array.from(nodes), edges }
}
/**
* Returns the start and end nodes of a topologically sorted graph
*/
export function sortGraph(graph: Graph): { startNodes: TLShapeId[], endNodes: TLShapeId[] } {
const targetNodes = new Set<TLShapeId>(graph.edges.map(e => e.to));
const sourceNodes = new Set<TLShapeId>(graph.edges.map(e => e.from));
const startNodes = [];
const endNodes = [];
for (const node of graph.nodes) {
if (sourceNodes.has(node) && !targetNodes.has(node)) {
startNodes.push(node);
} else if (targetNodes.has(node) && !sourceNodes.has(node)) {
endNodes.push(node);
}
}
return { startNodes, endNodes };
}
/**
* Returns the arrows starting from the given shape
*/
export function getArrowsFromShape(editor: Editor, shapeId: TLShapeId): TLShapeId[] {
const bindings = editor.getBindingsToShape<TLArrowBinding>(shapeId, 'arrow')
return bindings.filter(edge => edge.props.terminal === 'start').map(edge => edge.fromId)
}
/**
* Returns the arrows ending at the given shape
*/
export function getArrowsToShape(editor: Editor, shapeId: TLShapeId): TLShapeId[] {
const bindings = editor.getBindingsToShape<TLArrowBinding>(shapeId, 'arrow')
return bindings.filter(edge => edge.props.terminal === 'end').map(edge => edge.fromId)
}
/**
* Returns the arrows which share the same start shape as the given arrow
*/
export function getSiblingArrowIds(editor: Editor, arrow: TLShape): TLShapeId[] {
if (arrow.type !== 'arrow') return [];
const bindings = editor.getBindingsInvolvingShape<TLArrowBinding>(arrow.id);
if (!bindings || bindings.length !== 2) return [];
const startShapeId = bindings.find(binding => binding.props.terminal === 'start')?.toId;
if (!startShapeId) return [];
const siblingBindings = editor.getBindingsToShape<TLArrowBinding>(startShapeId, 'arrow');
const siblingArrows = siblingBindings
.filter(binding => binding.props.terminal === 'start' && binding.fromId !== arrow.id)
.map(binding => binding.fromId);
return siblingArrows;
}

22
src/propagators/utils.ts Normal file
View File

@ -0,0 +1,22 @@
import { Editor, TLShape, TLShapePartial } from "tldraw";
/**
* @returns true if the shape is of the given type
* @example
* ```ts
* isShapeOfType<TLArrowShape>(shape, 'arrow')
* ```
*/
export function isShapeOfType<T extends TLShape>(shape: TLShape, type: T['type']): shape is T {
return shape.type === type;
}
export function updateProps<T extends TLShape>(editor: Editor, shape: T, props: Partial<T['props']>) {
editor.updateShape({
...shape,
props: {
...shape.props,
...props
},
} as TLShapePartial)
}

43
src/routes/Auth.tsx Normal file
View File

@ -0,0 +1,43 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import CryptoLogin from '../components/auth/CryptoLogin';
import { useAuth } from '../context/AuthContext';
export const Auth: React.FC = () => {
const { session } = useAuth();
const navigate = useNavigate();
// Redirect to home if already authenticated
useEffect(() => {
if (session.authed) {
navigate('/');
}
}, [session.authed, navigate]);
if (session.loading) {
return (
<div className="auth-page">
<div className="auth-container loading">
<p>Loading authentication system...</p>
</div>
</div>
);
}
if (session.error) {
return (
<div className="auth-page">
<div className="auth-container error">
<h2>Authentication Error</h2>
<p>{session.error}</p>
</div>
</div>
);
}
return (
<div className="auth-page">
<CryptoLogin onSuccess={() => navigate('/')} />
</div>
);
};

290
src/routes/Board.tsx Normal file
View File

@ -0,0 +1,290 @@
import { useSync } from "@tldraw/sync"
import { useMemo, useEffect, useState } from "react"
import { Tldraw, Editor, TLShapeId } from "tldraw"
import { useParams } from "react-router-dom"
import { ChatBoxTool } from "@/tools/ChatBoxTool"
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
import { VideoChatTool } from "@/tools/VideoChatTool"
import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
import { multiplayerAssetStore } from "../utils/multiplayerAssetStore"
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
import { EmbedTool } from "@/tools/EmbedTool"
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
import { MarkdownTool } from "@/tools/MarkdownTool"
import { defaultShapeUtils, defaultBindingUtils } from "tldraw"
import { components } from "@/ui/components"
import { overrides } from "@/ui/overrides"
import { unfurlBookmarkUrl } from "../utils/unfurlBookmarkUrl"
import { handleInitialPageLoad } from "@/utils/handleInitialPageLoad"
import { MycrozineTemplateTool } from "@/tools/MycrozineTemplateTool"
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
import {
registerPropagators,
ChangePropagator,
TickPropagator,
ClickPropagator,
} from "@/propagators/ScopedPropagators"
import { SlideShapeTool } from "@/tools/SlideShapeTool"
import { SlideShape } from "@/shapes/SlideShapeUtil"
import { makeRealSettings, applySettingsMigrations } from "@/lib/settings"
import { PromptShapeTool } from "@/tools/PromptShapeTool"
import { PromptShape } from "@/shapes/PromptShapeUtil"
import { SharedPianoTool } from "@/tools/SharedPianoTool"
import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil"
import {
lockElement,
unlockElement,
setInitialCameraFromUrl,
initLockIndicators,
watchForLockedShapes,
} from "@/ui/cameraUtils"
import { Collection, initializeGlobalCollections } from "@/collections"
import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
import { CmdK } from "@/CmdK"
import "react-cmdk/dist/cmdk.css"
import "@/css/style.css"
const collections: Collection[] = [GraphLayoutCollection]
import { useAuth } from "../context/AuthContext"
import { updateLastVisited } from "../lib/starredBoards"
import { captureBoardScreenshot } from "../lib/screenshotService"
// Automatically switch between production and local dev based on environment
export const WORKER_URL = import.meta.env.DEV
? "http://localhost:5172"
: "https://jeffemmett-canvas.jeffemmett.workers.dev"
const customShapeUtils = [
ChatBoxShape,
VideoChatShape,
EmbedShape,
SlideShape,
MycrozineTemplateShape,
MarkdownShape,
PromptShape,
SharedPianoShape,
]
const customTools = [
ChatBoxTool,
VideoChatTool,
EmbedTool,
SlideShapeTool,
MycrozineTemplateTool,
MarkdownTool,
PromptShapeTool,
SharedPianoTool,
]
export function Board() {
const { slug } = useParams<{ slug: string }>()
const roomId = slug || "default-room"
const { session } = useAuth()
const storeConfig = useMemo(
() => ({
uri: `${WORKER_URL}/connect/${roomId}`,
assets: multiplayerAssetStore,
shapeUtils: [...defaultShapeUtils, ...customShapeUtils],
bindingUtils: [...defaultBindingUtils],
// Add user information to the presence system
user: session.authed ? {
id: session.username,
name: session.username,
} : undefined,
}),
[roomId, session.authed, session.username],
)
// Using TLdraw sync - fixed version compatibility issue
const store = useSync(storeConfig)
const [editor, setEditor] = useState<Editor | null>(null)
useEffect(() => {
const value = localStorage.getItem("makereal_settings_2")
if (value) {
const json = JSON.parse(value)
const migratedSettings = applySettingsMigrations(json)
localStorage.setItem(
"makereal_settings_2",
JSON.stringify(migratedSettings),
)
makeRealSettings.set(migratedSettings)
}
}, [])
// Remove the URL-based locking effect and replace with store-based initialization
useEffect(() => {
if (!editor) return
initLockIndicators(editor)
watchForLockedShapes(editor)
}, [editor])
// Update presence when session changes
useEffect(() => {
if (!editor || !session.authed || !session.username) return
// The presence should automatically update through the useSync configuration
// when the session changes, but we can also try to force an update
}, [editor, session.authed, session.username])
// Update TLDraw user preferences when editor is available and user is authenticated
useEffect(() => {
if (!editor) return
try {
if (session.authed && session.username) {
// Update the user preferences in TLDraw
editor.user.updateUserPreferences({
id: session.username,
name: session.username,
});
} else {
// Set default user preferences when not authenticated
editor.user.updateUserPreferences({
id: 'user-1',
name: 'User 1',
});
}
} catch (error) {
console.error('Error updating TLDraw user preferences from Board component:', error);
}
// Cleanup function to reset preferences when user logs out
return () => {
if (editor) {
try {
editor.user.updateUserPreferences({
id: 'user-1',
name: 'User 1',
});
} catch (error) {
console.error('Error resetting TLDraw user preferences:', error);
}
}
};
}, [editor, session.authed, session.username]);
// Track board visit for starred boards
useEffect(() => {
if (session.authed && session.username && roomId) {
updateLastVisited(session.username, roomId);
}
}, [session.authed, session.username, roomId]);
// Capture screenshots when board content changes
useEffect(() => {
if (!editor || !roomId || !store.store) return;
let lastContentHash = '';
let timeoutId: NodeJS.Timeout;
const captureScreenshot = async () => {
const currentShapes = editor.getCurrentPageShapes();
const currentContentHash = currentShapes.length > 0
? currentShapes.map(shape => `${shape.id}-${shape.type}`).sort().join('|')
: '';
// Only capture if content actually changed
if (currentContentHash !== lastContentHash) {
lastContentHash = currentContentHash;
await captureBoardScreenshot(editor, roomId);
}
};
// Listen to store changes instead of using getSnapshot() in dependencies
const unsubscribe = store.store.listen(() => {
// Clear existing timeout
if (timeoutId) clearTimeout(timeoutId);
// Set new timeout for debounced screenshot capture
timeoutId = setTimeout(captureScreenshot, 3000);
}, { source: "user", scope: "document" });
return () => {
unsubscribe();
if (timeoutId) clearTimeout(timeoutId);
};
}, [editor, roomId, store.store]);
return (
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw
store={store.store}
shapeUtils={customShapeUtils}
tools={customTools}
components={components}
overrides={{
...overrides,
actions: (editor, actions, helpers) => {
const customActions = overrides.actions?.(editor, actions, helpers) ?? {}
return {
...actions,
...customActions,
}
}
}}
cameraOptions={{
zoomSteps: [
0.001, // Min zoom
0.0025,
0.005,
0.01,
0.025,
0.05,
0.1,
0.25,
0.5,
1,
2,
4,
8,
16,
32,
64, // Max zoom
],
}}
onMount={(editor) => {
setEditor(editor)
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
editor.setCurrentTool("hand")
setInitialCameraFromUrl(editor)
handleInitialPageLoad(editor)
registerPropagators(editor, [
TickPropagator,
ChangePropagator,
ClickPropagator,
])
// Set user preferences immediately if user is authenticated
if (session.authed && session.username) {
try {
editor.user.updateUserPreferences({
id: session.username,
name: session.username,
});
} catch (error) {
console.error('Error setting initial TLDraw user preferences:', error);
}
} else {
// Set default user preferences when not authenticated
try {
editor.user.updateUserPreferences({
id: 'user-1',
name: 'User 1',
});
} catch (error) {
console.error('Error setting default TLDraw user preferences:', error);
}
}
initializeGlobalCollections(editor, collections)
// Note: User presence is configured through the useSync hook above
// The authenticated username should appear in the people section
}}
>
<CmdK />
</Tldraw>
</div>
)
}

61
src/routes/Contact.tsx Normal file
View File

@ -0,0 +1,61 @@
export function Contact() {
return (
<main>
<header>
<a href="/">Jeff Emmett</a>
</header>
<h1>Contact</h1>
<div style={{ marginBottom: '2rem' }}>
<h2>Schedule a Meeting</h2>
<p>Book a 30-minute meeting with me:</p>
<iframe
src="https://zcal.co/i/wvI6_DQG?embed=1&embedType=iframe"
loading="lazy"
style={{
border: 'none',
minWidth: '320px',
minHeight: '544px',
height: '731px',
width: '1096px'
}}
id="zcal-invite"
scrolling="no"
title="Schedule a meeting with Jeff Emmett"
/>
</div>
<div style={{ marginBottom: '2rem' }}>
<h2>Blog</h2>
<p>
<a href="https://allthingsdecent.substack.com/" target="_blank" rel="noopener noreferrer">
All Things Decent
</a> - Researching the biggest ideas that could make the world a more decent place
</p>
</div>
<div>
<h2>Connect & Follow</h2>
<p>
Twitter: <a href="https://twitter.com/jeffemmett">@jeffemmett</a>
</p>
<p>
BlueSky:{" "}
<a href="https://bsky.app/profile/jeffemmett.bsky.social">
@jeffemnmett.bsky.social
</a>
</p>
<p>
Mastodon:{" "}
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>
</p>
<p>
Email: <a href="mailto:jeffemmett (at) gmail.com">jeffemmett (at)gmail.com</a>
</p>
<p>
GitHub: <a href="https://github.com/Jeff-Emmett">Jeff-Emmett</a>
</p>
</div>
</main>
)
}

149
src/routes/Dashboard.tsx Normal file
View File

@ -0,0 +1,149 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useNotifications } from '../context/NotificationContext';
import { getStarredBoards, unstarBoard, StarredBoard } from '../lib/starredBoards';
import { getBoardScreenshot, removeBoardScreenshot } from '../lib/screenshotService';
export function Dashboard() {
const { session } = useAuth();
const { addNotification } = useNotifications();
const [starredBoards, setStarredBoards] = useState<StarredBoard[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Note: We don't redirect automatically - let the component show auth required message
// Load starred boards
useEffect(() => {
if (session.authed && session.username) {
const boards = getStarredBoards(session.username);
setStarredBoards(boards);
setIsLoading(false);
}
}, [session.authed, session.username]);
const handleUnstarBoard = (slug: string) => {
if (!session.username) return;
const success = unstarBoard(session.username, slug);
if (success) {
setStarredBoards(prev => prev.filter(board => board.slug !== slug));
removeBoardScreenshot(slug); // Remove screenshot when unstarring
addNotification('Board removed from starred boards', 'success');
} else {
addNotification('Failed to remove board from starred boards', 'error');
}
};
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (session.loading) {
return (
<div className="dashboard-container">
<div className="loading">Loading dashboard...</div>
</div>
);
}
if (!session.authed) {
return (
<div className="dashboard-container">
<div className="auth-required">
<h2>Authentication Required</h2>
<p>Please log in to access your dashboard.</p>
<Link to="/" className="back-link">Go Home</Link>
</div>
</div>
);
}
return (
<div className="dashboard-container">
<header className="dashboard-header">
<h1>My Dashboard</h1>
<p>Welcome back, {session.username}!</p>
</header>
<div className="dashboard-content">
<section className="starred-boards-section">
<div className="section-header">
<h2>Starred Boards</h2>
<span className="board-count">{starredBoards.length} board{starredBoards.length !== 1 ? 's' : ''}</span>
</div>
{isLoading ? (
<div className="loading">Loading starred boards...</div>
) : starredBoards.length === 0 ? (
<div className="empty-state">
<div className="empty-icon"></div>
<h3>No starred boards yet</h3>
<p>Star boards you want to save for quick access.</p>
<Link to="/" className="browse-link">Browse Boards</Link>
</div>
) : (
<div className="boards-grid">
{starredBoards.map((board) => {
const screenshot = getBoardScreenshot(board.slug);
return (
<div key={board.slug} className="board-card">
{screenshot && (
<div className="board-screenshot">
<img
src={screenshot.dataUrl}
alt={`Screenshot of ${board.title}`}
className="screenshot-image"
/>
</div>
)}
<div className="board-card-header">
<h3 className="board-title">{board.title}</h3>
<button
onClick={() => handleUnstarBoard(board.slug)}
className="unstar-button"
title="Remove from starred boards"
>
</button>
</div>
<div className="board-card-content">
<p className="board-slug">/{board.slug}</p>
<div className="board-meta">
<span className="starred-date">
Starred: {formatDate(board.starredAt)}
</span>
{board.lastVisited && (
<span className="last-visited">
Last visited: {formatDate(board.lastVisited)}
</span>
)}
</div>
</div>
<div className="board-card-actions">
<Link
to={`/board/${board.slug}`}
className="open-board-button"
>
Open Board
</Link>
</div>
</div>
);
})}
</div>
)}
</section>
</div>
</div>
);
}

114
src/routes/Default.tsx Normal file
View File

@ -0,0 +1,114 @@
export function Default() {
return (
<main>
<header>Jeff Emmett</header>
<nav className="main-nav">
<a href="/presentations" className="nav-link">Presentations</a>
<a href="/contact" className="nav-link">Contact</a>
</nav>
<h2>Hello! 👋🍄</h2>
<p>
My research investigates the intersection of mycelium and emancipatory
technologies. I am interested in the potential of new convivial tooling
as a medium for group consensus building and collective action, in order
to empower communities of practice to address their own challenges.
</p>
<p>
My current focus is basic research into the nature of digital
organisation, developing prototype toolkits to improve shared
infrastructure, and applying this research to the design of new systems
and protocols which support the self-organisation of knowledge and
emergent response to local needs.
</p>
<h2>My work</h2>
<p>
Alongside my independent work, I am a researcher and engineering
communicator at <a href="https://block.science/">Block Science</a>, an
advisor to the Active Inference Lab, Commons Stack, and the Trusted
Seed. I am also an occasional collaborator with{" "}
<a href="https://economicspace.agency/">ECSA</a>.
</p>
<h2>Get in touch</h2>
<p>
I am on Twitter <a href="https://twitter.com/jeffemmett">@jeffemmett</a>
, Mastodon{" "}
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>{" "}
and GitHub <a href="https://github.com/Jeff-Emmett">@Jeff-Emmett</a>.
</p>
<span className="dinkus">***</span>
<h2>Talks</h2>
<p>
You can find my presentations and slides on the{" "}
<a href="/presentations">presentations page</a>.
</p>
<ol reversed>
<li>
<a href="https://www.teamhuman.fm/episodes/238-jeff-emmett">
MycoPunk Futures on Team Human with Douglas Rushkoff
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li>
<a href="https://www.youtube.com/watch?v=AFJFDajuCSg">
Exploring MycoFi on the Greenpill Network with Kevin Owocki
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li>
<a href="https://youtu.be/9ad2EJhMbZ8">
Re-imagining Human Value on the Telos Podcast with Rieki &
Brandonfrom SEEDS
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li>
<a href="https://www.youtube.com/watch?v=i8qcg7FfpLM&t=1348s">
Move Slow & Fix Things: Design Patterns from Nature
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li>
<a href="https://podcasters.spotify.com/pod/show/theownershipeconomy/episodes/Episode-009---Localized-Democracy-and-Public-Goods-with-Token-Engineering--with-Jeff-Emmett-of-The-Commons-Stack--BlockScience-Labs-e1ggkqo">
Localized Democracy and Public Goods with Token Engineering on the
Ownership Economy
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li>
<a href="https://youtu.be/kxcat-XBWas">
A Discussion on Warm Data with Nora Bateson on Systems Innovation
</a>
</li>
</ol>
<h2>Writing</h2>
<ol reversed>
<li>
<a href="https://www.mycofi.art">
Exploring MycoFi: Mycelial Design Patterns for Web3 & Beyond
</a>
</li>
<li>
<a href="https://www.frontiersin.org/journals/blockchain/articles/10.3389/fbloc.2021.578721/full">
Challenges & Approaches to Scaling the Global Commons
</a>
</li>
<li>
<a href="https://allthingsdecent.substack.com/p/mycoeconomics-and-permaculture-currencies">
From Monoculture to Permaculture Currencies: A Glimpse of the
Myco-Economic Future
</a>
</li>
<li>
<a href="https://medium.com/good-audience/rewriting-the-story-of-human-collaboration-c33a8a4cd5b8">
Rewriting the Story of Human Collaboration
</a>
</li>
</ol>
</main>
)
}

88
src/routes/Inbox.tsx Normal file
View File

@ -0,0 +1,88 @@
import {
createShapeId,
Editor,
Tldraw,
TLGeoShape,
TLShapePartial,
} from "tldraw"
import { useEffect, useRef } from "react"
export function Inbox() {
const editorRef = useRef<Editor | null>(null)
const updateEmails = async (editor: Editor) => {
try {
const response = await fetch("https://jeffemmett-canvas.web.val.run", {
method: "GET",
})
const messages = (await response.json()) as {
id: string
from: string
subject: string
text: string
}[]
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
const messageId = message.id
const parsedEmailName =
message.from.match(/^([^<]+)/)?.[1]?.trim() ||
message.from.match(/[^<@]+(?=@)/)?.[0] ||
message.from
const messageText = `from: ${parsedEmailName}\nsubject: ${message.subject}\n\n${message.text}`
const shapeWidth = 500
const shapeHeight = 300
const spacing = 50
const shape: TLShapePartial<TLGeoShape> = {
id: createShapeId(),
type: "geo",
x: shapeWidth * (i % 5) + spacing * (i % 5),
y: shapeHeight * Math.floor(i / 5) + spacing * Math.floor(i / 5),
props: {
w: shapeWidth,
h: shapeHeight,
fill: "solid",
color: "white",
},
meta: {
id: messageId,
text: messageText, // Store text in meta instead of props
},
}
let found = false
for (const s of editor.getCurrentPageShapes()) {
if (s.meta.id === messageId) {
found = true
break
}
}
if (!found) {
editor.createShape(shape)
}
}
} catch (error) {
console.error("Error fetching data:", error)
}
}
useEffect(() => {
const intervalId = setInterval(() => {
if (editorRef.current) {
updateEmails(editorRef.current)
}
}, 5 * 1000)
return () => clearInterval(intervalId)
}, [])
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor: Editor) => {
editorRef.current = editor
updateEmails(editor)
}}
/>
</div>
)
}

View File

@ -0,0 +1,292 @@
export function Presentations() {
return (
<main>
<header>Jeff's Presentations</header>
<div className="presentations-info">
<h3>About These Presentations</h3>
<p>
These presentations represent my ongoing research into the intersection of
mycelium networks, emancipatory technologies, and convivial tooling. Each
presentation explores different aspects of how we can design systems that
support collective action and community self-organization.
</p>
<p>
For more of my work, check out my <a href="/">main page</a> or
<a href="/contact">get in touch</a>.
</p>
</div>
<div className="presentations-grid">
<div className="presentation-card">
<h3>Osmotic Governance</h3>
<p>Exploring the intersection of mycelium and emancipatory technologies</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/xfym/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Osmotic Governance Presentation"
/>
</div>
</div>
<div className="presentation-meta">
<span>Team Human with Douglas Rushkoff</span>
<a href="https://www.teamhuman.fm/episodes/238-jeff-emmett" target="_blank" rel="noopener noreferrer">
Listen to the full episode
</a>
</div>
</div>
<div className="presentation-card">
<h3>Exploring MycoFi</h3>
<p>Mycelial design patterns for Web3 and beyond</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/bqra/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Exploring MycoFi Presentation"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at DevCon 7 in Bangkok</span>
<a href="https://www.youtube.com/watch?v=0A4jXL5eBaI" target="_blank" rel="noopener noreferrer">
Watch the full talk
</a>
</div>
</div>
<div className="presentation-card">
<h3>MycoFi talk at CoFi gathering</h3>
<p>Mycelial design patterns for Web3 and beyond</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/vwmt/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="MycoFi Presentation at CoFi Gathering"
/>
</div>
</div>
<div className="presentation-meta">
<span>Exploring MycoFi on the Greenpill Network</span>
<a href="https://www.youtube.com/watch?v=AFJFDajuCSg" target="_blank" rel="noopener noreferrer">
Watch the full talk
</a>
</div>
</div>
<div className="presentation-card">
<h3>Myco-Mutualism</h3>
<p>Exploring mutualistic relationships in mycelial networks and their applications to human systems</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/caal/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Myco-Mutualism Presentation"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at the Mutualist Society</span>
<span>Video coming soon</span>
</div>
</div>
<div className="presentation-card">
<h3>Psilocybernetics: The Emergence of Institutional Neuroplasticity</h3>
<p>Exploring the intersection of mycelium and cybernetic institutional design</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/pnlz/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Psilocybernetics Presentation"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at the General Forum for Ethereum Localism</span>
<span>Video coming soon</span>
</div>
</div>
<div className="presentation-card">
<h3>Move Slow & Fix Things: The Commons Stack Design Pattern</h3>
<p>Design patterns for sustainable commons infrastructure</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/bnnf/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Move Slow & Fix Things: Commons Stack Design Pattern"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at the ReFi Unconf @ the Commons Hub Austria</span>
<a href="https://www.youtube.com/live/i8qcg7FfpLM?si=onLcl8q5rz7cMViO&t=1362" target="_blank" rel="noopener noreferrer">
Watch the full talk
</a>
</div>
</div>
<div className="presentation-card">
<h3>Commons Stack Launch & Open Sourcing cadCAD</h3>
<p>The launch of Commons Stack and the open sourcing of cadCAD for token engineering</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/hxac/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Commons Stack Launch & Open Sourcing cadCAD"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at the Token Engineering Global Gathering (TEGG)</span>
<a href="https://youtu.be/qjdjX2m_p0Q?si=r2AXVnVyzCIxIOSc&t=20" target="_blank" rel="noopener noreferrer">
Watch the full talk
</a>
</div>
</div>
<div className="presentation-card">
<h3>New Tools for Dynamic Collective Intelligence: Conviction Voting & Variations</h3>
<p>Exploring innovative voting mechanisms for collective decision-making in decentralized systems</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/fhos/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="New Tools for Dynamic Collective Intelligence"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation on Conviction Voting</span>
<span>Video coming soon</span>
</div>
</div>
<div className="presentation-card">
<h3>Exploring Polycentric Governance in Web3 Ecosystems</h3>
<p>Understanding multi-level governance structures in decentralized networks</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/zzoy/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Exploring Polycentric Governance in Web3 Ecosystems"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at the OpenWeb Hackathon</span>
<a href="https://youtu.be/ZIWskNogafg?si=DmUbOQJaSRE1rdzq" target="_blank" rel="noopener noreferrer">
Watch the full talk
</a>
</div>
</div>
<div className="presentation-card">
<h3>MycoFi for Myco-munnities</h3>
<p>Exploring mycelial financial systems for community-based organizations</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/xoea/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="MycoFi for Myco-munnities"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at CoFi Gathering in Liege</span>
<span>Video coming soon</span>
</div>
</div>
<div className="presentation-card">
<h3>Building Community Resilience in an Age of Crisis</h3>
<p>Internet outages during crises, such as wars or environmental disasters, can disrupt communication, education, and access to vital information. Preparing for such disruptions is essential for communities and organizations operating in challenging environments.</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/afbp/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Building Community Resilience in an Age of Crisis"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at re:publica conference in May 2025</span>
<a href="https://www.youtube.com/watch?v=rTOLk7k9Ad8" target="_blank" rel="noopener noreferrer">
Watch the full talk
</a>
</div>
</div>
</div>
</main>
)
}

133
src/routes/Resilience.tsx Normal file
View File

@ -0,0 +1,133 @@
export function Resilience() {
return (
<main>
<header>
<a href="/">Jeff Emmett</a>
</header>
<div className="presentation-info">
<h1>Building Community Resilience in an Age of Crisis</h1>
<p>
Internet outages during crises, such as wars or environmental disasters, can disrupt
communication, education, and access to vital information. Preparing for such disruptions
is essential for communities and organizations operating in challenging environments.
</p>
<p>
This presentation by Jeff Emmett and Fadia Elgharib explores strategies for building resilient communication networks
and maintaining access to critical information when traditional internet infrastructure fails.
</p>
</div>
<div className="presentation-embed">
<h2>Full Presentation</h2>
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/afbp/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Building Community Resilience in an Age of Crisis"
/>
</div>
</div>
<div className="video-clips">
<h2>Video Clips</h2>
<div className="video-section">
<h3>Full Talk at re:publica Conference</h3>
<div className="video-container">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/rTOLk7k9Ad8?si=Ye0gmEg_JhWbxS2a"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
</div>
<div className="video-section">
<h3>Clip 1: How can we mitigate crisis as a global community?</h3>
<div className="video-container">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/_ZVzlTzkv8Q?si=zKq2PatVXDDsC-71"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
</div>
<div className="video-section">
<h3>Clip 2: The moral imperative for collective harm reduction</h3>
<div className="video-container">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/sriN8AC_EKc?si=7lQS1gEHtEMXWjzD"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
</div>
<div className="video-section">
<h3>Clip 3: The need for protocols of community resilience</h3>
<div className="video-container">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/CufzFR_-JOg?si=kmhN39UaZskAyFCh"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
</div>
<div className="video-section">
<h3>Clip 4: Cosmo-localism and protocols of community care</h3>
<div className="video-container">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/IjMqssomIww?si=veLdLpGcdsyMRkey"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
</div>
</div>
<div className="presentation-meta">
<p>
<strong>Event:</strong> re:publica conference, May 2025<br />
<strong>Location:</strong> Berlin, Germany<br />
<strong>Topic:</strong> Building Community Resilience in an Age of Crisis
</p>
<p>
<a href="/presentations"> Back to all presentations</a>
</p>
</div>
</main>
)
}

View File

@ -0,0 +1,153 @@
Yes, it is possible to allow users of your website to render their own Google Docs securely, but it requires additional steps to ensure privacy, user authentication, and proper permissions. Here's how you can set it up:
---
### Steps to Enable Users to Render Their Own Google Docs
#### 1. Enable Google Sign-In for Your Website
- Users need to authenticate with their Google account to grant your app access to their documents.
- Use the [Google Sign-In library](https://developers.google.com/identity/sign-in/web) to implement OAuth authentication.
Steps:
- Include the Google Sign-In button on your site:
<script src="https://apis.google.com/js/platform.js" async defer></script>
<meta name="google-signin-client_id" content="YOUR_CLIENT_ID.apps.googleusercontent.com">
<div class="g-signin2" data-onsuccess="onSignIn"></div>
- Handle the user's authentication token on sign-in:
function onSignIn(googleUser) {
var profile = googleUser.getBasicProfile();
var idToken = googleUser.getAuthResponse().id_token;
// Send the token to your backend to authenticate and fetch user-specific documents
fetch('/api/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: idToken }),
}).then(response => response.json())
.then(data => console.log(data));
}
---
#### 2. Request Google Docs API Permissions
- Once the user is authenticated, request permissions for the Google Docs API.
- Scopes needed:
https://www.googleapis.com/auth/documents.readonly
- Example request for API access:
function requestDocsAccess() {
gapi.auth2.getAuthInstance().signIn({
scope: 'https://www.googleapis.com/auth/documents.readonly',
}).then(() => {
console.log('API access granted');
});
}
---
#### 3. Fetch User's Document Content
- After receiving user authorization, fetch their document content using the Google Docs API.
- Example using JavaScript:
gapi.client.load('docs', 'v1', function () {
var request = gapi.client.docs.documents.get({
documentId: 'USER_DOCUMENT_ID',
});
request.execute(function (response) {
console.log(response);
// Render document content on your website
document.getElementById('doc-container').innerHTML = response.body.content.map(
item => item.paragraph.elements.map(
el => el.textRun.content
).join('')
).join('<br>');
});
});
- Ensure that USER_DOCUMENT_ID is input by the user (e.g., through a form field).
---
#### 4. Secure Your Backend
- Create an API endpoint to handle requests for fetching document content.
- Validate the user's Google token on your server using Google's token verification endpoint.
- Use their authenticated token to call the Google Docs API and fetch the requested document.
Example in Python (using Flask):
from google.oauth2 import id_token
from google.auth.transport import requests
from googleapiclient.discovery import build
@app.route('/api/fetch-doc', methods=['POST'])
def fetch_doc():
token = request.json.get('token')
document_id = request.json.get('document_id')
# Verify token
idinfo = id_token.verify_oauth2_token(token, requests.Request(), CLIENT_ID)
if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']:
return 'Invalid token', 401
# Fetch the document
creds = id_token.Credentials(token=token)
service = build('docs', 'v1', credentials=creds)
doc = service.documents().get(documentId=document_id).execute()
return jsonify(doc)
---
rohan mehta, [2024-11-21 4:42 PM]
#### 5. Provide a Frontend UI
- Allow users to input their Google Doc ID through a form.
- Example:
<input type="text" id="doc-id" placeholder="Enter your Google Doc ID">
<button onclick="fetchDoc()">Render Doc</button>
<div id="doc-container"></div>
- JavaScript to send the document ID to your backend:
function fetchDoc() {
const docId = document.getElementById('doc-id').value;
fetch('/api/fetch-doc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: userToken, document_id: docId }),
})
.then(response => response.json())
.then(data => {
document.getElementById('doc-container').innerHTML = JSON.stringify(data);
});
}
---
### Security and Privacy Considerations
1. Authentication:
- Verify each user's Google token before processing their request.
- Only fetch documents they own or have shared with them.
2. Rate Limiting:
- Implement rate limiting on your backend API to prevent abuse.
3. Permission Scope:
- Use the minimal scope (documents.readonly) to ensure you can only read documents, not modify them.
4. Data Handling:
- Never store user document content unless explicitly required and with user consent.
---
With this approach, each user will be able to render their own Google Docs securely while maintaining privacy. Let me know if youd like a more detailed implementation in any specific programming language!

View File

@ -0,0 +1,191 @@
import { useEffect, useRef, useState } from "react"
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
export type IChatBoxShape = TLBaseShape<
"ChatBox",
{
w: number
h: number
roomId: string
userName: string
}
>
export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
static override type = "ChatBox"
getDefaultProps(): IChatBoxShape["props"] {
return {
roomId: "default-room",
w: 100,
h: 100,
userName: "",
}
}
indicator(shape: IChatBoxShape) {
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
}
component(shape: IChatBoxShape) {
return (
<ChatBox
roomId={shape.props.roomId}
w={shape.props.w}
h={shape.props.h}
userName=""
/>
)
}
}
interface Message {
id: string
username: string
content: string
timestamp: Date
}
// Update the ChatBox component to accept userName
export const ChatBox: React.FC<IChatBoxShape["props"]> = ({
roomId,
w,
h,
userName,
}) => {
const [messages, setMessages] = useState<Message[]>([])
const [inputMessage, setInputMessage] = useState("")
const [username, setUsername] = useState(userName)
const messagesEndRef = useRef(null)
useEffect(() => {
const storedUsername = localStorage.getItem("chatUsername")
if (storedUsername) {
setUsername(storedUsername)
} else {
const newUsername = `User${Math.floor(Math.random() * 1000)}`
setUsername(newUsername)
localStorage.setItem("chatUsername", newUsername)
}
fetchMessages(roomId)
const interval = setInterval(() => fetchMessages(roomId), 2000)
return () => clearInterval(interval)
}, [roomId])
useEffect(() => {
if (messagesEndRef.current) {
;(messagesEndRef.current as HTMLElement).scrollIntoView({
behavior: "smooth",
})
}
}, [messages])
const fetchMessages = async (roomId: string) => {
try {
const response = await fetch(
`https://jeffemmett-realtimechatappwithpolling.web.val.run?action=getMessages&roomId=${roomId}`,
)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const newMessages = (await response.json()) as Message[]
setMessages(
newMessages.map((msg) => ({
...msg,
timestamp: new Date(msg.timestamp),
})),
)
} catch (error) {
console.error("Error fetching messages:", error)
}
}
const sendMessage = async (e: React.FormEvent) => {
e.preventDefault()
if (!inputMessage.trim()) return
await sendMessageToChat(roomId, username, inputMessage)
setInputMessage("")
fetchMessages(roomId)
}
return (
<div
className="chat-container"
style={{
pointerEvents: "all",
width: `${w}px`,
height: `${h}px`,
overflow: "auto",
touchAction: "auto",
}}
>
<div className="messages-container">
{messages.map((msg) => (
<div
key={msg.id}
className={`message ${
msg.username === username ? "own-message" : ""
}`}
>
<div className="message-header">
<strong>{msg.username}</strong>
<span className="timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
<div className="message-content">{msg.content}</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={sendMessage} className="input-form">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder="Type a message..."
className="message-input"
style={{ touchAction: "manipulation" }}
/>
<button
type="submit"
style={{ pointerEvents: "all", touchAction: "manipulation" }}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
className="send-button"
>
Send
</button>
</form>
</div>
)
}
async function sendMessageToChat(
roomId: string,
username: string,
content: string,
): Promise<void> {
const apiUrl = "https://jeffemmett-realtimechatappwithpolling.web.val.run" // Replace with your actual Val Town URL
try {
const response = await fetch(`${apiUrl}?action=sendMessage`, {
method: "POST",
mode: "no-cors",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
roomId,
username,
content,
}),
})
const result = await response.text()
//console.log("Message sent successfully:", result)
} catch (error) {
console.error("Error sending message:", error)
}
}

View File

@ -0,0 +1,626 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
import { useCallback, useState } from "react"
//import Embed from "react-embed"
//TODO: FIX PEN AND MOBILE INTERACTION WITH EDITING EMBED URL - DEFAULT TO TEXT SELECTED
export type IEmbedShape = TLBaseShape<
"Embed",
{
w: number
h: number
url: string | null
isMinimized?: boolean
interactionState?: {
scrollPosition?: { x: number; y: number }
currentTime?: number // for videos
// other state you want to sync
}
}
>
const transformUrl = (url: string): string => {
// YouTube
const youtubeMatch = url.match(
/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/,
)
if (youtubeMatch) {
return `https://www.youtube.com/embed/${youtubeMatch[1]}`
}
// Google Maps
if (url.includes("google.com/maps") || url.includes("goo.gl/maps")) {
// If it's already an embed URL, return as is
if (url.includes("google.com/maps/embed")) {
return url
}
// Handle directions
const directionsMatch = url.match(/dir\/([^\/]+)\/([^\/]+)/)
if (directionsMatch || url.includes("/dir/")) {
const origin = url.match(/origin=([^&]+)/)?.[1] || directionsMatch?.[1]
const destination =
url.match(/destination=([^&]+)/)?.[1] || directionsMatch?.[2]
if (origin && destination) {
return `https://www.google.com/maps/embed/v1/directions?key=${
import.meta.env["VITE_GOOGLE_MAPS_API_KEY"]
}&origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(
destination,
)}&mode=driving`
}
}
// Extract place ID
const placeMatch = url.match(/[?&]place_id=([^&]+)/)
if (placeMatch) {
return `https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2!2d0!3d0!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s${placeMatch[1]}!2s!5e0!3m2!1sen!2s!4v1`
}
// For all other map URLs
return `https://www.google.com/maps/embed/v1/place?key=${
import.meta.env.VITE_GOOGLE_MAPS_API_KEY
}&q=${encodeURIComponent(url)}`
}
// Twitter/X
const xMatch = url.match(
/(?:twitter\.com|x\.com)\/([^\/\s?]+)(?:\/(?:status|tweets)\/(\d+)|$)/,
)
if (xMatch) {
const [, username, tweetId] = xMatch
if (tweetId) {
// For tweets
return `https://platform.x.com/embed/Tweet.html?id=${tweetId}`
} else {
// For profiles, return about:blank and handle display separately
return "about:blank"
}
}
// Medium - return about:blank to prevent iframe loading
if (url.includes("medium.com")) {
return "about:blank"
}
// Gather.town
if (url.includes("app.gather.town")) {
return url.replace("app.gather.town", "gather.town/embed")
}
return url
}
const getDefaultDimensions = (url: string): { w: number; h: number } => {
// YouTube default dimensions (16:9 ratio)
if (url.match(/(?:youtube\.com|youtu\.be)/)) {
return { w: 800, h: 450 }
}
// Twitter/X default dimensions
if (url.match(/(?:twitter\.com|x\.com)/)) {
if (url.match(/\/status\/|\/tweets\//)) {
return { w: 800, h: 600 } // For individual tweets
}
}
// Google Maps default dimensions
if (url.includes("google.com/maps") || url.includes("goo.gl/maps")) {
return { w: 800, h: 600 }
}
// Gather.town default dimensions
if (url.includes("gather.town")) {
return { w: 800, h: 600 }
}
// Default dimensions for other embeds
return { w: 800, h: 600 }
}
const getFaviconUrl = (url: string): string => {
try {
const urlObj = new URL(url)
return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32`
} catch {
return '' // Return empty if URL is invalid
}
}
const getDisplayTitle = (url: string): string => {
try {
const urlObj = new URL(url)
// Handle special cases
if (urlObj.hostname.includes('youtube.com')) {
return 'YouTube'
}
if (urlObj.hostname.includes('twitter.com') || urlObj.hostname.includes('x.com')) {
return 'Twitter/X'
}
if (urlObj.hostname.includes('google.com/maps')) {
return 'Google Maps'
}
// Default: return clean hostname
return urlObj.hostname.replace('www.', '')
} catch {
return url // Return original URL if parsing fails
}
}
export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
static override type = "Embed"
getDefaultProps(): IEmbedShape["props"] {
return {
url: null,
w: 800,
h: 600,
isMinimized: false,
}
}
indicator(shape: IEmbedShape) {
return (
<rect
x={0}
y={0}
width={shape.props.w}
height={shape.props.isMinimized ? 40 : shape.props.h}
fill="none"
/>
)
}
component(shape: IEmbedShape) {
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [inputUrl, setInputUrl] = useState(shape.props.url || "")
const [error, setError] = useState("")
const [copyStatus, setCopyStatus] = useState(false)
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
let completedUrl =
inputUrl.startsWith("http://") || inputUrl.startsWith("https://")
? inputUrl
: `https://${inputUrl}`
// Basic URL validation
const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//)
if (!isValidUrl) {
setError("Invalid URL")
return
}
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: "Embed",
props: { ...shape.props, url: completedUrl },
})
setError("")
},
[inputUrl],
)
const handleIframeInteraction = (
newState: typeof shape.props.interactionState,
) => {
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: "Embed",
props: {
...shape.props,
interactionState: newState,
},
})
}
const contentStyle = {
pointerEvents: isSelected ? "none" as const : "all" as const,
width: "100%",
height: "100%",
border: "1px solid #D3D3D3",
backgroundColor: "#FFFFFF",
display: "flex",
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
}
const wrapperStyle = {
position: 'relative' as const,
width: `${shape.props.w}px`,
height: `${shape.props.isMinimized ? 40 : shape.props.h}px`,
backgroundColor: "#F0F0F0",
borderRadius: "4px",
transition: "height 0.3s, width 0.3s",
overflow: "hidden",
}
// Update control button styles
const controlButtonStyle = {
border: "none",
background: "#666666", // Grey background
color: "white", // White text
padding: "4px 12px",
margin: "0 4px",
borderRadius: "4px",
cursor: "pointer",
fontSize: "12px",
pointerEvents: "all" as const,
whiteSpace: "nowrap" as const,
transition: "background-color 0.2s",
"&:hover": {
background: "#4D4D4D", // Darker grey on hover
}
}
const controlsContainerStyle = {
position: "absolute" as const,
top: "8px",
right: "8px",
display: "flex",
gap: "8px",
zIndex: 1,
}
const handleToggleMinimize = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: "Embed",
props: {
...shape.props,
isMinimized: !shape.props.isMinimized,
},
})
}
const controls = (url: string) => (
<div style={controlsContainerStyle}>
<button
onClick={() => navigator.clipboard.writeText(url)}
style={controlButtonStyle}
onPointerDown={(e) => e.stopPropagation()}
>
Copy Link
</button>
<button
onClick={() => window.open(url, '_blank')}
style={controlButtonStyle}
onPointerDown={(e) => e.stopPropagation()}
>
Open in Tab
</button>
<button
onClick={handleToggleMinimize}
style={controlButtonStyle}
onPointerDown={(e) => e.stopPropagation()}
>
{shape.props.isMinimized ? "Maximize" : "Minimize"}
</button>
</div>
)
// For minimized state, show URL and all controls
if (shape.props.url && shape.props.isMinimized) {
return (
<div style={wrapperStyle}>
<div
style={{
...contentStyle,
height: "40px",
alignItems: "center",
padding: "0 15px",
position: "relative",
display: "flex",
gap: "8px",
}}
>
<img
src={getFaviconUrl(shape.props.url)}
alt=""
style={{
width: "16px",
height: "16px",
flexShrink: 0,
}}
onError={(e) => {
// Hide broken favicon
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
overflow: "hidden",
flex: 1,
}}
>
<span
style={{
fontWeight: 500,
color: "#333",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{getDisplayTitle(shape.props.url)}
</span>
<span
style={{
fontSize: "11px",
color: "#666",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{shape.props.url}
</span>
</div>
{controls(shape.props.url)}
</div>
</div>
)
}
// For empty state
if (!shape.props.url) {
return (
<div style={wrapperStyle}>
{controls("")}
<div
style={{
...contentStyle,
cursor: 'text', // Add text cursor to indicate clickable
touchAction: 'none', // Prevent touch scrolling
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const input = e.currentTarget.querySelector('input')
input?.focus()
}}
>
<form
onSubmit={handleSubmit}
style={{
width: "100%",
height: "100%",
padding: "10px",
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder="Enter URL to embed"
style={{
width: "100%",
padding: "15px", // Increased padding for better touch target
border: "1px solid #ccc",
borderRadius: "4px",
fontSize: "16px", // Increased font size for better visibility
touchAction: 'none',
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSubmit(e)
}
}}
onPointerDown={(e) => {
e.stopPropagation()
e.currentTarget.focus()
}}
/>
{error && (
<div style={{ color: "red", marginTop: "10px" }}>{error}</div>
)}
</form>
</div>
</div>
)
}
// For medium.com and twitter profile views
if (shape.props.url?.includes("medium.com") ||
(shape.props.url && shape.props.url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))) {
return (
<div style={wrapperStyle}>
{controls(shape.props.url)}
<div
style={{
...contentStyle,
flexDirection: "column",
gap: "12px",
padding: "20px",
textAlign: "center",
pointerEvents: "all",
}}
>
<p>
Medium's content policy does not allow for embedding articles in
iframes.
</p>
<a
href={shape.props.url}
target="_blank"
rel="noopener noreferrer"
style={{
color: "#1976d2",
textDecoration: "none",
cursor: "pointer",
}}
>
Open article in new tab
</a>
</div>
</div>
)
}
// For normal embed view
return (
<div style={wrapperStyle}>
<div
style={{
height: "40px",
position: "relative",
backgroundColor: "#F0F0F0",
borderTopLeftRadius: "4px",
borderTopRightRadius: "4px",
display: "flex",
alignItems: "center",
padding: "0 8px",
}}
>
{controls(shape.props.url)}
</div>
{!shape.props.isMinimized && (
<>
<div style={{
...contentStyle,
height: `${shape.props.h - 80}px`,
}}>
<iframe
src={transformUrl(shape.props.url)}
width="100%"
height="100%"
style={{ border: "none" }}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer"
onLoad={(e) => {
// Only add listener if we have a valid iframe
const iframe = e.currentTarget as HTMLIFrameElement
if (!iframe) return;
const messageHandler = (event: MessageEvent) => {
if (event.source === iframe.contentWindow) {
handleIframeInteraction(event.data)
}
}
window.addEventListener("message", messageHandler)
// Clean up listener when iframe changes
return () => window.removeEventListener("message", messageHandler)
}}
/>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px",
height: "40px",
fontSize: "12px",
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderRadius: "4px",
position: "absolute",
bottom: 0,
left: 0,
right: 0,
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
marginRight: "8px",
color: "#666",
}}
>
{shape.props.url}
</span>
</div>
</>
)}
</div>
)
}
override onDoubleClick = (shape: IEmbedShape) => {
// If no URL is set, focus the input field
if (!shape.props.url) {
const input = document.querySelector('input')
input?.focus()
return
}
// For Medium articles and Twitter profiles that show alternative content
if (
shape.props.url.includes('medium.com') ||
(shape.props.url && shape.props.url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))
) {
window.top?.open(shape.props.url, '_blank', 'noopener,noreferrer')
return
}
// For other embeds, enable interaction by temporarily removing pointer-events: none
const iframe = document.querySelector(`[data-shape-id="${shape.id}"] iframe`) as HTMLIFrameElement
if (iframe) {
iframe.style.pointerEvents = 'all'
// Reset pointer-events after interaction
const cleanup = () => {
iframe.style.pointerEvents = 'none'
window.removeEventListener('pointerdown', cleanup)
}
window.addEventListener('pointerdown', cleanup)
}
}
// Update the pointer down handler
onPointerDown = (shape: IEmbedShape) => {
if (!shape.props.url) {
const input = document.querySelector('input')
input?.focus()
}
}
// Add a method to handle URL updates
override onBeforeCreate = (shape: IEmbedShape) => {
if (shape.props.url) {
const dimensions = getDefaultDimensions(shape.props.url)
return {
...shape,
props: {
...shape.props,
w: dimensions.w,
h: dimensions.h,
},
}
}
return shape
}
// Handle URL updates after creation
override onBeforeUpdate = (prev: IEmbedShape, next: IEmbedShape) => {
if (next.props.url && prev.props.url !== next.props.url) {
const dimensions = getDefaultDimensions(next.props.url)
return {
...next,
props: {
...next.props,
w: dimensions.w,
h: dimensions.h,
},
}
}
return next
}
}

View File

@ -0,0 +1,178 @@
import React from 'react'
import MDEditor from '@uiw/react-md-editor'
import { BaseBoxShapeUtil, TLBaseShape } from '@tldraw/tldraw'
export type IMarkdownShape = TLBaseShape<
'Markdown',
{
w: number
h: number
text: string
}
>
export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
static type = 'Markdown' as const
getDefaultProps(): IMarkdownShape['props'] {
return {
w: 500,
h: 400,
text: '',
}
}
component(shape: IMarkdownShape) {
// Hooks must be at the top level
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const markdownRef = React.useRef<HTMLDivElement>(null)
// Single useEffect hook that handles checkbox interactivity
React.useEffect(() => {
if (!isSelected && markdownRef.current) {
const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]')
checkboxes.forEach((checkbox) => {
checkbox.removeAttribute('disabled')
checkbox.addEventListener('click', handleCheckboxClick)
})
// Cleanup function
return () => {
if (markdownRef.current) {
const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]')
checkboxes.forEach((checkbox) => {
checkbox.removeEventListener('click', handleCheckboxClick)
})
}
}
}
}, [isSelected, shape.props.text])
// Handler function defined outside useEffect
const handleCheckboxClick = (event: Event) => {
event.stopPropagation()
const target = event.target as HTMLInputElement
const checked = target.checked
const text = shape.props.text
const lines = text.split('\n')
const checkboxRegex = /^\s*[-*+]\s+\[([ x])\]/
const newText = lines.map(line => {
if (line.includes(target.parentElement?.textContent || '')) {
return line.replace(checkboxRegex, `- [${checked ? 'x' : ' '}]`)
}
return line
}).join('\n')
this.editor.updateShape<IMarkdownShape>({
id: shape.id,
type: 'Markdown',
props: {
...shape.props,
text: newText,
},
})
}
const wrapperStyle: React.CSSProperties = {
width: '100%',
height: '100%',
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '4px',
overflow: 'hidden',
}
// Simplified contentStyle - removed padding and center alignment
const contentStyle: React.CSSProperties = {
width: '100%',
height: '100%',
backgroundColor: '#FFFFFF',
cursor: isSelected ? 'text' : 'default',
pointerEvents: 'all',
}
// Show MDEditor when selected
if (isSelected) {
return (
<div style={wrapperStyle}>
<div style={contentStyle}>
<MDEditor
value={shape.props.text}
onChange={(value = '') => {
this.editor.updateShape<IMarkdownShape>({
id: shape.id,
type: 'Markdown',
props: {
...shape.props,
text: value,
},
})
}}
preview='live'
visibleDragbar={false}
style={{
height: 'auto',
minHeight: '100%',
border: 'none',
backgroundColor: 'transparent',
}}
previewOptions={{
style: {
padding: '8px',
backgroundColor: 'transparent',
}
}}
textareaProps={{
style: {
padding: '8px',
lineHeight: '1.5',
height: 'auto',
minHeight: '100%',
resize: 'none',
backgroundColor: 'transparent',
}
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
/>
</div>
</div>
)
}
// Show rendered markdown when not selected
return (
<div style={wrapperStyle}>
<div style={contentStyle}>
<div ref={markdownRef} style={{ width: '100%', height: '100%', padding: '12px' }}>
{shape.props.text ? (
<MDEditor.Markdown source={shape.props.text} />
) : (
<span style={{ opacity: 0.5 }}>Click to edit markdown...</span>
)}
</div>
</div>
</div>
)
}
indicator(shape: IMarkdownShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
// Add handlers for better interaction
override onDoubleClick = (shape: IMarkdownShape) => {
const textarea = document.querySelector(`[data-shape-id="${shape.id}"] textarea`) as HTMLTextAreaElement
textarea?.focus()
}
onPointerDown = (shape: IMarkdownShape) => {
if (!shape.props.text) {
const textarea = document.querySelector(`[data-shape-id="${shape.id}"] textarea`) as HTMLTextAreaElement
textarea?.focus()
}
}
}

View File

@ -0,0 +1,81 @@
import { BaseBoxShapeUtil, TLBaseShape, TLResizeInfo} from "@tldraw/tldraw"
export type IMycrozineTemplateShape = TLBaseShape<
"MycrozineTemplate",
{
w: number
h: number
}
>
export class MycrozineTemplateShape extends BaseBoxShapeUtil<IMycrozineTemplateShape> {
static override type = "MycrozineTemplate"
getDefaultProps(): IMycrozineTemplateShape["props"] {
// 8.5" × 11" at 300 DPI = 2550 × 3300 pixels
const props = {
w: 2550,
h: 3300,
}
//console.log('MycrozineTemplate - Default props:', props)
return props
}
indicator(shape: IMycrozineTemplateShape) {
return (
<g>
<rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
</g>
)
}
containerStyle = {
position: 'relative' as const,
backgroundColor: 'transparent',
border: '1px solid #666',
borderRadius: '2px',
}
verticalGuideStyle = {
position: 'absolute' as const,
left: '50%',
top: 0,
bottom: 0,
borderLeft: '1px dashed #666',
}
horizontalGuideStyle = {
position: 'absolute' as const,
left: 0,
right: 0,
borderTop: '1px dashed #666',
}
component(shape: IMycrozineTemplateShape) {
const { w, h } = shape.props
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
return (
<div
style={{
...this.containerStyle,
width: `${w}px`,
height: `${h}px`,
pointerEvents: isSelected ? 'none' : 'all'
}}
>
<div style={this.verticalGuideStyle} />
{[0.25, 0.5, 0.75].map((ratio, index) => (
<div
key={index}
style={{
...this.horizontalGuideStyle,
top: `${ratio * 100}%`,
}}
/>
))}
</div>
)
}
}

View File

@ -0,0 +1,474 @@
import {
BaseBoxShapeUtil,
HTMLContainer,
TLBaseShape,
TLGeoShape,
TLShape,
} from "tldraw"
import { getEdge } from "@/propagators/tlgraph"
import { llm, getApiKey } from "@/utils/llmUtils"
import { isShapeOfType } from "@/propagators/utils"
import React, { useState } from "react"
type IPrompt = TLBaseShape<
"Prompt",
{
w: number
h: number
prompt: string
value: string
agentBinding: string | null
}
>
// Add this SVG copy icon component at the top level of the file
const CopyIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"/>
</svg>
)
const CheckIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
)
export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
static override type = "Prompt" as const
FIXED_HEIGHT = 500 as const
MIN_WIDTH = 200 as const
PADDING = 4 as const
getDefaultProps(): IPrompt["props"] {
return {
w: 300,
h: 50,
prompt: "",
value: "",
agentBinding: null,
}
}
// override onResize: TLResizeHandle<IPrompt> = (
// shape,
// { scaleX, initialShape },
// ) => {
// const { x, y } = shape
// const w = initialShape.props.w * scaleX
// return {
// x,
// y,
// props: {
// ...shape.props,
// w: Math.max(Math.abs(w), this.MIN_WIDTH),
// h: this.FIXED_HEIGHT,
// },
// }
// }
component(shape: IPrompt) {
const arrowBindings = this.editor.getBindingsInvolvingShape(
shape.id,
"arrow",
)
const arrows = arrowBindings.map((binding) =>
this.editor.getShape(binding.fromId),
)
const inputMap = arrows.reduce((acc, arrow) => {
const edge = getEdge(arrow, this.editor)
if (edge) {
const sourceShape = this.editor.getShape(edge.from)
if (sourceShape && edge.text) {
acc[edge.text] = sourceShape
}
}
return acc
}, {} as Record<string, TLShape>)
const generateText = async (prompt: string) => {
console.log("🎯 generateText called with prompt:", prompt);
const conversationHistory = shape.props.value ? shape.props.value + '\n' : ''
const escapedPrompt = prompt.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
const userMessage = `{"role": "user", "content": "${escapedPrompt}"}`
console.log("💬 User message:", userMessage);
console.log("📚 Conversation history:", conversationHistory);
// Update with user message and trigger scroll
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: {
value: conversationHistory + userMessage,
agentBinding: "someone"
},
})
let fullResponse = ''
console.log("🚀 Calling llm function...");
try {
await llm(prompt, (partial: string, done?: boolean) => {
console.log(`📝 LLM callback received - partial: "${partial}", done: ${done}`);
if (partial) {
fullResponse = partial
const escapedResponse = partial.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}`
console.log("🤖 Assistant message:", assistantMessage);
try {
JSON.parse(assistantMessage)
// Use requestAnimationFrame to ensure smooth scrolling during streaming
requestAnimationFrame(() => {
console.log("🔄 Updating shape with partial response...");
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: {
value: conversationHistory + userMessage + '\n' + assistantMessage,
agentBinding: done ? null : "someone"
},
})
})
} catch (error) {
console.error('❌ Invalid JSON message:', error)
}
}
})
console.log("✅ LLM function completed successfully");
} catch (error) {
console.error("❌ Error in LLM function:", error);
}
// Ensure the final message is saved after streaming is complete
if (fullResponse) {
console.log("💾 Saving final response:", fullResponse);
const escapedResponse = fullResponse.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}`
try {
// Verify the final message is valid JSON before updating
JSON.parse(assistantMessage)
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: {
value: conversationHistory + userMessage + '\n' + assistantMessage,
agentBinding: null
},
})
console.log("✅ Final response saved successfully");
} catch (error) {
console.error('❌ Invalid JSON in final message:', error)
}
}
}
const handlePrompt = () => {
if (shape.props.agentBinding) {
return
}
let processedPrompt = shape.props.prompt
for (const [key, sourceShape] of Object.entries(inputMap)) {
const pattern = `{${key}}`
if (processedPrompt.includes(pattern)) {
if (isShapeOfType<TLGeoShape>(sourceShape, "geo")) {
processedPrompt = processedPrompt.replace(
pattern,
(sourceShape.meta as any)?.text || "",
)
}
}
}
generateText(processedPrompt)
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: { prompt: "" },
})
}
// Add state for copy button text
const [copyButtonText, setCopyButtonText] = React.useState("Copy Conversation")
// In the component function, add state for tracking copy success
const [isCopied, setIsCopied] = React.useState(false)
// In the component function, update the state to track which message was copied
const [copiedIndex, setCopiedIndex] = React.useState<number | null>(null)
// Add ref for the chat container
const chatContainerRef = React.useRef<HTMLDivElement>(null)
// Add function to scroll to bottom
const scrollToBottom = () => {
if (chatContainerRef.current) {
// Use requestAnimationFrame for smooth scrolling
requestAnimationFrame(() => {
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
}
})
}
}
// Use both value and agentBinding as dependencies to catch all updates
React.useEffect(() => {
scrollToBottom()
}, [shape.props.value, shape.props.agentBinding])
const handleCopy = async () => {
try {
// Parse and format each message
const messages = shape.props.value
.split('\n')
.filter(line => line.trim())
.map(line => {
try {
const parsed = JSON.parse(line);
return `${parsed.role}: ${parsed.content}`;
} catch {
return null;
}
})
.filter(Boolean)
.join('\n\n');
await navigator.clipboard.writeText(messages);
setCopyButtonText("Copied!");
setTimeout(() => {
setCopyButtonText("Copy Conversation");
}, 2000);
} catch (err) {
console.error('Failed to copy text:', err);
setCopyButtonText("Failed to copy");
setTimeout(() => {
setCopyButtonText("Copy Conversation");
}, 2000);
}
};
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [isHovering, setIsHovering] = useState(false)
return (
<HTMLContainer
style={{
borderRadius: 6,
border: "1px solid lightgrey",
padding: this.PADDING,
height: this.FIXED_HEIGHT,
width: shape.props.w,
pointerEvents: isSelected || isHovering ? "all" : "none",
backgroundColor: "#efefef",
overflow: "visible",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: "stretch",
outline: shape.props.agentBinding ? "2px solid orange" : "none",
}}
//TODO: FIX SCROLL IN PROMPT CHAT WHEN HOVERING OVER ELEMENT
onPointerEnter={() => setIsHovering(true)}
onPointerLeave={() => setIsHovering(false)}
onWheel={(e) => {
if (isSelected || isHovering) {
e.preventDefault()
e.stopPropagation()
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop += e.deltaY
}
}
}}
>
<div
ref={chatContainerRef}
style={{
padding: "4px 8px",
flex: 1,
backgroundColor: "white",
borderRadius: "4px",
marginBottom: "4px",
fontSize: "14px",
overflowY: "auto",
whiteSpace: "pre-wrap",
fontFamily: "monospace",
pointerEvents: isSelected || isHovering ? "all" : "none",
}}
>
{shape.props.value ? (
shape.props.value.split('\n').map((message, index) => {
if (!message.trim()) return null;
try {
const parsed = JSON.parse(message);
const isUser = parsed.role === "user";
return (
<div
key={index}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: isUser ? 'flex-end' : 'flex-start',
margin: '8px 0',
maxWidth: '100%',
position: 'relative',
}}
>
<div
style={{
padding: '12px 16px',
maxWidth: '80%',
backgroundColor: isUser ? '#007AFF' : '#f0f0f0',
color: isUser ? 'white' : 'black',
borderRadius: isUser ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
}}
>
{parsed.content}
<button
style={{
position: 'absolute',
bottom: '-20px',
right: isUser ? '0' : 'auto',
left: isUser ? 'auto' : '0',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '12px',
color: '#666',
opacity: 0.7,
transition: 'opacity 0.2s',
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
onClick={async () => {
try {
await navigator.clipboard.writeText(parsed.content)
setCopiedIndex(index)
setTimeout(() => {
setCopiedIndex(null)
}, 2000)
} catch (err) {
console.error('Failed to copy text:', err)
}
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.7'
}}
>
{copiedIndex === index ? <CheckIcon /> : <CopyIcon />}
</button>
</div>
</div>
);
} catch {
return null; // Skip invalid JSON
}
})
) : (
"Chat history will appear here..."
)}
</div>
<div style={{
display: "flex",
flexDirection: "column",
gap: "5px",
marginTop: "auto",
pointerEvents: isSelected || isHovering ? "all" : "none",
}}>
<div style={{
display: "flex",
gap: "5px"
}}>
<input
style={{
width: "100%",
height: "40px",
overflow: "visible",
backgroundColor: "rgba(0, 0, 0, 0.05)",
border: "1px solid rgba(0, 0, 0, 0.05)",
borderRadius: 6 - this.PADDING,
fontSize: 16,
}}
type="text"
placeholder="Enter prompt..."
value={shape.props.prompt}
onChange={(text) => {
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: { prompt: text.target.value },
})
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handlePrompt()
}
}}
/>
<button
style={{
width: 100,
height: "40px",
pointerEvents: "all",
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
type="button"
onClick={handlePrompt}
>
Prompt
</button>
</div>
<button
style={{
width: "100%",
height: "30px",
pointerEvents: "all",
backgroundColor: "#f0f0f0",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
onClick={handleCopy}
>
{copyButtonText}
</button>
</div>
</HTMLContainer>
)
}
// Override the default indicator behavior
// TODO: FIX SECOND INDICATOR UX GLITCH
override indicator(shape: IPrompt) {
return (
<rect
width={shape.props.w}
height={this.FIXED_HEIGHT}
rx={6}
/>
)
}
}

View File

@ -0,0 +1,267 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
import { useCallback, useState } from "react"
export type ISharedPianoShape = TLBaseShape<
"SharedPiano",
{
w: number
h: number
isMinimized?: boolean
interactionState?: {
scrollPosition?: { x: number; y: number }
}
}
>
const getDefaultDimensions = (): { w: number; h: number } => {
// Default dimensions for the Shared Piano (16:9 ratio)
return { w: 800, h: 600 }
}
export class SharedPianoShape extends BaseBoxShapeUtil<ISharedPianoShape> {
static override type = "SharedPiano"
getDefaultProps(): ISharedPianoShape["props"] {
const { w, h } = getDefaultDimensions()
return {
w,
h,
isMinimized: false,
}
}
indicator(shape: ISharedPianoShape) {
return (
<rect
width={shape.props.w}
height={shape.props.h}
fill="none"
stroke="var(--color-selected)"
strokeWidth={2}
/>
)
}
component(shape: ISharedPianoShape) {
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const handleIframeLoad = useCallback(() => {
setIsLoading(false)
setError(null)
}, [])
const handleIframeError = useCallback(() => {
setIsLoading(false)
setError("Failed to load Shared Piano. Please check your browser permissions for MIDI and audio access.")
}, [])
const handleToggleMinimize = (e: React.MouseEvent) => {
e.stopPropagation()
this.editor.updateShape<ISharedPianoShape>({
id: shape.id,
type: "SharedPiano",
props: {
...shape.props,
isMinimized: !shape.props.isMinimized,
},
})
}
const controls = (
<div
style={{
position: "absolute",
top: 8,
right: 8,
zIndex: 10,
display: "flex",
gap: 4,
pointerEvents: "auto",
}}
>
<button
onClick={handleToggleMinimize}
style={{
background: "rgba(0, 0, 0, 0.7)",
color: "white",
border: "none",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "12px",
cursor: "pointer",
}}
>
{shape.props.isMinimized ? "🔽" : "🔼"}
</button>
</div>
)
const sharedPianoUrl = "https://musiclab.chromeexperiments.com/Shared-Piano/#jQB715bFJ"
return (
<div
style={{
width: "100%",
height: "100%",
position: "relative",
overflow: "hidden",
borderRadius: "8px",
border: "1px solid var(--color-panel)",
backgroundColor: "var(--color-background)",
zIndex: 1,
pointerEvents: "auto",
}}
>
{controls}
{shape.props.isMinimized ? (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
fontSize: "16px",
fontWeight: "bold",
}}
>
🎹 Shared Piano
</div>
) : (
<div style={{ position: "relative", width: "100%", height: "100%", zIndex: 1 }}>
{isLoading && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--color-background)",
zIndex: 3,
pointerEvents: "auto",
}}
>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: "24px", marginBottom: "8px" }}>🎹</div>
<div>Loading Shared Piano...</div>
</div>
</div>
)}
{error && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--color-background)",
zIndex: 3,
pointerEvents: "auto",
}}
>
<div style={{ textAlign: "center", color: "var(--color-text)" }}>
<div style={{ fontSize: "24px", marginBottom: "8px" }}></div>
<div>{error}</div>
<button
onClick={() => {
setIsLoading(true)
setError(null)
// Force iframe reload
const iframe = document.querySelector(`iframe[data-shape-id="${shape.id}"]`) as HTMLIFrameElement
if (iframe) {
iframe.src = iframe.src
}
}}
style={{
marginTop: "8px",
padding: "4px 8px",
background: "var(--color-primary)",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
Retry
</button>
</div>
</div>
)}
<iframe
data-shape-id={shape.id}
src={sharedPianoUrl}
style={{
width: "100%",
height: "100%",
border: "none",
borderRadius: "8px",
opacity: isLoading ? 0 : 1,
transition: "opacity 0.3s ease",
position: "absolute",
top: 0,
left: 0,
zIndex: 2,
pointerEvents: "auto",
}}
onLoad={handleIframeLoad}
onError={handleIframeError}
title="Chrome Music Lab Shared Piano"
allow="microphone; camera; midi; autoplay; encrypted-media; fullscreen"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals"
/>
</div>
)}
</div>
)
}
override onDoubleClick = (shape: ISharedPianoShape) => {
// Toggle minimized state on double click
this.editor.updateShape<ISharedPianoShape>({
id: shape.id,
type: "SharedPiano",
props: {
...shape.props,
isMinimized: !shape.props.isMinimized,
},
})
}
onPointerDown = (_shape: ISharedPianoShape) => {
// Handle pointer down events if needed
}
override onBeforeCreate = (shape: ISharedPianoShape) => {
// Set default dimensions if not provided
if (!shape.props.w || !shape.props.h) {
const { w, h } = getDefaultDimensions()
this.editor.updateShape<ISharedPianoShape>({
id: shape.id,
type: "SharedPiano",
props: {
...shape.props,
w,
h,
},
})
}
}
onBeforeUpdate = (_prev: ISharedPianoShape, _next: ISharedPianoShape) => {
// Handle any updates if needed
}
}

View File

@ -0,0 +1,136 @@
import { useCallback } from "react"
import {
BaseBoxShapeUtil,
Geometry2d,
RecordProps,
Rectangle2d,
SVGContainer,
ShapeUtil,
T,
TLBaseShape,
getPerfectDashProps,
resizeBox,
useValue,
} from "tldraw"
import { moveToSlide, useSlides } from "@/slides/useSlides"
export type ISlideShape = TLBaseShape<
"Slide",
{
w: number
h: number
}
>
export class SlideShape extends BaseBoxShapeUtil<ISlideShape> {
static override type = "Slide"
// static override props = {
// w: T.number,
// h: T.number,
// }
override canBind = () => false
override hideRotateHandle = () => true
getDefaultProps(): ISlideShape["props"] {
return {
w: 720,
h: 480,
}
}
getGeometry(shape: ISlideShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: false,
})
}
override onRotate = (initial: ISlideShape) => initial
override onResize(shape: ISlideShape, info: any) {
return resizeBox(shape, info)
}
override onDoubleClick = (shape: ISlideShape) => {
moveToSlide(this.editor, shape)
this.editor.selectNone()
}
override onDoubleClickEdge = (shape: ISlideShape) => {
moveToSlide(this.editor, shape)
this.editor.selectNone()
}
component(shape: ISlideShape) {
const bounds = this.editor.getShapeGeometry(shape).bounds
// eslint-disable-next-line react-hooks/rules-of-hooks
const zoomLevel = useValue("zoom level", () => this.editor.getZoomLevel(), [
this.editor,
])
// eslint-disable-next-line react-hooks/rules-of-hooks
const slides = useSlides()
const index = slides.findIndex((s) => s.id === shape.id)
// eslint-disable-next-line react-hooks/rules-of-hooks
const handleLabelPointerDown = useCallback(
() => this.editor.select(shape.id),
[shape.id],
)
if (!bounds) return null
return (
<>
<div
onPointerDown={handleLabelPointerDown}
className="slide-shape-label"
>
{`Slide ${index + 1}`}
</div>
<SVGContainer>
<g
style={{
stroke: "var(--color-text)",
strokeWidth: "calc(1px * var(--tl-scale))",
opacity: 0.25,
}}
pointerEvents="none"
strokeLinecap="round"
strokeLinejoin="round"
>
{bounds.sides.map((side, i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
side[0].dist(side[1]),
1 / zoomLevel,
{
style: "dashed",
lengthRatio: 6,
},
)
return (
<line
key={i}
x1={side[0].x}
y1={side[0].y}
x2={side[1].x}
y2={side[1].y}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})}
</g>
</SVGContainer>
</>
)
}
indicator(shape: ISlideShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}

View File

@ -0,0 +1,701 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
import { useEffect, useState } from "react"
import { WORKER_URL } from "../routes/Board"
interface DailyApiResponse {
url: string;
}
interface DailyRecordingResponse {
id: string;
}
export type IVideoChatShape = TLBaseShape<
"VideoChat",
{
w: number
h: number
roomUrl: string | null
allowCamera: boolean
allowMicrophone: boolean
enableRecording: boolean
recordingId: string | null // Track active recording
enableTranscription: boolean
isTranscribing: boolean
transcriptionHistory: Array<{
sender: string
message: string
id: string
}>
meetingToken: string | null
isOwner: boolean
}
>
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
static override type = "VideoChat"
indicator(_shape: IVideoChatShape) {
return null
}
getDefaultProps(): IVideoChatShape["props"] {
const props = {
roomUrl: null,
w: 800,
h: 600,
allowCamera: false,
allowMicrophone: false,
enableRecording: true,
recordingId: null,
enableTranscription: true,
isTranscribing: false,
transcriptionHistory: [],
meetingToken: null,
isOwner: false
};
console.log('🔧 getDefaultProps called, returning:', props);
return props;
}
async generateMeetingToken(roomName: string) {
const workerUrl = WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
if (!apiKey) {
throw new Error('Daily.co API key not configured');
}
if (!workerUrl) {
throw new Error('Worker URL is not configured');
}
// For now, let's skip token generation and use a simpler approach
// We'll use the room URL directly and handle owner permissions differently
console.log('Skipping meeting token generation for now');
return `token_${roomName}_${Date.now()}`;
}
async ensureRoomExists(shape: IVideoChatShape) {
const boardId = this.editor.getCurrentPageId();
if (!boardId) {
throw new Error('Board ID is undefined');
}
// Try to get existing room URL from localStorage first
const storageKey = `videoChat_room_${boardId}`;
const existingRoomUrl = localStorage.getItem(storageKey);
const existingToken = localStorage.getItem(`${storageKey}_token`);
if (existingRoomUrl && existingRoomUrl !== 'undefined' && existingToken) {
console.log("Using existing room from storage:", existingRoomUrl);
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
roomUrl: existingRoomUrl,
meetingToken: existingToken,
isOwner: true, // Assume the creator is the owner
},
});
return;
}
if (shape.props.roomUrl !== null && shape.props.roomUrl !== 'undefined' && shape.props.meetingToken) {
console.log("Room already exists:", shape.props.roomUrl);
localStorage.setItem(storageKey, shape.props.roomUrl);
localStorage.setItem(`${storageKey}_token`, shape.props.meetingToken);
return;
}
try {
const workerUrl = WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
// Debug logging
console.log('🔧 VideoChat Debug:');
console.log('WORKER_URL:', WORKER_URL);
console.log('workerUrl:', workerUrl);
console.log('apiKey exists:', !!apiKey);
if (!apiKey) {
throw new Error('Daily.co API key not configured');
}
if (!workerUrl) {
throw new Error('Worker URL is not configured');
}
// Create room name based on board ID and timestamp
// Sanitize boardId to only use valid Daily.co characters (A-Z, a-z, 0-9, '-', '_')
const sanitizedBoardId = boardId.replace(/[^A-Za-z0-9\-_]/g, '_');
const roomName = `board_${sanitizedBoardId}_${Date.now()}`;
console.log('🔧 Room name generation:');
console.log('Original boardId:', boardId);
console.log('Sanitized boardId:', sanitizedBoardId);
console.log('Final roomName:', roomName);
const response = await fetch(`${workerUrl}/daily/rooms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
name: roomName,
properties: {
enable_chat: true,
enable_screenshare: true,
start_video_off: true,
start_audio_off: true
}
})
});
if (!response.ok) {
const error = await response.json()
throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`)
}
const data = (await response.json()) as DailyApiResponse;
const url = data.url;
if (!url) throw new Error("Room URL is missing")
// Generate meeting token for the owner
// First ensure the room exists, then generate token
const meetingToken = await this.generateMeetingToken(roomName);
// Store the room URL and token in localStorage
localStorage.setItem(storageKey, url);
localStorage.setItem(`${storageKey}_token`, meetingToken);
console.log("Room created successfully:", url)
console.log("Meeting token generated:", meetingToken)
console.log("Updating shape with new URL and token")
console.log("Setting isOwner to true")
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
roomUrl: url,
meetingToken: meetingToken,
isOwner: true,
},
})
console.log("Shape updated:", this.editor.getShape(shape.id))
const updatedShape = this.editor.getShape(shape.id) as IVideoChatShape;
console.log("Updated shape isOwner:", updatedShape?.props.isOwner)
} catch (error) {
console.error("Error in ensureRoomExists:", error)
throw error
}
}
async startRecording(shape: IVideoChatShape) {
if (!shape.props.roomUrl) return;
const workerUrl = WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
try {
// Extract room name from URL (same as transcription methods)
const roomName = shape.props.roomUrl.split('/').pop();
if (!roomName) {
throw new Error('Could not extract room name from URL');
}
const response = await fetch(`${workerUrl}/daily/recordings/start`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
room_name: roomName,
layout: {
preset: "active-speaker"
}
})
});
if (!response.ok) throw new Error('Failed to start recording');
const data = await response.json() as DailyRecordingResponse;
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
recordingId: data.id
}
});
} catch (error) {
console.error('Error starting recording:', error);
throw error;
}
}
async stopRecording(shape: IVideoChatShape) {
if (!shape.props.recordingId) return;
const workerUrl = WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
try {
await fetch(`${workerUrl}/daily/recordings/${shape.props.recordingId}/stop`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`
}
});
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
recordingId: null
}
});
} catch (error) {
console.error('Error stopping recording:', error);
throw error;
}
}
async startTranscription(shape: IVideoChatShape) {
console.log('🎤 startTranscription method called');
console.log('Shape props:', shape.props);
console.log('Room URL:', shape.props.roomUrl);
console.log('Is owner:', shape.props.isOwner);
if (!shape.props.roomUrl || !shape.props.isOwner) {
console.log('❌ Early return - missing roomUrl or not owner');
console.log('roomUrl exists:', !!shape.props.roomUrl);
console.log('isOwner:', shape.props.isOwner);
return;
}
try {
const workerUrl = WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
console.log('🔧 Environment variables:');
console.log('Worker URL:', workerUrl);
console.log('API Key exists:', !!apiKey);
// Extract room name from URL
const roomName = shape.props.roomUrl.split('/').pop();
console.log('📝 Extracted room name:', roomName);
if (!roomName) {
throw new Error('Could not extract room name from URL');
}
console.log('🌐 Making API request to start transcription...');
console.log('Request URL:', `${workerUrl}/daily/rooms/${roomName}/start-transcription`);
const response = await fetch(`${workerUrl}/daily/rooms/${roomName}/start-transcription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
});
console.log('📡 Response status:', response.status);
console.log('📡 Response ok:', response.ok);
if (!response.ok) {
const error = await response.json();
console.error('❌ API error response:', error);
throw new Error(`Failed to start transcription: ${JSON.stringify(error)}`);
}
console.log('✅ API call successful, updating shape...');
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
isTranscribing: true,
}
});
console.log('✅ Shape updated with isTranscribing: true');
} catch (error) {
console.error('❌ Error starting transcription:', error);
throw error;
}
}
async stopTranscription(shape: IVideoChatShape) {
console.log('🛑 stopTranscription method called');
console.log('Shape props:', shape.props);
if (!shape.props.roomUrl || !shape.props.isOwner) {
console.log('❌ Early return - missing roomUrl or not owner');
return;
}
try {
const workerUrl = WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
// Extract room name from URL
const roomName = shape.props.roomUrl.split('/').pop();
console.log('📝 Extracted room name:', roomName);
if (!roomName) {
throw new Error('Could not extract room name from URL');
}
console.log('🌐 Making API request to stop transcription...');
const response = await fetch(`${workerUrl}/daily/rooms/${roomName}/stop-transcription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
});
console.log('📡 Response status:', response.status);
if (!response.ok) {
const error = await response.json();
console.error('❌ API error response:', error);
throw new Error(`Failed to stop transcription: ${JSON.stringify(error)}`);
}
console.log('✅ API call successful, updating shape...');
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
isTranscribing: false,
}
});
console.log('✅ Shape updated with isTranscribing: false');
} catch (error) {
console.error('❌ Error stopping transcription:', error);
throw error;
}
}
addTranscriptionMessage(shape: IVideoChatShape, sender: string, message: string) {
console.log('📝 addTranscriptionMessage called');
console.log('Sender:', sender);
console.log('Message:', message);
console.log('Current transcription history length:', shape.props.transcriptionHistory?.length || 0);
const newMessage = {
sender,
message,
id: `${Date.now()}_${Math.random()}`
};
console.log('📝 Adding new message:', newMessage);
this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
transcriptionHistory: [...(shape.props.transcriptionHistory || []), newMessage]
}
});
console.log('✅ Transcription message added to shape');
}
component(shape: IVideoChatShape) {
const [hasPermissions, setHasPermissions] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [roomUrl, setRoomUrl] = useState<string | null>(shape.props.roomUrl)
useEffect(() => {
let mounted = true;
const createRoom = async () => {
try {
setIsLoading(true);
await this.ensureRoomExists(shape);
// Get the updated shape after room creation
const updatedShape = this.editor.getShape(shape.id);
if (mounted && updatedShape) {
setRoomUrl((updatedShape as IVideoChatShape).props.roomUrl);
}
} catch (err) {
if (mounted) {
console.error("Error creating room:", err);
setError(err as Error);
}
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
createRoom();
return () => {
mounted = false;
};
}, [shape.id]); // Only re-run if shape.id changes
useEffect(() => {
let mounted = true;
const requestPermissions = async () => {
try {
if (shape.props.allowCamera || shape.props.allowMicrophone) {
const constraints = {
video: shape.props.allowCamera,
audio: shape.props.allowMicrophone,
}
await navigator.mediaDevices.getUserMedia(constraints)
if (mounted) {
setHasPermissions(true)
}
}
} catch (err) {
console.error("Permission request failed:", err)
if (mounted) {
setHasPermissions(false)
}
}
}
requestPermissions()
return () => {
mounted = false;
}
}, [shape.props.allowCamera, shape.props.allowMicrophone])
if (error) {
return <div>Error creating room: {error.message}</div>
}
if (isLoading || !roomUrl || roomUrl === 'undefined') {
return (
<div
style={{
width: shape.props.w,
height: shape.props.h,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f0f0f0",
borderRadius: "4px",
}}
>
{isLoading ? "Creating room... Please wait" : "Error: No room URL available"}
</div>
)
}
// Construct URL with permission parameters
const roomUrlWithParams = new URL(roomUrl)
roomUrlWithParams.searchParams.set(
"allow_camera",
String(shape.props.allowCamera),
)
roomUrlWithParams.searchParams.set(
"allow_mic",
String(shape.props.allowMicrophone),
)
console.log(roomUrl)
return (
<div
style={{
width: `${shape.props.w}px`,
height: `${shape.props.h}px`,
position: "relative",
pointerEvents: "all",
overflow: "hidden",
}}
>
<iframe
src={roomUrlWithParams.toString()}
width="100%"
height="100%"
style={{
border: "none",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
allow={`camera ${shape.props.allowCamera ? "self" : ""}; microphone ${
shape.props.allowMicrophone ? "self" : ""
}`}
></iframe>
{/* Recording Button */}
{shape.props.enableRecording && (
<button
onClick={async () => {
try {
if (shape.props.recordingId) {
await this.stopRecording(shape);
} else {
await this.startRecording(shape);
}
} catch (err) {
console.error('Recording error:', err);
}
}}
style={{
position: "absolute",
top: "8px",
right: "8px",
padding: "4px 8px",
background: shape.props.recordingId ? "#ff4444" : "#ffffff",
border: "1px solid #ccc",
borderRadius: "4px",
cursor: "pointer",
zIndex: 1,
}}
>
{shape.props.recordingId ? "Stop Recording" : "Start Recording"}
</button>
)}
{/* Test Button - Always visible for debugging */}
<button
onClick={() => {
console.log('🧪 Test button clicked!');
console.log('Shape props:', shape.props);
alert('Test button clicked! Check console for details.');
}}
style={{
position: "absolute",
top: "8px",
left: "8px",
padding: "4px 8px",
background: "#ffff00",
border: "1px solid #000",
borderRadius: "4px",
cursor: "pointer",
zIndex: 1000,
fontSize: "10px",
}}
>
TEST
</button>
{/* Transcription Button - Only for owners */}
{(() => {
console.log('🔍 Checking transcription button conditions:');
console.log('enableTranscription:', shape.props.enableTranscription);
console.log('isOwner:', shape.props.isOwner);
console.log('Button should render:', shape.props.enableTranscription && shape.props.isOwner);
return shape.props.enableTranscription && shape.props.isOwner;
})() && (
<button
onClick={async () => {
console.log('🚀 Transcription button clicked!');
console.log('Current transcription state:', shape.props.isTranscribing);
console.log('Shape props:', shape.props);
try {
if (shape.props.isTranscribing) {
console.log('🛑 Stopping transcription...');
await this.stopTranscription(shape);
console.log('✅ Transcription stopped successfully');
} else {
console.log('🎤 Starting transcription...');
await this.startTranscription(shape);
console.log('✅ Transcription started successfully');
}
} catch (err) {
console.error('❌ Transcription error:', err);
}
}}
style={{
position: "absolute",
top: "8px",
right: shape.props.enableRecording ? "120px" : "8px",
padding: "4px 8px",
background: shape.props.isTranscribing ? "#44ff44" : "#ffffff",
border: "1px solid #ccc",
borderRadius: "4px",
cursor: "pointer",
zIndex: 1,
}}
>
{shape.props.isTranscribing ? "Stop Transcription" : "Start Transcription"}
</button>
)}
{/* Transcription History */}
{shape.props.transcriptionHistory && shape.props.transcriptionHistory.length > 0 && (
<div
style={{
position: "absolute",
bottom: "40px",
left: "8px",
right: "8px",
maxHeight: "200px",
overflowY: "auto",
background: "rgba(255, 255, 255, 0.95)",
borderRadius: "4px",
padding: "8px",
fontSize: "12px",
zIndex: 1,
border: "1px solid #ccc",
}}
>
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>
Live Transcription:
</div>
{shape.props.transcriptionHistory.slice(-10).map((msg) => (
<div key={msg.id} style={{ marginBottom: "2px" }}>
<span style={{ fontWeight: "bold", color: "#666" }}>
{msg.sender}:
</span>{" "}
<span>{msg.message}</span>
</div>
))}
</div>
)}
<p
style={{
position: "absolute",
bottom: 0,
left: 0,
margin: "8px",
padding: "4px 8px",
background: "rgba(255, 255, 255, 0.9)",
borderRadius: "4px",
fontSize: "12px",
pointerEvents: "all",
cursor: "text",
userSelect: "text",
zIndex: 1,
}}
>
url: {roomUrl}
{shape.props.isOwner && " (Owner)"}
</p>
</div>
)
}
}

222265
src/shapes/mycofi_room.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More