Compare commits
514 Commits
main
...
feature/ma
| Author | SHA1 | Date |
|---|---|---|
|
|
f5eb29ae74 | |
|
|
fa212f90ea | |
|
|
ecb3dfd646 | |
|
|
e701b04d56 | |
|
|
f1c7df5699 | |
|
|
a60c2c0899 | |
|
|
df117acc94 | |
|
|
71c0059c9a | |
|
|
76ec56a5f4 | |
|
|
fab58aead0 | |
|
|
b95eb6dc01 | |
|
|
eb7498157f | |
|
|
30608dfdc8 | |
|
|
9f5befc729 | |
|
|
fe0a96ddad | |
|
|
e11eccd34c | |
|
|
c00106e2b7 | |
|
|
037e232b85 | |
|
|
14bf688b60 | |
|
|
af6666bf72 | |
|
|
d4df704c86 | |
|
|
d49f7486e2 | |
|
|
36ce0f3fb2 | |
|
|
e778e20bae | |
|
|
e2a9f3ba54 | |
|
|
254eeda94e | |
|
|
857879cb7e | |
|
|
7677595708 | |
|
|
2418e5065f | |
|
|
9b06bfadb3 | |
|
|
4a9312fa52 | |
|
|
856bfa8e9b | |
|
|
9473a49ebb | |
|
|
c93460804d | |
|
|
9db6fde5a5 | |
|
|
a347df11a5 | |
|
|
8892a9cf3a | |
|
|
abca1ea9d2 | |
|
|
bc1d0421c6 | |
|
|
09de6438c0 | |
|
|
6937bf09b8 | |
|
|
b3a7ff2b0c | |
|
|
c0f30ea29a | |
|
|
f28bfd9340 | |
|
|
4da7d4b1e7 | |
|
|
09bce4dd94 | |
|
|
815fc04366 | |
|
|
84c6bf834c | |
|
|
d279daa9eb | |
|
|
dcd1ed326b | |
|
|
565e14ad34 | |
|
|
641a3467a3 | |
|
|
4416286087 | |
|
|
052c98417d | |
|
|
6d963f62f1 | |
|
|
58671a1c0b | |
|
|
069ba1510c | |
|
|
33f5dc7e7f | |
|
|
c1acf34ccd | |
|
|
06c9e48999 | |
|
|
a754ffab57 | |
|
|
2b5a1736d7 | |
|
|
c9c8c008b2 | |
|
|
8bc3924a10 | |
|
|
35b5d22c4e | |
|
|
cd9b36dc21 | |
|
|
47bd1044a1 | |
|
|
d9fac31a7a | |
|
|
da9467cdac | |
|
|
686dc7c705 | |
|
|
eadef4ee36 | |
|
|
c42b986e3d | |
|
|
985221d848 | |
|
|
e69ed0e867 | |
|
|
377b8f0bca | |
|
|
fb6697a052 | |
|
|
ebb3ab661b | |
|
|
477af6ae05 | |
|
|
36ea5e5482 | |
|
|
f9f54f9f32 | |
|
|
878227f31d | |
|
|
c95ece9fe5 | |
|
|
538dbcd807 | |
|
|
f0d261ff98 | |
|
|
1ec6faed56 | |
|
|
37cd086ff0 | |
|
|
808532a1b6 | |
|
|
a48708525c | |
|
|
c4e50f01fd | |
|
|
16acd3d6ef | |
|
|
a50e3dad58 | |
|
|
22ac1d65dd | |
|
|
f67ee111e6 | |
|
|
63264cf636 | |
|
|
0ec4e9382f | |
|
|
8cda0d4e28 | |
|
|
8dac699acf | |
|
|
966e1855c1 | |
|
|
4f1a6d1314 | |
|
|
8c90727b93 | |
|
|
7a471b0e37 | |
|
|
068ff7d3be | |
|
|
075e317a0d | |
|
|
de770a4f91 | |
|
|
aa742919cb | |
|
|
5a6e04f26b | |
|
|
f15f46f742 | |
|
|
9c1e8cbcaa | |
|
|
60a6825dfd | |
|
|
0edac2968b | |
|
|
1ccaf32d1a | |
|
|
a6f2d6e015 | |
|
|
f5c775e417 | |
|
|
7191e2543d | |
|
|
cd58b1c1cd | |
|
|
01b5a84e42 | |
|
|
478c1f6774 | |
|
|
420ad28d9a | |
|
|
b1c3ceeab7 | |
|
|
a5c5c7f441 | |
|
|
abb80c05d8 | |
|
|
5ca4b19aec | |
|
|
1d212c385d | |
|
|
26a15b7aaf | |
|
|
2cee2cb31b | |
|
|
26a931acf5 | |
|
|
f3a9a28724 | |
|
|
b2a7879d2c | |
|
|
5f2166075b | |
|
|
ebf602fd21 | |
|
|
0906704693 | |
|
|
343f408661 | |
|
|
49cf763858 | |
|
|
d73aee3530 | |
|
|
8bb87075ad | |
|
|
42c3ec7587 | |
|
|
55ac9381fa | |
|
|
d89fb4baaf | |
|
|
b8d014a0af | |
|
|
c396bbca85 | |
|
|
090646f893 | |
|
|
829dd4f642 | |
|
|
f2a77580b1 | |
|
|
e230d571e4 | |
|
|
d42bae52d7 | |
|
|
dba981c2af | |
|
|
179a03057e | |
|
|
439a7f07e8 | |
|
|
c070e673ad | |
|
|
c175c0f29a | |
|
|
928a535aec | |
|
|
33ff5216cc | |
|
|
5cd36c3d3c | |
|
|
a8c3988e3f | |
|
|
5151b474be | |
|
|
8ea3490fb4 | |
|
|
67d4db7281 | |
|
|
083095c821 | |
|
|
6507adc36d | |
|
|
05197f8430 | |
|
|
e479413363 | |
|
|
6a0a53796c | |
|
|
3549326122 | |
|
|
28b8ebdc72 | |
|
|
f4e72452f1 | |
|
|
cae367db28 | |
|
|
7e09d22843 | |
|
|
943162899e | |
|
|
e70cba82b4 | |
|
|
63b19f7db8 | |
|
|
c18d204814 | |
|
|
3231720004 | |
|
|
ca9f250de8 | |
|
|
6ab966cc95 | |
|
|
02808d170e | |
|
|
2cc7c52755 | |
|
|
1e68150b60 | |
|
|
dd190824d2 | |
|
|
69ecaf2335 | |
|
|
693d38eb7d | |
|
|
7a921d8477 | |
|
|
831d463b5c | |
|
|
8c9eaa1d2b | |
|
|
b52b715340 | |
|
|
0882648565 | |
|
|
f963152238 | |
|
|
99ee87b6d6 | |
|
|
c2a56f08e1 | |
|
|
e2619f4aae | |
|
|
88a162d6ae | |
|
|
fde305abf6 | |
|
|
48dac00f59 | |
|
|
caf831cce7 | |
|
|
776526c65e | |
|
|
64d4a65613 | |
|
|
432e90d9ef | |
|
|
52c1af6864 | |
|
|
13ceb2ebbd | |
|
|
71e7e5de05 | |
|
|
9b7cde262a | |
|
|
f0f7c47775 | |
|
|
90605bee09 | |
|
|
0de17476d5 | |
|
|
7a17b0944a | |
|
|
cb5045984b | |
|
|
f5582fc7d1 | |
|
|
7c0c888276 | |
|
|
f66fac74d0 | |
|
|
4d7b05efa2 | |
|
|
7065626e71 | |
|
|
ab1d0344a5 | |
|
|
04f6fe5192 | |
|
|
2528ad4726 | |
|
|
ffef04df50 | |
|
|
7919d34dfa | |
|
|
5fd83944fc | |
|
|
a3950baf17 | |
|
|
ef4a84e8f1 | |
|
|
d1179169cc | |
|
|
0e90e2d097 | |
|
|
eafbf6c9fe | |
|
|
edbe76ebda | |
|
|
ef39328d95 | |
|
|
229f4d6b41 | |
|
|
0fa1652f72 | |
|
|
1b172d7529 | |
|
|
c1df50c49b | |
|
|
053bd95d4a | |
|
|
73ac456e17 | |
|
|
92cac8dee5 | |
|
|
b8fb64c01b | |
|
|
680b6a5359 | |
|
|
fec80ddd18 | |
|
|
5b32184012 | |
|
|
be5f1a5a3a | |
|
|
bf5d214e45 | |
|
|
f8e4fa3802 | |
|
|
a063abdf77 | |
|
|
04135a5487 | |
|
|
5e11183557 | |
|
|
b5463d4d64 | |
|
|
bda2523e3b | |
|
|
3072dc70c0 | |
|
|
62afed445e | |
|
|
f2b05a8fe6 | |
|
|
0a34c0ab3e | |
|
|
0c2ca28d0e | |
|
|
5cfa2d683c | |
|
|
b5785f059f | |
|
|
fa6b874313 | |
|
|
657df72534 | |
|
|
9664439f31 | |
|
|
8cce96ea20 | |
|
|
5375f63e70 | |
|
|
663c845cab | |
|
|
a82f8faa00 | |
|
|
065a3b3483 | |
|
|
f688851764 | |
|
|
5c99a82c14 | |
|
|
39c1e2251b | |
|
|
8a8568d042 | |
|
|
822b979864 | |
|
|
067dae1ba6 | |
|
|
d1ad51c8ab | |
|
|
d3f2029521 | |
|
|
119146e094 | |
|
|
38d1f28e35 | |
|
|
4815fa4a23 | |
|
|
f8e4647e1a | |
|
|
368732e3b1 | |
|
|
719a4eb918 | |
|
|
8fa8c388d9 | |
|
|
356a262114 | |
|
|
1abeeaea10 | |
|
|
808b37425a | |
|
|
8385e30d25 | |
|
|
391e13c350 | |
|
|
d0233c0eb6 | |
|
|
3b137b0b55 | |
|
|
ec9db36a50 | |
|
|
e78f9a8281 | |
|
|
c99b9710b5 | |
|
|
a8c9bd845b | |
|
|
9a9cab1b8e | |
|
|
1d1b64fe7c | |
|
|
17ba57ce6e | |
|
|
fa2f16c019 | |
|
|
0c980f5f48 | |
|
|
fdc14a1a92 | |
|
|
956463d43f | |
|
|
125e565c55 | |
|
|
129d72cd58 | |
|
|
b01cb9abf8 | |
|
|
f949f323de | |
|
|
5eb5789c23 | |
|
|
15fa9b8d19 | |
|
|
7e3cca656e | |
|
|
6e373e57f1 | |
|
|
545372dcba | |
|
|
d7fcf121f8 | |
|
|
c5e606e326 | |
|
|
bb144428d0 | |
|
|
33f1aa4e90 | |
|
|
411fc99201 | |
|
|
4364743555 | |
|
|
6dd387613b | |
|
|
04705665f5 | |
|
|
c13d8720d2 | |
|
|
df72890577 | |
|
|
4e88428706 | |
|
|
52736e9812 | |
|
|
7b84d34c98 | |
|
|
e936d1c597 | |
|
|
b0beefe516 | |
|
|
49f11dc6e5 | |
|
|
30c0dfc3ba | |
|
|
d7b1e348e9 | |
|
|
2a3b79df15 | |
|
|
b11aecffa4 | |
|
|
4b5ba9eab3 | |
|
|
0add9bd514 | |
|
|
a770d516df | |
|
|
47db716af3 | |
|
|
e7e911c5bb | |
|
|
1126fc4a1c | |
|
|
59e9025336 | |
|
|
7d6afb6c6b | |
|
|
3a99af257d | |
|
|
12256c5b9c | |
|
|
87854883c6 | |
|
|
ebe2d4c0a2 | |
|
|
d733b61a66 | |
|
|
61143d2c20 | |
|
|
f47c3e0007 | |
|
|
536e1e7a87 | |
|
|
ab2a9f6a79 | |
|
|
9b33efdcb3 | |
|
|
86b37b9cc8 | |
|
|
7805a1e961 | |
|
|
fdb96b6ae1 | |
|
|
1783d1b6eb | |
|
|
bfbe7b8325 | |
|
|
e3e2c474ac | |
|
|
7b1fe2b803 | |
|
|
02f816e613 | |
|
|
198109a919 | |
|
|
c6370c0fde | |
|
|
c75acca85b | |
|
|
d7f4d61b55 | |
|
|
221a453411 | |
|
|
ce3063e9ba | |
|
|
7987c3a8e4 | |
|
|
8f94ee3a6f | |
|
|
201e489cef | |
|
|
d23dca3ba8 | |
|
|
42e5afbb21 | |
|
|
997f690d22 | |
|
|
7978772d7b | |
|
|
9f54400f18 | |
|
|
34681a3f4f | |
|
|
3bb7eda655 | |
|
|
72a7a54866 | |
|
|
e714233f67 | |
|
|
cca1a06b9f | |
|
|
84e737216d | |
|
|
bf5b3239dd | |
|
|
5858775483 | |
|
|
b74ae75fa8 | |
|
|
6e1e03d05b | |
|
|
ce50366985 | |
|
|
d9fb9637bd | |
|
|
5d39baaea8 | |
|
|
9def6c52b5 | |
|
|
1f6b693ec1 | |
|
|
b2e06ad76b | |
|
|
ac69e09aca | |
|
|
08f31a0bbd | |
|
|
2bdd6a8dba | |
|
|
9ff366c80b | |
|
|
cc216eb07f | |
|
|
d2ff445ddf | |
|
|
a8ca366bb6 | |
|
|
4901a56d61 | |
|
|
2d562b3e4c | |
|
|
a9a23e27e3 | |
|
|
cee2bfa336 | |
|
|
5924b0cc97 | |
|
|
4ec6b73fb3 | |
|
|
ce50026cc3 | |
|
|
0ff9c64908 | |
|
|
cf722c2490 | |
|
|
64d7581e6b | |
|
|
1190848222 | |
|
|
11c88ec0de | |
|
|
95307ed453 | |
|
|
bfe6b238e9 | |
|
|
fe4b40a3fe | |
|
|
4fda800e8b | |
|
|
7c28758204 | |
|
|
75c769a774 | |
|
|
5d8781462d | |
|
|
b2d6b1599b | |
|
|
c81238c45a | |
|
|
f012632cde | |
|
|
78e396d11e | |
|
|
cba62a453b | |
|
|
923f61ac9e | |
|
|
94bec533c4 | |
|
|
e286a120f1 | |
|
|
2e0a05ab32 | |
|
|
110fc19b94 | |
|
|
111be03907 | |
|
|
39e6cccc3f | |
|
|
08175d3a7c | |
|
|
3006e85375 | |
|
|
632e7979a2 | |
|
|
71fc07133a | |
|
|
97b00c1569 | |
|
|
c4198e1faf | |
|
|
6f6c924f66 | |
|
|
0eb4407219 | |
|
|
3a2a38c0b6 | |
|
|
02124ce920 | |
|
|
b700846a9c | |
|
|
f7310919f8 | |
|
|
949062941f | |
|
|
7f497ae8d8 | |
|
|
1d817c8e0f | |
|
|
7dd045bb33 | |
|
|
11d13a03d3 | |
|
|
3bcfa83168 | |
|
|
b0a3cd7328 | |
|
|
c71b67e24c | |
|
|
d582be49b2 | |
|
|
46ee4e7906 | |
|
|
c34418e964 | |
|
|
1c8909ce69 | |
|
|
5f2c90219d | |
|
|
fef2ca0eb3 | |
|
|
eab574e130 | |
|
|
b2656c911b | |
|
|
6ba124b038 | |
|
|
1cd7208ddf | |
|
|
d555910c77 | |
|
|
d1a8407a9b | |
|
|
db3205f97a | |
|
|
100b88268b | |
|
|
202971f343 | |
|
|
b26b9e6384 | |
|
|
4d69340a6b | |
|
|
14e0126995 | |
|
|
04782854d2 | |
|
|
4eff918bd3 | |
|
|
4e2103aab2 | |
|
|
895d02a19c | |
|
|
375f69b365 | |
|
|
09a729c787 | |
|
|
bb8a76026e | |
|
|
4319a6b1ee | |
|
|
2ca6705599 | |
|
|
07556dd53a | |
|
|
c93b3066bd | |
|
|
d282f6b650 | |
|
|
c34cae40b6 | |
|
|
46b54394ad | |
|
|
b05aa413e3 | |
|
|
2435f3f495 | |
|
|
49bca38b5f | |
|
|
0d7ee5889c | |
|
|
a0bba93055 | |
|
|
a2d7ab4af0 | |
|
|
99f7f131ed | |
|
|
c369762001 | |
|
|
d81ae56de0 | |
|
|
f384673cf9 | |
|
|
670c9ff0b0 | |
|
|
2ac4ec8de3 | |
|
|
7e16f6e6b0 | |
|
|
63cd76e919 | |
|
|
91df5214c6 | |
|
|
900833c06c | |
|
|
700875434f | |
|
|
9d5d0d6655 | |
|
|
8ce8dec8f7 | |
|
|
836d37df76 | |
|
|
2c35a0c53c | |
|
|
a8c8d62e63 | |
|
|
807637eae0 | |
|
|
572608f878 | |
|
|
6747c5df02 | |
|
|
2c4b2f6c91 | |
|
|
80cda32cba | |
|
|
032e4e1199 | |
|
|
04676b3788 | |
|
|
d6f3830884 | |
|
|
50c7c52c3d | |
|
|
a6eb2abed0 | |
|
|
1c38cb1bdb | |
|
|
932c9935d5 | |
|
|
249031619d | |
|
|
408df0d11e | |
|
|
fc602ff943 | |
|
|
d34e586215 | |
|
|
ee2484f1d0 | |
|
|
0ac03dec60 | |
|
|
5f3cf2800c | |
|
|
206d2a57ec | |
|
|
87118b86d5 | |
|
|
58cb4da348 | |
|
|
d087b61ce5 | |
|
|
9d73295702 | |
|
|
3e6db31c69 | |
|
|
b8038a6a97 | |
|
|
ee49689416 |
|
|
@ -1,6 +1,7 @@
|
|||
# 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'
|
||||
|
||||
# AI Configuration
|
||||
|
|
@ -15,13 +16,10 @@ VITE_RUNPOD_IMAGE_ENDPOINT_ID='your_image_endpoint_id' # Automatic1111/SD
|
|||
VITE_RUNPOD_VIDEO_ENDPOINT_ID='your_video_endpoint_id' # Wan2.2
|
||||
VITE_RUNPOD_WHISPER_ENDPOINT_ID='your_whisper_endpoint_id' # WhisperX
|
||||
|
||||
# WalletConnect (Web3 wallet integration)
|
||||
# Get your project ID at https://cloud.walletconnect.com/
|
||||
VITE_WALLETCONNECT_PROJECT_ID='your_walletconnect_project_id'
|
||||
|
||||
# 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'
|
||||
R2_PREVIEW_BUCKET_NAME='your_preview_bucket_name'
|
||||
DAILY_API_KEY=your_daily_api_key_here
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
pull_request:
|
||||
branches: [dev, main]
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: Unit & Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
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
|
||||
|
||||
- name: Run TypeScript check
|
||||
run: npm run types
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Run worker tests
|
||||
run: npm run test:worker
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
|
||||
e2e-tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
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
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload Playwright traces
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
build-check:
|
||||
name: Build Check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
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
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
|
||||
# Gate job that requires all tests to pass before merge
|
||||
merge-ready:
|
||||
name: Merge Ready
|
||||
needs: [unit-tests, e2e-tests, build-check]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Check all jobs passed
|
||||
run: |
|
||||
if [[ "${{ needs.unit-tests.result }}" != "success" ]]; then
|
||||
echo "Unit tests failed"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${{ needs.e2e-tests.result }}" != "success" ]]; then
|
||||
echo "E2E tests failed"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${{ needs.build-check.result }}" != "success" ]]; then
|
||||
echo "Build check failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All checks passed - ready to merge!"
|
||||
|
|
@ -176,7 +176,3 @@ dist
|
|||
.dev.vars
|
||||
.env.production
|
||||
.aider*
|
||||
|
||||
# Playwright
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
|
|
|||
63
CHANGELOG.md
63
CHANGELOG.md
|
|
@ -1,63 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
Activity log of changes to canvas boards, organized by contributor.
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-06
|
||||
|
||||
### Claude
|
||||
- Added per-board Activity Logger feature
|
||||
- Automatically tracks shape creates, deletes, and updates
|
||||
- Collapsible sidebar panel showing activity timeline
|
||||
- Groups activities by date (Today, Yesterday, etc.)
|
||||
- Debounces updates to avoid logging tiny movements
|
||||
- Toggle button in top-right corner
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-05
|
||||
|
||||
### Jeff
|
||||
- Added embed shape linking to MycoFi whitepaper
|
||||
- Deleted old map shape from planning board
|
||||
- Added shared piano shape to music-collab board
|
||||
- Moved token diagram to center of canvas
|
||||
- Created new markdown note with meeting summary
|
||||
|
||||
### Claude
|
||||
- Added "Last Visited" canvases feature to Dashboard
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-04
|
||||
|
||||
### Jeff
|
||||
- Created new board `/hyperindex-planning`
|
||||
- Added 3 holon shapes for system architecture
|
||||
- Uploaded screenshot of database schema
|
||||
- Added arrow connectors between components
|
||||
- Renamed board title to "Hyperindex Architecture"
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-03
|
||||
|
||||
### Jeff
|
||||
- Deleted duplicate image shapes from mycofi board
|
||||
- Added video chat shape for team standup
|
||||
- Created slide deck with 5 slides for presentation
|
||||
- Added sticky notes with action items
|
||||
|
||||
---
|
||||
|
||||
## Legend
|
||||
|
||||
| User | Description |
|
||||
|------|-------------|
|
||||
| Jeff | Project Owner |
|
||||
| Claude | AI Assistant |
|
||||
|
||||
---
|
||||
|
||||
*This log tracks user actions on canvas boards (shape additions, deletions, moves, etc.)*
|
||||
|
|
@ -46,7 +46,7 @@ These permissions are configured in `~/.claude/settings.json`.
|
|||
|
||||
- **GitHub**: Public mirror and collaboration
|
||||
- Receives pushes from Gitea via mirror sync
|
||||
- Token: `(REDACTED-GITHUB-TOKEN)`
|
||||
- Token: `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`
|
||||
- SSH Key: `~/.ssh/github_deploy_key` (private), `~/.ssh/github_deploy_key.pub` (public)
|
||||
- **GitHub CLI (gh)**: ✅ Installed and available for PR/issue management
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ main (production)
|
|||
- SSH Key: `~/.ssh/runpod_ed25519` (private), `~/.ssh/runpod_ed25519.pub` (public)
|
||||
- Public Key: `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAC7NYjI0U/2ChGaZBBWP7gKt/V12Ts6FgatinJOQ8JG runpod@jeffemmett.com`
|
||||
- SSH Access: `ssh runpod`
|
||||
- **API Key**: `(REDACTED-RUNPOD-KEY)`
|
||||
- **API Key**: `rpa_YYOARL5MEBTTKKWGABRKTW2CVHQYRBTOBZNSGIL3lwwfdz`
|
||||
- **CLI Config**: `~/.runpod/config.toml`
|
||||
- **Serverless Endpoints**:
|
||||
- Image (SD): `tzf1j3sc3zufsy` (Automatic1111)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ RUN npm ci --legacy-peer-deps
|
|||
COPY . .
|
||||
|
||||
# Build args for environment
|
||||
ARG VITE_WORKER_ENV=production
|
||||
ARG VITE_TLDRAW_WORKER_URL=https://jeffemmett-canvas.jeffemmett.workers.dev
|
||||
ARG VITE_DAILY_API_KEY
|
||||
ARG VITE_RUNPOD_API_KEY
|
||||
ARG VITE_RUNPOD_IMAGE_ENDPOINT_ID
|
||||
|
|
@ -23,8 +23,7 @@ ARG VITE_RUNPOD_TEXT_ENDPOINT_ID
|
|||
ARG VITE_RUNPOD_WHISPER_ENDPOINT_ID
|
||||
|
||||
# Set environment for build
|
||||
# VITE_WORKER_ENV: 'production' | 'staging' | 'dev' | 'local'
|
||||
ENV VITE_WORKER_ENV=$VITE_WORKER_ENV
|
||||
ENV VITE_TLDRAW_WORKER_URL=$VITE_TLDRAW_WORKER_URL
|
||||
ENV VITE_DAILY_API_KEY=$VITE_DAILY_API_KEY
|
||||
ENV VITE_RUNPOD_API_KEY=$VITE_RUNPOD_API_KEY
|
||||
ENV VITE_RUNPOD_IMAGE_ENDPOINT_ID=$VITE_RUNPOD_IMAGE_ENDPOINT_ID
|
||||
|
|
|
|||
|
|
@ -1,665 +0,0 @@
|
|||
---
|
||||
id: doc-001
|
||||
title: Web3 Wallet Integration Architecture
|
||||
type: other
|
||||
created_date: '2026-01-02 16:07'
|
||||
---
|
||||
# Web3 Wallet Integration Architecture
|
||||
|
||||
**Status:** Planning
|
||||
**Created:** 2026-01-02
|
||||
**Related Task:** task-007
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document outlines the architecture for integrating Web3 wallet capabilities into the canvas-website, enabling CryptID users to link Ethereum wallets for on-chain transactions, voting, and token-gated features.
|
||||
|
||||
### Key Constraint: Cryptographic Curve Mismatch
|
||||
|
||||
| System | Curve | Usage |
|
||||
|--------|-------|-------|
|
||||
| **CryptID (WebCrypto)** | ECDSA P-256 (NIST) | Authentication, passwordless login |
|
||||
| **Ethereum** | ECDSA secp256k1 | Transactions, message signing |
|
||||
|
||||
These curves are **incompatible**. A CryptID key cannot sign Ethereum transactions. Therefore, we use a **wallet linking** approach where:
|
||||
1. CryptID handles authentication (who you are)
|
||||
2. Linked wallet handles on-chain actions (what you can do)
|
||||
|
||||
---
|
||||
|
||||
## 2. Database Schema
|
||||
|
||||
### Migration: `002_linked_wallets.sql`
|
||||
|
||||
```sql
|
||||
-- Migration: Add Linked Wallets for Web3 Integration
|
||||
-- Date: 2026-01-02
|
||||
-- Description: Enables CryptID users to link Ethereum wallets for
|
||||
-- on-chain transactions, voting, and token-gated features.
|
||||
|
||||
-- =============================================================================
|
||||
-- LINKED WALLETS TABLE
|
||||
-- =============================================================================
|
||||
-- Each CryptID user can link multiple Ethereum wallets (EOA, Safe, hardware)
|
||||
-- Linking requires signature verification to prove wallet ownership
|
||||
|
||||
CREATE TABLE IF NOT EXISTS linked_wallets (
|
||||
id TEXT PRIMARY KEY, -- UUID for the link record
|
||||
user_id TEXT NOT NULL, -- References users.id (CryptID account)
|
||||
wallet_address TEXT NOT NULL, -- Ethereum address (checksummed, 0x-prefixed)
|
||||
|
||||
-- Wallet metadata
|
||||
wallet_type TEXT DEFAULT 'eoa' CHECK (wallet_type IN ('eoa', 'safe', 'hardware', 'contract')),
|
||||
chain_id INTEGER DEFAULT 1, -- Primary chain (1 = Ethereum mainnet)
|
||||
label TEXT, -- User-provided label (e.g., "Main Wallet")
|
||||
|
||||
-- Verification proof
|
||||
signature_message TEXT NOT NULL, -- The message that was signed
|
||||
signature TEXT NOT NULL, -- EIP-191 personal_sign signature
|
||||
verified_at TEXT NOT NULL, -- When signature was verified
|
||||
|
||||
-- ENS integration
|
||||
ens_name TEXT, -- Resolved ENS name (if any)
|
||||
ens_avatar TEXT, -- ENS avatar URL (if any)
|
||||
ens_resolved_at TEXT, -- When ENS was last resolved
|
||||
|
||||
-- Flags
|
||||
is_primary INTEGER DEFAULT 0, -- 1 = primary wallet for this user
|
||||
is_active INTEGER DEFAULT 1, -- 0 = soft-deleted
|
||||
|
||||
-- Timestamps
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
last_used_at TEXT, -- Last time wallet was used for action
|
||||
|
||||
-- Constraints
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, wallet_address) -- Can't link same wallet twice
|
||||
);
|
||||
|
||||
-- Indexes for efficient lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_linked_wallets_user ON linked_wallets(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_linked_wallets_address ON linked_wallets(wallet_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_linked_wallets_active ON linked_wallets(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_linked_wallets_primary ON linked_wallets(user_id, is_primary);
|
||||
|
||||
-- =============================================================================
|
||||
-- WALLET LINKING TOKENS TABLE (for Safe/multisig delayed verification)
|
||||
-- =============================================================================
|
||||
-- For contract wallets that require on-chain signature verification
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallet_link_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
wallet_address TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL, -- Random nonce for signature message
|
||||
token TEXT NOT NULL UNIQUE, -- Secret token for verification callback
|
||||
expires_at TEXT NOT NULL,
|
||||
used INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wallet_link_tokens_token ON wallet_link_tokens(token);
|
||||
|
||||
-- =============================================================================
|
||||
-- TOKEN BALANCES CACHE (optional, for token-gating)
|
||||
-- =============================================================================
|
||||
-- Cache of token balances for faster permission checks
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallet_token_balances (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet_address TEXT NOT NULL,
|
||||
token_address TEXT NOT NULL, -- ERC-20/721/1155 contract address
|
||||
token_type TEXT CHECK (token_type IN ('erc20', 'erc721', 'erc1155')),
|
||||
chain_id INTEGER NOT NULL,
|
||||
balance TEXT NOT NULL, -- String to handle big numbers
|
||||
last_updated TEXT DEFAULT (datetime('now')),
|
||||
|
||||
UNIQUE(wallet_address, token_address, chain_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_token_balances_wallet ON wallet_token_balances(wallet_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_balances_token ON wallet_token_balances(token_address);
|
||||
```
|
||||
|
||||
### TypeScript Types
|
||||
|
||||
Add to `worker/types.ts`:
|
||||
|
||||
```typescript
|
||||
// =============================================================================
|
||||
// Linked Wallet Types
|
||||
// =============================================================================
|
||||
|
||||
export type WalletType = 'eoa' | 'safe' | 'hardware' | 'contract';
|
||||
|
||||
export interface LinkedWallet {
|
||||
id: string;
|
||||
user_id: string;
|
||||
wallet_address: string;
|
||||
wallet_type: WalletType;
|
||||
chain_id: number;
|
||||
label: string | null;
|
||||
signature_message: string;
|
||||
signature: string;
|
||||
verified_at: string;
|
||||
ens_name: string | null;
|
||||
ens_avatar: string | null;
|
||||
ens_resolved_at: string | null;
|
||||
is_primary: number; // SQLite boolean
|
||||
is_active: number; // SQLite boolean
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export interface WalletLinkToken {
|
||||
id: string;
|
||||
user_id: string;
|
||||
wallet_address: string;
|
||||
nonce: string;
|
||||
token: string;
|
||||
expires_at: string;
|
||||
used: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WalletTokenBalance {
|
||||
id: string;
|
||||
wallet_address: string;
|
||||
token_address: string;
|
||||
token_type: 'erc20' | 'erc721' | 'erc1155';
|
||||
chain_id: number;
|
||||
balance: string;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface LinkedWalletResponse {
|
||||
id: string;
|
||||
address: string;
|
||||
type: WalletType;
|
||||
chainId: number;
|
||||
label: string | null;
|
||||
ensName: string | null;
|
||||
ensAvatar: string | null;
|
||||
isPrimary: boolean;
|
||||
linkedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface WalletLinkRequest {
|
||||
walletAddress: string;
|
||||
signature: string;
|
||||
message: string;
|
||||
walletType?: WalletType;
|
||||
chainId?: number;
|
||||
label?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API Endpoints
|
||||
|
||||
### Base Path: `/api/wallet`
|
||||
|
||||
All endpoints require CryptID authentication via `X-CryptID-PublicKey` header.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/wallet/link`
|
||||
|
||||
Link a new wallet to the authenticated CryptID account.
|
||||
|
||||
**Request:**
|
||||
```typescript
|
||||
{
|
||||
walletAddress: string; // 0x-prefixed Ethereum address
|
||||
signature: string; // EIP-191 signature of the message
|
||||
message: string; // Must match server-generated format
|
||||
walletType?: 'eoa' | 'safe' | 'hardware' | 'contract';
|
||||
chainId?: number; // Default: 1 (mainnet)
|
||||
label?: string; // Optional user label
|
||||
}
|
||||
```
|
||||
|
||||
**Message Format (must be signed):**
|
||||
```
|
||||
Link wallet to CryptID
|
||||
|
||||
Account: ${cryptidUsername}
|
||||
Wallet: ${walletAddress}
|
||||
Timestamp: ${isoTimestamp}
|
||||
Nonce: ${randomNonce}
|
||||
|
||||
This signature proves you own this wallet.
|
||||
```
|
||||
|
||||
**Response (201 Created):**
|
||||
```typescript
|
||||
{
|
||||
success: true;
|
||||
wallet: LinkedWalletResponse;
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `400` - Invalid request body or signature
|
||||
- `401` - Not authenticated
|
||||
- `409` - Wallet already linked to this account
|
||||
- `422` - Signature verification failed
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/wallet/list`
|
||||
|
||||
Get all wallets linked to the authenticated user.
|
||||
|
||||
**Response:**
|
||||
```typescript
|
||||
{
|
||||
wallets: LinkedWalletResponse[];
|
||||
count: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/wallet/:address`
|
||||
|
||||
Get details for a specific linked wallet.
|
||||
|
||||
**Response:**
|
||||
```typescript
|
||||
{
|
||||
wallet: LinkedWalletResponse;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /api/wallet/:address`
|
||||
|
||||
Update a linked wallet (label, primary status).
|
||||
|
||||
**Request:**
|
||||
```typescript
|
||||
{
|
||||
label?: string;
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```typescript
|
||||
{
|
||||
success: true;
|
||||
wallet: LinkedWalletResponse;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/wallet/:address`
|
||||
|
||||
Unlink a wallet from the account.
|
||||
|
||||
**Response:**
|
||||
```typescript
|
||||
{
|
||||
success: true;
|
||||
message: 'Wallet unlinked';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/wallet/verify/:address`
|
||||
|
||||
Check if a wallet address is linked to any CryptID account.
|
||||
(Public endpoint - no auth required)
|
||||
|
||||
**Response:**
|
||||
```typescript
|
||||
{
|
||||
linked: boolean;
|
||||
cryptidUsername?: string; // Only if user allows public display
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/wallet/refresh-ens`
|
||||
|
||||
Refresh ENS name resolution for a linked wallet.
|
||||
|
||||
**Request:**
|
||||
```typescript
|
||||
{
|
||||
walletAddress: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```typescript
|
||||
{
|
||||
ensName: string | null;
|
||||
ensAvatar: string | null;
|
||||
resolvedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Signature Verification Implementation
|
||||
|
||||
```typescript
|
||||
// worker/walletAuth.ts
|
||||
|
||||
import { verifyMessage, getAddress } from 'viem';
|
||||
|
||||
export function generateLinkMessage(
|
||||
username: string,
|
||||
address: string,
|
||||
timestamp: string,
|
||||
nonce: string
|
||||
): string {
|
||||
return `Link wallet to CryptID
|
||||
|
||||
Account: ${username}
|
||||
Wallet: ${address}
|
||||
Timestamp: ${timestamp}
|
||||
Nonce: ${nonce}
|
||||
|
||||
This signature proves you own this wallet.`;
|
||||
}
|
||||
|
||||
export async function verifyWalletSignature(
|
||||
address: string,
|
||||
message: string,
|
||||
signature: `0x${string}`
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Normalize address
|
||||
const checksumAddress = getAddress(address);
|
||||
|
||||
// Verify EIP-191 personal_sign signature
|
||||
const valid = await verifyMessage({
|
||||
address: checksumAddress,
|
||||
message,
|
||||
signature,
|
||||
});
|
||||
|
||||
return valid;
|
||||
} catch (error) {
|
||||
console.error('Signature verification error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For ERC-1271 contract wallet verification (Safe, etc.)
|
||||
export async function verifyContractSignature(
|
||||
address: string,
|
||||
message: string,
|
||||
signature: string,
|
||||
rpcUrl: string
|
||||
): Promise<boolean> {
|
||||
// ERC-1271 magic value: 0x1626ba7e
|
||||
// Implementation needed for Safe/contract wallet support
|
||||
// Uses eth_call to isValidSignature(bytes32,bytes)
|
||||
throw new Error('Contract signature verification not yet implemented');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Library Comparison
|
||||
|
||||
### Recommendation: **wagmi v2 + viem**
|
||||
|
||||
| Library | Bundle Size | Type Safety | React Hooks | Maintenance | Recommendation |
|
||||
|---------|-------------|-------------|-------------|-------------|----------------|
|
||||
| **wagmi v2** | ~40KB | Excellent | Native | Active (wevm team) | ✅ **Best for React** |
|
||||
| **viem** | ~25KB | Excellent | N/A | Active (wevm team) | ✅ **Best for worker** |
|
||||
| **ethers v6** | ~120KB | Good | None | Active | ⚠️ Larger bundle |
|
||||
| **web3.js** | ~400KB | Poor | None | Declining | ❌ Avoid |
|
||||
|
||||
### Why wagmi + viem?
|
||||
|
||||
1. **Same team** - wagmi and viem are both from wevm, designed to work together
|
||||
2. **Tree-shakeable** - Only import what you use
|
||||
3. **TypeScript-first** - Excellent type inference and autocomplete
|
||||
4. **Modern React** - Hooks-based, works with React 18+ and Suspense
|
||||
5. **WalletConnect v2** - Built-in support via Web3Modal
|
||||
6. **No ethers dependency** - Pure viem underneath
|
||||
|
||||
### Package Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"wagmi": "^2.12.0",
|
||||
"viem": "^2.19.0",
|
||||
"@tanstack/react-query": "^5.45.0",
|
||||
"@web3modal/wagmi": "^5.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Supported Wallets (via Web3Modal)
|
||||
|
||||
- MetaMask (injected)
|
||||
- WalletConnect v2 (mobile wallets)
|
||||
- Coinbase Wallet
|
||||
- Rainbow
|
||||
- Safe (via WalletConnect)
|
||||
- Hardware wallets (via MetaMask bridge)
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend Architecture
|
||||
|
||||
### Provider Setup (`src/providers/Web3Provider.tsx`)
|
||||
|
||||
```typescript
|
||||
import { WagmiProvider, createConfig, http } from 'wagmi';
|
||||
import { mainnet, optimism, arbitrum, base } from 'wagmi/chains';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createWeb3Modal } from '@web3modal/wagmi/react';
|
||||
|
||||
// Configure chains
|
||||
const chains = [mainnet, optimism, arbitrum, base] as const;
|
||||
|
||||
// Create wagmi config
|
||||
const config = createConfig({
|
||||
chains,
|
||||
transports: {
|
||||
[mainnet.id]: http(),
|
||||
[optimism.id]: http(),
|
||||
[arbitrum.id]: http(),
|
||||
[base.id]: http(),
|
||||
},
|
||||
});
|
||||
|
||||
// Create Web3Modal
|
||||
const projectId = process.env.WALLETCONNECT_PROJECT_ID!;
|
||||
|
||||
createWeb3Modal({
|
||||
wagmiConfig: config,
|
||||
projectId,
|
||||
chains,
|
||||
themeMode: 'dark',
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function Web3Provider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<WagmiProvider config={config}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Wallet Link Hook (`src/hooks/useWalletLink.ts`)
|
||||
|
||||
```typescript
|
||||
import { useAccount, useSignMessage, useDisconnect } from 'wagmi';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useWalletLink() {
|
||||
const { address, isConnected } = useAccount();
|
||||
const { signMessageAsync } = useSignMessage();
|
||||
const { disconnect } = useDisconnect();
|
||||
const { session } = useAuth();
|
||||
const [isLinking, setIsLinking] = useState(false);
|
||||
|
||||
const linkWallet = async (label?: string) => {
|
||||
if (!address || !session.username) return;
|
||||
|
||||
setIsLinking(true);
|
||||
try {
|
||||
// Generate link message
|
||||
const timestamp = new Date().toISOString();
|
||||
const nonce = crypto.randomUUID();
|
||||
const message = generateLinkMessage(
|
||||
session.username,
|
||||
address,
|
||||
timestamp,
|
||||
nonce
|
||||
);
|
||||
|
||||
// Request signature from wallet
|
||||
const signature = await signMessageAsync({ message });
|
||||
|
||||
// Send to backend for verification
|
||||
const response = await fetch('/api/wallet/link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CryptID-PublicKey': session.publicKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
walletAddress: address,
|
||||
signature,
|
||||
message,
|
||||
label,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to link wallet');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} finally {
|
||||
setIsLinking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
address,
|
||||
isConnected,
|
||||
isLinking,
|
||||
linkWallet,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Integration Points
|
||||
|
||||
### A. AuthContext Extension
|
||||
|
||||
Add to `Session` type:
|
||||
```typescript
|
||||
interface Session {
|
||||
// ... existing fields
|
||||
linkedWallets?: LinkedWalletResponse[];
|
||||
primaryWallet?: LinkedWalletResponse;
|
||||
}
|
||||
```
|
||||
|
||||
### B. Token-Gated Features
|
||||
|
||||
```typescript
|
||||
// Check if user holds specific tokens
|
||||
async function checkTokenGate(
|
||||
walletAddress: string,
|
||||
requirement: {
|
||||
tokenAddress: string;
|
||||
minBalance: string;
|
||||
chainId: number;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
// Query on-chain balance or use cached value
|
||||
}
|
||||
```
|
||||
|
||||
### C. Snapshot Voting (Future)
|
||||
|
||||
```typescript
|
||||
// Vote on Snapshot proposal
|
||||
async function voteOnProposal(
|
||||
space: string,
|
||||
proposal: string,
|
||||
choice: number,
|
||||
walletAddress: string
|
||||
): Promise<void> {
|
||||
// Use Snapshot.js SDK with linked wallet
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
1. **Signature Replay Prevention**
|
||||
- Include timestamp and nonce in message
|
||||
- Server validates timestamp is recent (within 5 minutes)
|
||||
- Nonces are single-use
|
||||
|
||||
2. **Address Validation**
|
||||
- Always checksum addresses before storing/comparing
|
||||
- Validate address format (0x + 40 hex chars)
|
||||
|
||||
3. **Rate Limiting**
|
||||
- Limit link attempts per user (e.g., 5/hour)
|
||||
- Limit total wallets per user (e.g., 10)
|
||||
|
||||
4. **Wallet Verification**
|
||||
- EOA: EIP-191 personal_sign
|
||||
- Safe: ERC-1271 isValidSignature
|
||||
- Hardware: Same as EOA (via MetaMask bridge)
|
||||
|
||||
---
|
||||
|
||||
## 9. Next Steps
|
||||
|
||||
1. **Phase 1 (This Sprint)**
|
||||
- [ ] Add migration file
|
||||
- [ ] Install wagmi/viem dependencies
|
||||
- [ ] Implement link/list/unlink endpoints
|
||||
- [ ] Create WalletLinkPanel UI
|
||||
- [ ] Add wallet section to settings
|
||||
|
||||
2. **Phase 2 (Next Sprint)**
|
||||
- [ ] Snapshot.js integration
|
||||
- [ ] VotingShape for canvas
|
||||
- [ ] Token balance caching
|
||||
|
||||
3. **Phase 3 (Future)**
|
||||
- [ ] Safe SDK integration
|
||||
- [ ] TransactionBuilderShape
|
||||
- [ ] Account Abstraction exploration
|
||||
|
|
@ -4,7 +4,7 @@ title: offline local storage
|
|||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-03 23:42'
|
||||
updated_date: '2025-12-07 20:50'
|
||||
updated_date: '2025-12-04 20:35'
|
||||
labels:
|
||||
- feature
|
||||
- offline
|
||||
|
|
@ -49,6 +49,4 @@ Implemented connection status tracking:
|
|||
- Created ConnectionStatusIndicator component with visual feedback
|
||||
- Shows status only when not connected (connecting/reconnecting/disconnected/offline)
|
||||
- Auto-hides when connected and online
|
||||
|
||||
Model files downloaded successfully: tiny.en-encoder.int8.onnx (13MB), tiny.en-decoder.int8.onnx (87MB), tokens.txt (816KB)
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
|
|||
|
|
@ -1,182 +0,0 @@
|
|||
---
|
||||
id: task-007
|
||||
title: Web3 Wallet Linking & Blockchain Integration
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-03'
|
||||
updated_date: '2026-01-02 17:05'
|
||||
labels:
|
||||
- feature
|
||||
- web3
|
||||
- blockchain
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Integrate Web3 wallet capabilities to enable CryptID users to link EOA wallets and Safe multisigs for on-chain transactions, voting (Snapshot), and token-gated features.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
CryptID uses ECDSA P-256 (WebCrypto), while Ethereum uses secp256k1. These curves are incompatible, so we use a **wallet linking** approach rather than key reuse.
|
||||
|
||||
### Core Concept
|
||||
1. CryptID remains the primary authentication layer (passwordless)
|
||||
2. Users can link one or more Ethereum wallets to their CryptID
|
||||
3. Linking requires signing a verification message with the wallet
|
||||
4. Linked wallets enable: transactions, voting, token-gating, NFT features
|
||||
|
||||
### Tech Stack
|
||||
- **wagmi v2** + **viem** - Modern React hooks for wallet connection
|
||||
- **WalletConnect v2** - Multi-wallet support (MetaMask, Rainbow, etc.)
|
||||
- **Safe SDK** - Multisig wallet integration
|
||||
- **Snapshot.js** - Off-chain governance voting
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Wallet Linking Foundation (This Task)
|
||||
- Add wagmi/viem/walletconnect dependencies
|
||||
- Create linked_wallets D1 table
|
||||
- Implement wallet linking API endpoints
|
||||
- Build WalletLinkPanel UI component
|
||||
- Display linked wallets in user settings
|
||||
|
||||
### Phase 2: Snapshot Voting (Future Task)
|
||||
- Integrate Snapshot.js SDK
|
||||
- Create VotingShape for canvas visualization
|
||||
- Implement vote signing flow
|
||||
|
||||
### Phase 3: Safe Multisig (Future Task)
|
||||
- Safe SDK integration
|
||||
- TransactionBuilderShape for visual tx composition
|
||||
- Collaborative signing UI
|
||||
|
||||
### Phase 4: Account Abstraction (Future Task)
|
||||
- ERC-4337 smart wallet with P-256 signature validation
|
||||
- Gasless transactions via paymaster
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Install and configure wagmi v2, viem, and @walletconnect/web3modal
|
||||
- [x] #2 Create linked_wallets table in Cloudflare D1 with proper schema
|
||||
- [x] #3 Implement POST /api/wallet/link endpoint with signature verification
|
||||
- [ ] #4 Implement GET /api/wallet/list endpoint to retrieve linked wallets
|
||||
- [ ] #5 Implement DELETE /api/wallet/unlink endpoint to remove wallet links
|
||||
- [ ] #6 Create WalletConnectButton component using wagmi hooks
|
||||
- [ ] #7 Create WalletLinkPanel component for linking flow UI
|
||||
- [ ] #8 Add wallet section to user settings/profile panel
|
||||
- [ ] #9 Display linked wallet addresses with ENS resolution
|
||||
- [ ] #10 Support multiple wallet types: EOA, Safe, Hardware
|
||||
- [ ] #11 Add wallet connection state to AuthContext
|
||||
- [ ] #12 Write tests for wallet linking flow
|
||||
- [ ] #13 Update CLAUDE.md with Web3 architecture documentation
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Dependencies & Configuration
|
||||
```bash
|
||||
npm install wagmi viem @tanstack/react-query @walletconnect/web3modal
|
||||
```
|
||||
|
||||
Configure wagmi with WalletConnect projectId and supported chains.
|
||||
|
||||
### Step 2: Database Schema
|
||||
Add to D1 migration:
|
||||
- linked_wallets table (user_id, wallet_address, wallet_type, chain_id, verified_at, signature_proof, ens_name, is_primary)
|
||||
|
||||
### Step 3: API Endpoints
|
||||
Worker routes:
|
||||
- POST /api/wallet/link - Verify signature, create link
|
||||
- GET /api/wallet/list - List user's linked wallets
|
||||
- DELETE /api/wallet/unlink - Remove a linked wallet
|
||||
- GET /api/wallet/verify/:address - Check if address is linked to any CryptID
|
||||
|
||||
### Step 4: Frontend Components
|
||||
- WagmiProvider wrapper in App.tsx
|
||||
- WalletConnectButton - Connect/disconnect wallet
|
||||
- WalletLinkPanel - Full linking flow with signature
|
||||
- WalletBadge - Display linked wallet in UI
|
||||
|
||||
### Step 5: Integration
|
||||
- Add linkedWallets to Session type
|
||||
- Update AuthContext with wallet state
|
||||
- Add wallet section to settings panel
|
||||
|
||||
### Step 6: Testing
|
||||
- Unit tests for signature verification
|
||||
- Integration tests for linking flow
|
||||
- E2E test for full wallet link journey
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
## Planning Complete (2026-01-02)
|
||||
|
||||
Comprehensive planning phase completed:
|
||||
|
||||
### Created Architecture Document (doc-001)
|
||||
- Full technical architecture for wallet linking
|
||||
- Database schema design
|
||||
- API endpoint specifications
|
||||
- Library comparison (wagmi/viem recommended)
|
||||
- Security considerations
|
||||
- Frontend component designs
|
||||
|
||||
### Created Migration File
|
||||
- `worker/migrations/002_linked_wallets.sql`
|
||||
- Tables: linked_wallets, wallet_link_tokens, wallet_token_balances
|
||||
- Proper indexes and foreign keys
|
||||
|
||||
### Created Follow-up Tasks
|
||||
- task-060: Snapshot Voting Integration
|
||||
- task-061: Safe Multisig Integration
|
||||
- task-062: Account Abstraction Exploration
|
||||
|
||||
### Key Architecture Decisions
|
||||
1. **Wallet Linking** approach (not key reuse) due to P-256/secp256k1 incompatibility
|
||||
2. **wagmi v2 + viem** for frontend (React hooks, tree-shakeable)
|
||||
3. **viem** for worker (signature verification)
|
||||
4. **EIP-191 personal_sign** for EOA verification
|
||||
5. **ERC-1271** for Safe/contract wallet verification (future)
|
||||
|
||||
### Next Steps
|
||||
1. Install dependencies: wagmi, viem, @tanstack/react-query, @web3modal/wagmi
|
||||
2. Run migration on D1
|
||||
3. Implement API endpoints in worker
|
||||
4. Build WalletLinkPanel UI component
|
||||
|
||||
## Implementation Complete (Phase 1: Wallet Linking)
|
||||
|
||||
### Files Created:
|
||||
- `src/providers/Web3Provider.tsx` - Wagmi v2 config with WalletConnect
|
||||
- `src/hooks/useWallet.ts` - React hooks for wallet connection/linking
|
||||
- `src/components/WalletLinkPanel.tsx` - UI component for wallet management
|
||||
- `worker/walletAuth.ts` - Backend signature verification and API handlers
|
||||
- `worker/migrations/002_linked_wallets.sql` - Database schema
|
||||
|
||||
### Files Modified:
|
||||
- `worker/types.ts` - Added wallet types
|
||||
- `worker/worker.ts` - Added wallet API routes
|
||||
- `src/App.tsx` - Integrated Web3Provider
|
||||
- `src/ui/UserSettingsModal.tsx` - Added wallet section to Integrations tab
|
||||
|
||||
### Features:
|
||||
- Connect wallets via MetaMask, WalletConnect, Coinbase Wallet
|
||||
- Link wallets to CryptID accounts via EIP-191 signature
|
||||
- View/manage linked wallets
|
||||
- Set primary wallet, unlink wallets
|
||||
- Supports mainnet, Optimism, Arbitrum, Base, Polygon
|
||||
|
||||
### Remaining Work:
|
||||
- Add @noble/hashes for proper keccak256/ecrecover (placeholder functions)
|
||||
- Run D1 migration on production
|
||||
- Get WalletConnect Project ID from cloud.walletconnect.com
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: task-007
|
||||
title: Web3 Integration
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2025-12-03'
|
||||
labels: [feature, web3, blockchain]
|
||||
priority: low
|
||||
branch: web3-integration
|
||||
---
|
||||
|
||||
## Description
|
||||
Integrate Web3 capabilities for blockchain-based features (wallet connect, NFT canvas elements, etc.).
|
||||
|
||||
## Branch Info
|
||||
- **Branch**: `web3-integration`
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Add wallet connection
|
||||
- [ ] Enable NFT minting of canvas elements
|
||||
- [ ] Blockchain-based ownership verification
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
---
|
||||
id: task-017
|
||||
title: Deploy CryptID email recovery to dev branch and test
|
||||
status: In Progress
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2025-12-04 12:00'
|
||||
updated_date: '2025-12-11 15:15'
|
||||
updated_date: '2025-12-04 12:27'
|
||||
labels:
|
||||
- feature
|
||||
- cryptid
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
---
|
||||
id: task-026
|
||||
title: Fix text shape sync between clients
|
||||
status: Done
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2025-12-04 20:48'
|
||||
updated_date: '2025-12-25 23:30'
|
||||
labels:
|
||||
- bug
|
||||
- sync
|
||||
|
|
@ -32,26 +31,7 @@ Files to investigate:
|
|||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Text shapes sync correctly between multiple clients
|
||||
- [x] #2 Text content preserved during automerge serialization/deserialization
|
||||
- [x] #3 Both new and existing text shapes display correctly on all clients
|
||||
- [ ] #1 Text shapes sync correctly between multiple clients
|
||||
- [ ] #2 Text content preserved during automerge serialization/deserialization
|
||||
- [ ] #3 Both new and existing text shapes display correctly on all clients
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
## Fix Applied (2025-12-25)
|
||||
|
||||
Root cause: Text shapes arriving from other clients had `props.text` but the deserialization code was:
|
||||
1. Initializing `richText` to empty `{ content: [], type: 'doc' }`
|
||||
2. Then deleting `props.text`
|
||||
3. Result: content lost
|
||||
|
||||
Fix: Added text → richText conversion for text shapes in `AutomergeToTLStore.ts` (lines 1162-1191), similar to the existing conversion for geo shapes.
|
||||
|
||||
The fix:
|
||||
- Checks if `props.text` exists before initializing richText
|
||||
- Converts text content to richText format
|
||||
- Preserves original text in `meta.text` for backward compatibility
|
||||
- Logs conversion for debugging
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ title: Implement proper Automerge CRDT sync for offline-first support
|
|||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2025-12-04 21:06'
|
||||
updated_date: '2025-12-25 23:59'
|
||||
updated_date: '2025-12-06 06:55'
|
||||
labels:
|
||||
- offline-sync
|
||||
- crdt
|
||||
|
|
@ -87,33 +87,4 @@ Added safety mitigations for Automerge format conversion (commit f8092d8 on feat
|
|||
Fixed persistence issue: Modified handlePeerDisconnect to flush pending saves and updated client-side merge strategy in useAutomergeSyncRepo.ts to properly bootstrap from server when local is empty while preserving offline changes
|
||||
|
||||
Fixed TypeScript errors in networking module: corrected useSession->useAuth import, added myConnections to NetworkGraph type, fixed GraphEdge type alignment between client and worker
|
||||
|
||||
## Investigation Summary (2025-12-25)
|
||||
|
||||
**Current Architecture:**
|
||||
- Worker: CRDT sync enabled with SyncManager
|
||||
- Client: CloudflareNetworkAdapter with binary message support
|
||||
- Storage: IndexedDB for offline persistence
|
||||
|
||||
**Issue:** Automerge Repo not generating sync messages when `handle.change()` is called. JSON sync workaround in use.
|
||||
|
||||
**Suspected Root Cause:**
|
||||
The Automerge Repo requires proper peer discovery. The adapter emits `peer-candidate` for server, but Repo may not be establishing proper sync relationship.
|
||||
|
||||
**Remaining ACs:**
|
||||
- #2 Client-server binary protocol (partially working - needs Repo to generate messages)
|
||||
- #3 Deletions persist (needs testing once binary sync works)
|
||||
- #4 Concurrent edits merge (needs testing)
|
||||
- #6 All functionality works (JSON workaround is functional)
|
||||
|
||||
**Next Steps:**
|
||||
1. Add debug logging to adapter.send() to verify Repo calls
|
||||
2. Check sync states between local peer and server
|
||||
3. May need to manually trigger sync or fix Repo configuration
|
||||
|
||||
Dec 25: Added debug logging and peer-candidate re-emission fix to CloudflareAdapter.ts
|
||||
|
||||
Key fix: Re-emit peer-candidate after documentId is set to trigger Repo sync (timing issue)
|
||||
|
||||
Committed and pushed to dev branch - needs testing to verify binary sync is now working
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
---
|
||||
id: task-044
|
||||
title: Test dev branch UI redesign and Map fixes
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-07 23:26'
|
||||
updated_date: '2025-12-08 01:19'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Test the changes pushed to dev branch in commit 8123f0f
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 CryptID dropdown works (sign in/out, Google integration)
|
||||
- [ ] #2 Settings gear dropdown shows dark mode toggle
|
||||
- [ ] #3 Social Network graph shows user as lone node when solo
|
||||
- [ ] #4 Map marker tool adds markers on click
|
||||
- [ ] #5 Map scroll wheel zooms correctly
|
||||
- [ ] #6 Old boards with Map shapes load without validation errors
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Session completed. All changes pushed to dev branch:
|
||||
- UI redesign: unified top-right menu with grey oval container
|
||||
- Social Network graph: dark theme with directional arrows
|
||||
- MI bar: responsive layout (bottom on mobile)
|
||||
- Map fixes: tool clicks work, scroll zoom works
|
||||
- Automerge: Map shape schema validation fix
|
||||
- Network graph: graceful fallback on API errors
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
id: task-045
|
||||
title: Implement offline-first loading from IndexedDB
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-08 08:47'
|
||||
labels:
|
||||
- bug-fix
|
||||
- offline
|
||||
- automerge
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Fixed a bug where the app would hang indefinitely when the server wasn't running because `await adapter.whenReady()` blocked IndexedDB loading. Now the app loads from IndexedDB first (offline-first), then syncs with server in the background with a 5-second timeout.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
id: task-046
|
||||
title: Add maximize button to StandardizedToolWrapper
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-08 08:51'
|
||||
updated_date: '2025-12-08 09:03'
|
||||
labels:
|
||||
- feature
|
||||
- ui
|
||||
- shapes
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Added a maximize/fullscreen button to the standardized header bar. When clicked, the tool fills the viewport. Press Esc or click again to restore original dimensions. Created useMaximize hook that shape utils can use. Implemented on ChatBoxShapeUtil as example.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added maximize to ALL 16 shapes using StandardizedToolWrapper (not just ChatBox)
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
---
|
||||
id: task-047
|
||||
title: Improve mobile touch/pen interactions across custom tools
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-10 18:28'
|
||||
updated_date: '2025-12-10 18:28'
|
||||
labels:
|
||||
- mobile
|
||||
- touch
|
||||
- ux
|
||||
- accessibility
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Fixed touch and pen interaction issues across all custom canvas tools to ensure they work properly on mobile devices and with stylus input.
|
||||
|
||||
Changes made:
|
||||
- Added onTouchStart/onTouchEnd handlers to all interactive elements
|
||||
- Added touchAction: 'manipulation' CSS to prevent 300ms click delay
|
||||
- Increased minimum touch target sizes to 44px for accessibility
|
||||
- Fixed ImageGen: Generate button, Copy/Download/Delete, input field
|
||||
- Fixed VideoGen: Upload, URL input, prompt, duration, Generate button
|
||||
- Fixed Transcription: Start/Stop/Pause buttons, textarea, Save/Cancel
|
||||
- Fixed Multmux: Create Session, Refresh, session list, input fields
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 All buttons respond to touch on mobile devices
|
||||
- [x] #2 No 300ms click delay on interactive elements
|
||||
- [x] #3 Touch targets are at least 44px for accessibility
|
||||
- [x] #4 Image generation works on mobile
|
||||
- [x] #5 Video generation works on mobile
|
||||
- [x] #6 Transcription controls work on mobile
|
||||
- [x] #7 Terminal (Multmux) controls work on mobile
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Pushed to dev branch: b6af3ec
|
||||
|
||||
Files modified: ImageGenShapeUtil.tsx, VideoGenShapeUtil.tsx, TranscriptionShapeUtil.tsx, MultmuxShapeUtil.tsx
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
---
|
||||
id: task-048
|
||||
title: Version History & CryptID Registration Enhancements
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-10 22:22'
|
||||
updated_date: '2025-12-10 22:22'
|
||||
labels:
|
||||
- feature
|
||||
- auth
|
||||
- history
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add version history feature with diff visualization and enhance CryptID registration flow with email backup
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
## Implementation Summary
|
||||
|
||||
### Email Service (SendGrid → Resend)
|
||||
- Updated `worker/types.ts` to use `RESEND_API_KEY`
|
||||
- Updated `worker/cryptidAuth.ts` sendEmail() to use Resend API
|
||||
|
||||
### CryptID Registration Flow
|
||||
- Multi-step registration: welcome → username → email → success
|
||||
- Detailed explainer about passwordless authentication
|
||||
- Email backup for multi-device access
|
||||
- Added `email` field to Session type
|
||||
|
||||
### Version History Feature
|
||||
|
||||
**Backend API Endpoints:**
|
||||
- `GET /room/:roomId/history` - Get version history
|
||||
- `GET /room/:roomId/snapshot/:hash` - Get snapshot at version
|
||||
- `POST /room/:roomId/diff` - Compute diff between versions
|
||||
- `POST /room/:roomId/revert` - Revert to a version
|
||||
|
||||
**Frontend Components:**
|
||||
- `VersionHistoryPanel.tsx` - Timeline with diff visualization
|
||||
- `useVersionHistory.ts` - React hook for programmatic access
|
||||
- GREEN highlighting for added shapes
|
||||
- RED highlighting for removed shapes
|
||||
- PURPLE highlighting for modified shapes
|
||||
|
||||
### Other Fixes
|
||||
- Network graph connect/trust buttons now work
|
||||
- CryptID dropdown integration buttons improved
|
||||
- Obsidian vault connection modal added
|
||||
|
||||
Pushed to dev branch: commit 195cc7f
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
---
|
||||
id: task-049
|
||||
title: Implement second device verification for CryptID
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2025-12-10 22:24'
|
||||
labels:
|
||||
- cryptid
|
||||
- auth
|
||||
- security
|
||||
- testing
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Set up and test second device verification flow for the CryptID authentication system. This ensures users can recover their account and verify identity across multiple devices.
|
||||
|
||||
Key areas to implement/verify:
|
||||
- QR code scanning between devices for key sharing
|
||||
- Email backup verification flow
|
||||
- Device linking and trust establishment
|
||||
- Recovery flow when primary device is lost
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Second device can scan QR code to link account
|
||||
- [ ] #2 Email backup sends verification code correctly (via Resend)
|
||||
- [ ] #3 Linked devices can both access the same account
|
||||
- [ ] #4 Recovery flow works when primary device unavailable
|
||||
- [ ] #5 Test across different browsers/devices
|
||||
<!-- AC:END -->
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
---
|
||||
id: task-050
|
||||
title: Implement Make-Real Feature (Wireframe to Working Prototype)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2025-12-14 18:32'
|
||||
labels:
|
||||
- feature
|
||||
- ai
|
||||
- canvas
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Implement the full make-real workflow that converts wireframe sketches/designs on the canvas into working HTML/CSS/JS prototypes using AI.
|
||||
|
||||
## Current State
|
||||
The backend infrastructure is ~60% complete:
|
||||
- ✅ `makeRealSettings` atom in `src/lib/settings.tsx` with provider/model/API key configs
|
||||
- ✅ System prompt in `src/prompt.ts` for wireframe-to-prototype conversion
|
||||
- ✅ LLM backend in `src/utils/llmUtils.ts` with OpenAI, Anthropic, Ollama, RunPod support
|
||||
- ✅ Settings migration in `src/routes/Board.tsx` loading `makereal_settings_2`
|
||||
- ✅ "Make Real" placeholder in AI_TOOLS dropdown
|
||||
|
||||
## Missing Components
|
||||
1. **Selection-to-image capture** - Export selected shapes as base64 PNG
|
||||
2. **`makeReal()` action function** - Orchestrate the capture → AI → render pipeline
|
||||
3. **ResponseShape/PreviewShape** - Custom tldraw shape to render generated HTML in iframe
|
||||
4. **UI trigger** - Button/keyboard shortcut to invoke make-real on selection
|
||||
5. **Iteration support** - Allow annotations on generated output for refinement
|
||||
|
||||
## Reference Implementation
|
||||
- tldraw make-real demo: https://github.com/tldraw/make-real
|
||||
- Key files to reference: `makeReal.ts`, `ResponseShape.tsx`, `getSelectionAsImageDataUrl.ts`
|
||||
|
||||
## Old Branch
|
||||
`remotes/origin/make-real-integration` exists but is very outdated with errors - needs complete rewrite rather than merge.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 User can select shapes on canvas and trigger make-real action
|
||||
- [ ] #2 Selection is captured as image and sent to configured AI provider
|
||||
- [ ] #3 AI generates HTML/CSS/JS prototype based on wireframe and system prompt
|
||||
- [ ] #4 Generated prototype renders in interactive iframe on canvas (ResponseShape)
|
||||
- [ ] #5 User can annotate/modify and re-run make-real for iterations
|
||||
- [ ] #6 Settings modal allows configuring provider/model/API keys
|
||||
- [ ] #7 Works with Ollama (free), OpenAI, and Anthropic backends
|
||||
<!-- AC:END -->
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
---
|
||||
id: task-051
|
||||
title: Offline storage and cold reload from offline state
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-15 04:58'
|
||||
updated_date: '2025-12-25 23:38'
|
||||
labels:
|
||||
- feature
|
||||
- offline
|
||||
- storage
|
||||
- IndexedDB
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Implement offline storage fallback so that when a browser reloads without network connectivity, it automatically loads from local IndexedDB storage and renders the last known state of the board for that user.
|
||||
|
||||
## Implementation Summary (Completed)
|
||||
|
||||
### Changes Made:
|
||||
1. **Board.tsx** - Updated render condition to allow rendering when offline with local data (`isOfflineWithLocalData` flag)
|
||||
2. **useAutomergeStoreV2** - Added `isNetworkOnline` parameter and offline fast path that immediately loads records from Automerge doc without waiting for network patches
|
||||
3. **useAutomergeSyncRepo** - Passes `isNetworkOnline` to `useAutomergeStoreV2`
|
||||
4. **ConnectionStatusIndicator** - Updated messaging to clarify users are viewing locally cached canvas when offline
|
||||
|
||||
### How It Works:
|
||||
1. useAutomergeSyncRepo detects no network and loads data from IndexedDB
|
||||
2. useAutomergeStoreV2 receives handle with local data and detects offline state
|
||||
3. Offline Fast Path immediately loads records into TLDraw store
|
||||
4. Board.tsx renders with local data
|
||||
5. ConnectionStatusIndicator shows "Working Offline - Viewing locally saved canvas"
|
||||
6. When back online, Automerge automatically syncs via CRDT merge
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Board renders from local IndexedDB when browser reloads offline
|
||||
- [x] #2 User sees 'Working Offline' indicator with clear messaging
|
||||
- [x] #3 Changes made offline are saved locally
|
||||
- [x] #4 Auto-sync when network connectivity returns
|
||||
- [x] #5 No data loss during offline/online transitions
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
## Testing Required
|
||||
- Test cold reload while offline (airplane mode)
|
||||
- Test with board containing various shape types
|
||||
- Test transition from offline to online (auto-sync)
|
||||
- Test making changes while offline and syncing
|
||||
- Verify no data loss scenarios
|
||||
|
||||
Commit: 4df9e42 pushed to dev branch
|
||||
|
||||
## Code Review Complete (2025-12-25)
|
||||
|
||||
All acceptance criteria implemented:
|
||||
|
||||
**AC #1 - Board renders from IndexedDB offline:**
|
||||
- Board.tsx line 1225: `isOfflineWithLocalData = !isNetworkOnline && hasStore`
|
||||
- Line 1229: `shouldRender = hasStore && (isSynced || isOfflineWithLocalData)`
|
||||
|
||||
**AC #2 - Working Offline indicator:**
|
||||
- ConnectionStatusIndicator shows 'Working Offline' with purple badge
|
||||
- Detailed message explains local caching and auto-sync
|
||||
|
||||
**AC #3 - Changes saved locally:**
|
||||
- Automerge Repo uses IndexedDBStorageAdapter
|
||||
- Changes persisted via handle.change() automatically
|
||||
|
||||
**AC #4 - Auto-sync on reconnect:**
|
||||
- CloudflareAdapter has networkOnlineHandler/networkOfflineHandler
|
||||
- Triggers reconnect when network returns
|
||||
|
||||
**AC #5 - No data loss:**
|
||||
- CRDT merge semantics preserve all changes
|
||||
- JSON sync fallback also handles offline changes
|
||||
|
||||
**Manual testing recommended:**
|
||||
- Test in airplane mode with browser reload
|
||||
- Verify data persists across offline sessions
|
||||
- Test online/offline transitions
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
---
|
||||
id: task-052
|
||||
title: 'Flip permissions model: everyone edits by default, protected boards opt-in'
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-15 17:23'
|
||||
updated_date: '2025-12-15 19:26'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Change the default permission model so ALL users (including anonymous) can edit by default. Boards can be marked as "protected" by an admin, making them view-only for non-designated users.
|
||||
|
||||
Key changes:
|
||||
1. Add is_protected column to boards table
|
||||
2. Add global_admins table (jeffemmett@gmail.com as initial admin)
|
||||
3. Flip getEffectivePermission logic
|
||||
4. Create BoardSettingsDropdown component with view-only toggle
|
||||
5. Add user invite for protected boards
|
||||
6. Admin request email flow
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Anonymous users can edit unprotected boards
|
||||
- [x] #2 Protected boards are view-only for non-editors
|
||||
- [x] #3 Global admin (jeffemmett@gmail.com) has admin on all boards
|
||||
- [x] #4 Settings dropdown shows view-only toggle for admins
|
||||
- [x] #5 Can add/remove editors on protected boards
|
||||
- [x] #6 Admin request button sends email
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
## Implementation Complete (Dec 15, 2025)
|
||||
|
||||
### Backend Changes (commit 2fe96fa)
|
||||
- **worker/schema.sql**: Added `is_protected` column to boards, created `global_admins` table
|
||||
- **worker/types.ts**: Added `GlobalAdmin` interface, extended `PermissionCheckResult`
|
||||
- **worker/boardPermissions.ts**: Rewrote `getEffectivePermission()` with new logic, added `isGlobalAdmin()`, new API handlers
|
||||
- **worker/worker.ts**: Added routes for `/boards/:boardId/info`, `/boards/:boardId/editors`, `/admin/request`
|
||||
- **worker/migrations/001_add_protected_boards.sql**: Migration script created
|
||||
|
||||
### D1 Migration (executed manually)
|
||||
```sql
|
||||
ALTER TABLE boards ADD COLUMN is_protected INTEGER DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_boards_protected ON boards(is_protected);
|
||||
CREATE TABLE IF NOT EXISTS global_admins (email TEXT PRIMARY KEY, added_at TEXT, added_by TEXT);
|
||||
INSERT OR IGNORE INTO global_admins (email) VALUES ('jeffemmett@gmail.com');
|
||||
```
|
||||
|
||||
### Frontend Changes (commit 3f71222)
|
||||
- **src/ui/components.tsx**: Integrated board protection settings into existing settings dropdown
|
||||
- Protection toggle (view-only mode)
|
||||
- Editor list management (add/remove)
|
||||
- Global Admin badge display
|
||||
- **src/context/AuthContext.tsx**: Changed default permission to 'edit' for everyone
|
||||
- **src/routes/Board.tsx**: Updated `isReadOnly` logic for new permission model
|
||||
- **src/components/BoardSettingsDropdown.tsx**: Created standalone component (kept for reference)
|
||||
|
||||
### Worker Deployment
|
||||
- Deployed to Cloudflare Workers (version 5ddd1e23-d32f-459f-bc5c-cf3f799ab93f)
|
||||
|
||||
### Remaining
|
||||
- [ ] AC #6: Admin request email flow (Resend integration needed)
|
||||
|
||||
### Resend Email Integration (commit a46ce44)
|
||||
- Added `RESEND_API_KEY` secret to Cloudflare Worker
|
||||
- Fixed from email to use verified domain: `Canvas <noreply@jeffemmett.com>`
|
||||
- Admin request emails will be sent to jeffemmett@gmail.com
|
||||
- Test email sent successfully: ID 7113526b-ce1e-43e7-b18d-42b3d54823d1
|
||||
|
||||
**All acceptance criteria now complete!**
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
---
|
||||
id: task-053
|
||||
title: Initial mycro-zine toolkit setup
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-15 23:41'
|
||||
updated_date: '2025-12-15 23:41'
|
||||
labels:
|
||||
- setup
|
||||
- feature
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Created the mycro-zine repository with:
|
||||
- Single-page print layout generator (2x4 grid, all 8 pages on one 8.5"x11" sheet)
|
||||
- Prompt templates for AI content/image generation
|
||||
- Example Undernet zine pages
|
||||
- Support for US Letter and A4 paper sizes
|
||||
- CLI and programmatic API
|
||||
- Pushed to Gitea and GitHub
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Repository structure created
|
||||
- [x] #2 Layout script generates single-page output
|
||||
- [x] #3 Prompt templates created
|
||||
- [x] #4 Example zine pages included
|
||||
- [x] #5 Pushed to Gitea and GitHub
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Completed 2025-12-15. Repository at:
|
||||
- Gitea: gitea.jeffemmett.com:jeffemmett/mycro-zine
|
||||
- GitHub: github.com/Jeff-Emmett/mycro-zine
|
||||
|
||||
Test with: cd /home/jeffe/Github/mycro-zine && npm run example
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
---
|
||||
id: task-054
|
||||
title: Re-enable Map tool with GPS location sharing
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-15 23:40'
|
||||
updated_date: '2025-12-15 23:40'
|
||||
labels:
|
||||
- feature
|
||||
- map
|
||||
- collaboration
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Re-enabled the Map tool in the toolbar and context menu. Added GPS location sharing feature allowing collaborators to share their real-time location on the map with colored markers.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Map tool visible in toolbar (globe icon)
|
||||
- [x] #2 Map tool available in context menu under Create Tool
|
||||
- [x] #3 GPS location sharing toggle button works
|
||||
- [x] #4 Collaborator locations shown as colored markers
|
||||
- [x] #5 GPS watch cleaned up on component unmount
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented in commit 2d9d216.
|
||||
|
||||
Changes:
|
||||
- CustomToolbar.tsx: Uncommented Map tool
|
||||
- CustomContextMenu.tsx: Uncommented Map tool in Create Tool submenu
|
||||
- MapShapeUtil.tsx: Added GPS location sharing with collaborator markers
|
||||
|
||||
GPS feature includes toggle button, real-time location updates, colored markers for each collaborator, and proper cleanup on unmount.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
---
|
||||
id: task-055
|
||||
title: Integrate MycroZine generator tool into canvas
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2025-12-15 23:41'
|
||||
updated_date: '2025-12-18 23:24'
|
||||
labels:
|
||||
- feature
|
||||
- canvas
|
||||
- ai
|
||||
- gemini
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Create a MycroZineGeneratorShape - an interactive tool on the canvas that allows users to generate complete 8-page mini-zines from a topic/prompt.
|
||||
|
||||
5-phase iterative workflow:
|
||||
1. Ideation: User discusses content with Claude (conversational)
|
||||
2. Drafts: Claude generates 8 draft pages using Gemini, spawns on canvas
|
||||
3. Feedback: User gives spatial feedback on each page
|
||||
4. Finalization: Claude integrates feedback into final versions
|
||||
5. Print: Aggregate into single-page printable (2x4 grid)
|
||||
|
||||
Key requirements:
|
||||
- Always use Gemini for image generation (latest model)
|
||||
- Store completed zines as templates for reprinting
|
||||
- Individual image shapes spawned on canvas for spatial feedback
|
||||
- Single-page print layout (all 8 pages on one 8.5"x11" sheet)
|
||||
|
||||
References mycro-zine repo at /home/jeffe/Github/mycro-zine for layout utilities and prompt templates.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 MycroZineGeneratorShapeUtil.tsx created
|
||||
- [x] #2 MycroZineGeneratorTool.ts created and registered
|
||||
- [ ] #3 Ideation phase with embedded chat UI
|
||||
- [ ] #4 Drafts phase generates 8 images via Gemini and spawns on canvas
|
||||
- [ ] #5 Feedback phase collects user input per page
|
||||
- [ ] #6 Finalizing phase regenerates pages with feedback
|
||||
- [ ] #7 Complete phase with print-ready download and template save
|
||||
- [ ] #8 Templates stored in localStorage for reprinting
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Starting implementation of full 5-phase MycroZineGenerator shape
|
||||
|
||||
Created MycroZineGeneratorShapeUtil.tsx with full 5-phase workflow (ideation, drafts, feedback, finalizing, complete)
|
||||
|
||||
Created MycroZineGeneratorTool.ts
|
||||
|
||||
Registered in Board.tsx
|
||||
|
||||
Build successful - no TypeScript errors
|
||||
|
||||
Integrated Gemini Nano Banana Pro for image generation:
|
||||
- Updated standalone mycro-zine app (generate-page/route.ts) with fallback chain: Nano Banana Pro → Imagen 3 → Gemini 2.0 Flash → placeholder
|
||||
- Updated canvas MycroZineGeneratorShapeUtil.tsx to call Gemini API directly with proper types
|
||||
- Added getGeminiConfig() to clientConfig.ts for API key management
|
||||
- Aspect ratio: 3:4 portrait for zine pages (825x1275 target dimensions)
|
||||
|
||||
2025-12-18: Fixed geo-restriction issue for image generation
|
||||
- Direct Gemini API calls were blocked in EU (Netcup server location)
|
||||
- Created RunPod serverless proxy (US-based) to bypass geo-restrictions
|
||||
- Added /api/generate-image endpoint to zine.jeffemmett.com that returns base64
|
||||
- Updated canvas MycroZineGeneratorShapeUtil to call zine.jeffemmett.com API instead of Gemini directly
|
||||
- Image generation now works reliably from any location
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
---
|
||||
id: task-056
|
||||
title: Test Infrastructure & Merge Readiness Tests
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-18 07:25'
|
||||
updated_date: '2025-12-18 07:26'
|
||||
labels:
|
||||
- testing
|
||||
- ci-cd
|
||||
- infrastructure
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Established comprehensive testing infrastructure to verify readiness for merging dev to main. Includes:
|
||||
|
||||
- Vitest for unit/integration tests
|
||||
- Playwright for E2E tests
|
||||
- Miniflare setup for worker tests
|
||||
- GitHub Actions CI/CD pipeline with 80% coverage gate
|
||||
|
||||
Test coverage for:
|
||||
- Automerge CRDT sync (collaboration tests)
|
||||
- Offline storage/cold reload
|
||||
- CryptID authentication (registration, login, device linking)
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Vitest configured with jsdom environment
|
||||
- [x] #2 Playwright configured for E2E tests
|
||||
- [x] #3 Unit tests for crypto and IndexedDB document mapping
|
||||
- [x] #4 E2E tests for collaboration, offline mode, authentication
|
||||
- [x] #5 GitHub Actions workflow for CI/CD
|
||||
- [x] #6 All current tests passing
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
## Implementation Summary
|
||||
|
||||
### Files Created:
|
||||
- `vitest.config.ts` - Vitest configuration with jsdom, coverage thresholds
|
||||
- `playwright.config.ts` - Playwright E2E test configuration
|
||||
- `tests/setup.ts` - Global test setup (mocks for matchMedia, ResizeObserver, etc.)
|
||||
- `tests/mocks/indexeddb.ts` - fake-indexeddb utilities
|
||||
- `tests/mocks/websocket.ts` - MockWebSocket for sync tests
|
||||
- `tests/mocks/automerge.ts` - Test helpers for CRDT documents
|
||||
- `tests/unit/cryptid/crypto.test.ts` - WebCrypto unit tests (14 tests)
|
||||
- `tests/unit/offline/document-mapping.test.ts` - IndexedDB tests (13 tests)
|
||||
- `tests/e2e/collaboration.spec.ts` - CRDT sync E2E tests
|
||||
- `tests/e2e/offline-mode.spec.ts` - Offline storage E2E tests
|
||||
- `tests/e2e/authentication.spec.ts` - CryptID auth E2E tests
|
||||
- `.github/workflows/test.yml` - CI/CD pipeline
|
||||
|
||||
### Test Commands Added to package.json:
|
||||
- `npm run test` - Run Vitest in watch mode
|
||||
- `npm run test:run` - Run once
|
||||
- `npm run test:coverage` - With coverage report
|
||||
- `npm run test:e2e` - Run Playwright E2E tests
|
||||
|
||||
### Current Test Results:
|
||||
- 27 unit tests passing
|
||||
- E2E tests ready to run against dev server
|
||||
|
||||
### Next Steps:
|
||||
- Add worker tests with Miniflare (task-056 continuation)
|
||||
- Run E2E tests to verify collaboration/offline/auth flows
|
||||
- Increase unit test coverage to 80%
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
---
|
||||
id: task-057
|
||||
title: Set up Cloudflare WARP split tunnels for Claude Code
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-19 01:10'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Configured Cloudflare Zero Trust split tunnel excludes to allow Claude Code to work in WSL2 with WARP enabled on Windows.
|
||||
|
||||
Completed:
|
||||
- Created Zero Trust API token with device config permissions
|
||||
- Added localhost (127.0.0.0/8) to excludes
|
||||
- Added Anthropic domains (api.anthropic.com, claude.ai, anthropic.com)
|
||||
- Private networks already excluded (172.16.0.0/12, 192.168.0.0/16, 10.0.0.0/8)
|
||||
- Created ~/bin/warp-split-tunnel CLI tool for future management
|
||||
- Saved token to Netcup ~/.cloudflare-credentials.env
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
---
|
||||
id: task-058
|
||||
title: Set FAL_API_KEY and RUNPOD_API_KEY secrets in Cloudflare Worker
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2025-12-25 23:30'
|
||||
updated_date: '2025-12-26 01:26'
|
||||
labels:
|
||||
- security
|
||||
- infrastructure
|
||||
- canvas-website
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
SECURITY FIX: API keys were exposed in browser bundle. They've been removed from client code and proxy endpoints added to the worker. Need to set the secrets server-side for the proxy to work.
|
||||
|
||||
Run these commands:
|
||||
```bash
|
||||
cd /home/jeffe/Github/canvas-website
|
||||
wrangler secret put FAL_API_KEY
|
||||
# Paste: (REDACTED-FAL-KEY)
|
||||
|
||||
wrangler secret put RUNPOD_API_KEY
|
||||
# Paste: (REDACTED-RUNPOD-KEY)
|
||||
|
||||
wrangler deploy
|
||||
```
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 FAL_API_KEY secret set in Cloudflare Worker
|
||||
- [x] #2 RUNPOD_API_KEY secret set in Cloudflare Worker
|
||||
- [x] #3 Worker deployed with new secrets
|
||||
- [x] #4 Browser console no longer shows 'fal credentials exposed' warning
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Secrets set and deployed on 2025-12-25
|
||||
|
||||
Dec 25: Completed full client migration to server-side proxies. Pushed to dev branch.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
---
|
||||
id: task-059
|
||||
title: Debug Drawfast tool output
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2025-12-26 04:37'
|
||||
labels:
|
||||
- bug
|
||||
- ai
|
||||
- shapes
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
The Drawfast tool has been temporarily disabled due to output issues that need debugging.
|
||||
|
||||
## Background
|
||||
Drawfast is a real-time AI image generation tool that generates images as users draw. The tool has been disabled in Board.tsx pending debugging.
|
||||
|
||||
## Files to investigate
|
||||
- `src/shapes/DrawfastShapeUtil.tsx` - Shape rendering and state
|
||||
- `src/tools/DrawfastTool.ts` - Tool interaction logic
|
||||
- `src/hooks/useLiveImage.tsx` - Live image generation hook
|
||||
|
||||
## To re-enable
|
||||
1. Uncomment imports in Board.tsx (lines 50-52)
|
||||
2. Uncomment DrawfastShape in customShapeUtils array (line 173)
|
||||
3. Uncomment DrawfastTool in customTools array (line 199)
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
---
|
||||
id: task-060
|
||||
title: Snapshot Voting Integration
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-01-02 16:08'
|
||||
labels:
|
||||
- feature
|
||||
- web3
|
||||
- governance
|
||||
- voting
|
||||
dependencies:
|
||||
- task-007
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Integrate Snapshot.js SDK for off-chain governance voting through the canvas interface.
|
||||
|
||||
## Overview
|
||||
Enable CryptID users with linked wallets to participate in Snapshot governance votes directly from the canvas. Proposals and voting can be visualized as shapes on the canvas.
|
||||
|
||||
## Dependencies
|
||||
- Requires task-007 (Web3 Wallet Linking) to be completed first
|
||||
- User must have at least one linked wallet with voting power
|
||||
|
||||
## Technical Approach
|
||||
- Use Snapshot.js SDK for proposal fetching and vote submission
|
||||
- Create VotingShape to visualize proposals on canvas
|
||||
- Support EIP-712 signature-based voting via linked wallet
|
||||
- Cache voting power from linked wallets
|
||||
|
||||
## Features
|
||||
1. **Proposal Browser** - List active proposals from configured spaces
|
||||
2. **VotingShape** - Canvas shape to display proposal details and vote
|
||||
3. **Vote Signing** - Use wagmi's signTypedData for EIP-712 votes
|
||||
4. **Voting Power Display** - Show user's voting power per space
|
||||
5. **Vote History** - Track user's past votes
|
||||
|
||||
## Spaces to Support Initially
|
||||
- mycofi.eth (MycoFi DAO)
|
||||
- Add configuration for additional spaces
|
||||
|
||||
## References
|
||||
- Snapshot.js: https://docs.snapshot.org/tools/snapshot.js
|
||||
- Snapshot API: https://docs.snapshot.org/tools/api
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Install and configure Snapshot.js SDK
|
||||
- [ ] #2 Create VotingShape with proposal details display
|
||||
- [ ] #3 Implement vote signing flow with EIP-712
|
||||
- [ ] #4 Add proposal browser panel to canvas UI
|
||||
- [ ] #5 Display voting power from linked wallets
|
||||
- [ ] #6 Support multiple Snapshot spaces via configuration
|
||||
- [ ] #7 Cache and display vote history
|
||||
<!-- AC:END -->
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
---
|
||||
id: task-061
|
||||
title: Safe Multisig Integration for Collaborative Transactions
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-01-02 16:08'
|
||||
labels:
|
||||
- feature
|
||||
- web3
|
||||
- multisig
|
||||
- safe
|
||||
- governance
|
||||
dependencies:
|
||||
- task-007
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Integrate Safe (Gnosis Safe) SDK to enable collaborative transaction building and signing through the canvas interface.
|
||||
|
||||
## Overview
|
||||
Allow CryptID users to create, propose, and sign Safe multisig transactions visually on the canvas. Multiple signers can collaborate in real-time to approve transactions.
|
||||
|
||||
## Dependencies
|
||||
- Requires task-007 (Web3 Wallet Linking) to be completed first
|
||||
- Users must link their Safe wallet or EOA that is a Safe signer
|
||||
|
||||
## Technical Approach
|
||||
- Use Safe{Core} SDK for transaction building and signing
|
||||
- Create TransactionBuilderShape for visual tx composition
|
||||
- Use Safe Transaction Service API for proposal queue
|
||||
- Real-time signature collection via canvas collaboration
|
||||
|
||||
## Features
|
||||
1. **Safe Linking** - Link Safe addresses (detect via ERC-1271)
|
||||
2. **TransactionBuilderShape** - Visual transaction composer
|
||||
3. **Signature Collection UI** - See who has signed, who is pending
|
||||
4. **Transaction Queue** - View pending transactions for linked Safes
|
||||
5. **Execution** - Execute transactions when threshold is met
|
||||
|
||||
## Visual Transaction Builder Capabilities
|
||||
- Transfer ETH/tokens
|
||||
- Contract interactions (with ABI import)
|
||||
- Batch transactions
|
||||
- Scheduled transactions (via delay module)
|
||||
|
||||
## Collaboration Features
|
||||
- Real-time signature status on canvas
|
||||
- Notifications when signatures are needed
|
||||
- Discussion threads on pending transactions
|
||||
|
||||
## References
|
||||
- Safe{Core} SDK: https://docs.safe.global/sdk/overview
|
||||
- Safe Transaction Service API: https://docs.safe.global/core-api/transaction-service-overview
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Install and configure Safe{Core} SDK
|
||||
- [ ] #2 Implement ERC-1271 signature verification for Safe linking
|
||||
- [ ] #3 Create TransactionBuilderShape for visual tx composition
|
||||
- [ ] #4 Build signature collection UI with real-time updates
|
||||
- [ ] #5 Display pending transaction queue for linked Safes
|
||||
- [ ] #6 Enable transaction execution when threshold is met
|
||||
- [ ] #7 Support basic transfer and contract interaction transactions
|
||||
<!-- AC:END -->
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
---
|
||||
id: task-062
|
||||
title: Account Abstraction (ERC-4337) Exploration
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-01-02 16:08'
|
||||
labels:
|
||||
- research
|
||||
- web3
|
||||
- account-abstraction
|
||||
- erc-4337
|
||||
dependencies:
|
||||
- task-007
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Research and prototype using ERC-4337 Account Abstraction to enable CryptID's P-256 keys to directly control smart contract wallets.
|
||||
|
||||
## Overview
|
||||
Explore the possibility of using Account Abstraction (ERC-4337) to bridge CryptID's WebCrypto P-256 keys with Ethereum transactions. This would eliminate the need for wallet linking by allowing CryptID keys to directly sign UserOperations that control a smart wallet.
|
||||
|
||||
## Background
|
||||
- CryptID uses ECDSA P-256 (NIST curve) via WebCrypto API
|
||||
- Ethereum uses ECDSA secp256k1
|
||||
- These curves are incompatible for direct signing
|
||||
- ERC-4337 allows any signature scheme via custom validation logic
|
||||
|
||||
## Research Questions
|
||||
1. Is P-256 signature verification gas-efficient on-chain?
|
||||
2. What existing implementations exist? (Clave, Daimo)
|
||||
3. What are the wallet deployment costs per user?
|
||||
4. How do we handle gas sponsorship (paymaster)?
|
||||
5. Which bundler/paymaster providers support this?
|
||||
|
||||
## Potential Benefits
|
||||
- Single key for auth AND transactions
|
||||
- Gasless transactions via paymaster
|
||||
- Social recovery using CryptID email
|
||||
- No MetaMask/wallet app needed
|
||||
- True passwordless Web3
|
||||
|
||||
## Risks & Challenges
|
||||
- Complex implementation
|
||||
- Gas costs for P-256 verification (~100k gas)
|
||||
- Not all L2s support ERC-4337 yet
|
||||
- User education on new paradigm
|
||||
|
||||
## Providers to Evaluate
|
||||
- Pimlico (bundler + paymaster)
|
||||
- Alchemy Account Kit
|
||||
- Stackup
|
||||
- Biconomy
|
||||
|
||||
## References
|
||||
- ERC-4337 Spec: https://eips.ethereum.org/EIPS/eip-4337
|
||||
- Clave (P-256 wallet): https://getclave.io/
|
||||
- Daimo (P-256 wallet): https://daimo.com/
|
||||
- viem Account Abstraction: https://viem.sh/account-abstraction
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Research P-256 on-chain verification gas costs
|
||||
- [ ] #2 Evaluate existing P-256 wallet implementations (Clave, Daimo)
|
||||
- [ ] #3 Prototype UserOperation signing with CryptID keys
|
||||
- [ ] #4 Evaluate bundler/paymaster providers
|
||||
- [ ] #5 Document architecture proposal if viable
|
||||
- [ ] #6 Estimate implementation timeline and costs
|
||||
<!-- AC:END -->
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
---
|
||||
id: task-063
|
||||
title: Fix Obsidian vault storage overflow - store content in IndexedDB
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-01-22 20:03'
|
||||
updated_date: '2026-01-23 08:41'
|
||||
labels:
|
||||
- bug
|
||||
- obsidian
|
||||
- automerge
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
The Obsidian vault browser is storing full note content in Automerge, causing capacity overflow errors and localStorage quota exceeded errors. Need to:
|
||||
1. Store only metadata in Automerge (id, title, tags, links, paths)
|
||||
2. Store full content in IndexedDB separately
|
||||
3. Clear existing vault data from Automerge for privacy
|
||||
4. Load content on-demand when notes are opened
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implementation complete:
|
||||
- Created NoteContentStore (src/lib/noteContentStore.ts) for IndexedDB storage
|
||||
- Added light record types (ObsidianObsNoteMeta, FolderNodeMeta, ObsidianVaultRecordLight)
|
||||
- Modified saveVaultToAutomerge to save only metadata to Automerge, content to IndexedDB
|
||||
- Added clearVaultFromAutomerge function
|
||||
- Added on-demand content loading via loadNoteContentFromIDB
|
||||
- Added 'Clear from Sync' button to UI
|
||||
- TypeScript compiles cleanly, build succeeds
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1 +0,0 @@
|
|||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
114
dev-dist/sw.js
114
dev-dist/sw.js
|
|
@ -1,114 +0,0 @@
|
|||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-52f2a342'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "registerSW.js",
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.n708e9nairg"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
workbox.registerRoute(/^https?:\/\/.*\/api\/.*/i, new workbox.NetworkFirst({
|
||||
"cacheName": "api-cache",
|
||||
"networkTimeoutSeconds": 10,
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 86400
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({
|
||||
"cacheName": "google-fonts-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 31536000
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({
|
||||
"cacheName": "gstatic-fonts-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 31536000
|
||||
})]
|
||||
}), 'GET');
|
||||
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,31 +0,0 @@
|
|||
# Canvas Website - Dev Branch Deployment
|
||||
# Automatically deploys from `dev` branch for testing
|
||||
# Access at: staging.jeffemmett.com
|
||||
|
||||
services:
|
||||
canvas-dev:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_WORKER_ENV=staging
|
||||
container_name: canvas-dev
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
- "traefik.http.services.canvas-dev.loadbalancer.server.port=80"
|
||||
- "traefik.http.routers.canvas-dev.rule=Host(`staging.jeffemmett.com`)"
|
||||
- "traefik.http.routers.canvas-dev.entrypoints=web"
|
||||
- "traefik.http.routers.canvas-dev.service=canvas-dev"
|
||||
networks:
|
||||
- traefik-public
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Canvas Website Docker Compose
|
||||
# Production: jeffemmett.com, www.jeffemmett.com
|
||||
# Dev branch: staging.jeffemmett.com (separate container via docker-compose.dev.yml)
|
||||
# Staging: staging.jeffemmett.com
|
||||
|
||||
services:
|
||||
canvas-website:
|
||||
|
|
@ -8,18 +8,23 @@ services:
|
|||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_WORKER_ENV=production
|
||||
- VITE_TLDRAW_WORKER_URL=https://jeffemmett-canvas.jeffemmett.workers.dev
|
||||
# Add other build args from .env if needed
|
||||
container_name: canvas-website
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
# Single service definition (both routers use same backend)
|
||||
- "traefik.http.services.canvas.loadbalancer.server.port=80"
|
||||
# Production deployment (jeffemmett.com and www)
|
||||
- "traefik.http.routers.canvas-prod.rule=Host(`jeffemmett.com`) || Host(`www.jeffemmett.com`) || Host(`canvas.jeffemmett.com`)"
|
||||
- "traefik.http.routers.canvas-prod.rule=Host(`jeffemmett.com`) || Host(`www.jeffemmett.com`)"
|
||||
- "traefik.http.routers.canvas-prod.entrypoints=web"
|
||||
- "traefik.http.routers.canvas-prod.service=canvas"
|
||||
# Staging deployment (keep for testing)
|
||||
- "traefik.http.routers.canvas-staging.rule=Host(`staging.jeffemmett.com`)"
|
||||
- "traefik.http.routers.canvas-staging.entrypoints=web"
|
||||
- "traefik.http.routers.canvas-staging.service=canvas"
|
||||
networks:
|
||||
- traefik-public
|
||||
healthcheck:
|
||||
|
|
|
|||
20
index.html
20
index.html
|
|
@ -4,42 +4,32 @@
|
|||
<head>
|
||||
<title>Jeff Emmett</title>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍄</text></svg>" />
|
||||
<link rel="apple-touch-icon" href="/pwa-192x192.svg" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<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=*">
|
||||
<!-- Preconnect to critical origins for faster loading -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="dns-prefetch" href="https://jeffemmett-canvas.jeffemmett.workers.dev" />
|
||||
<link rel="dns-prefetch" href="https://jeffemmett-canvas-dev.jeffemmett.workers.dev" />
|
||||
<link rel="preconnect" href="https://jeffemmett-canvas.jeffemmett.workers.dev" crossorigin />
|
||||
<link rel="preconnect" href="https://jeffemmett-canvas-dev.jeffemmett.workers.dev" 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="Exploring mycoeconomics, token engineering, psilo-cybernetics, zero-knowledge local-first systems, and institutional neuroplasticity. Research at the intersection of regenerative systems, crypto commons, and emancipatory technology.">
|
||||
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="Exploring mycoeconomics, token engineering, psilo-cybernetics, zero-knowledge local-first systems, and institutional neuroplasticity. Research at the intersection of regenerative systems, crypto commons, and emancipatory technology.">
|
||||
<meta property="og:image" content="https://jeffemmett.com/og-image.jpg">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
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="Exploring mycoeconomics, token engineering, psilo-cybernetics, zero-knowledge local-first systems, and institutional neuroplasticity. Research at the intersection of regenerative systems, crypto commons, and emancipatory technology.">
|
||||
<meta name="twitter:image" content="https://jeffemmett.com/og-image.jpg">
|
||||
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>
|
||||
|
|
|
|||
48
nginx.conf
48
nginx.conf
|
|
@ -4,25 +4,12 @@ server {
|
|||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression (fallback for clients that don't support Brotli)
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/wasm
|
||||
application/octet-stream
|
||||
image/svg+xml
|
||||
font/woff2;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
|
||||
gzip_disable "MSIE [1-6]\.";
|
||||
|
||||
# Security headers
|
||||
|
|
@ -31,32 +18,7 @@ server {
|
|||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# NEVER cache index.html and service worker - always fetch fresh
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
location = /registerSW.js {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# Cache static assets with hashed filenames (immutable)
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
50
package.json
50
package.json
|
|
@ -11,23 +11,14 @@
|
|||
"dev:client": "vite --host 0.0.0.0 --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": "NODE_OPTIONS=\"--max-old-space-size=8192\" tsc && NODE_OPTIONS=\"--max-old-space-size=8192\" vite build",
|
||||
"build": "tsc && vite build",
|
||||
"build:worker": "wrangler build --config wrangler.dev.toml",
|
||||
"preview": "vite preview",
|
||||
"deploy": "NODE_OPTIONS=\"--max-old-space-size=8192\" tsc && NODE_OPTIONS=\"--max-old-space-size=8192\" vite build && wrangler deploy",
|
||||
"deploy:pages": "NODE_OPTIONS=\"--max-old-space-size=8192\" tsc && NODE_OPTIONS=\"--max-old-space-size=8192\" vite build",
|
||||
"deploy": "tsc && vite build && wrangler deploy",
|
||||
"deploy:pages": "tsc && vite build",
|
||||
"deploy:worker": "wrangler deploy",
|
||||
"deploy:worker:dev": "wrangler deploy --config wrangler.dev.toml",
|
||||
"types": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:worker": "vitest run --config vitest.worker.config.ts",
|
||||
"test:all": "vitest run && vitest run --config vitest.worker.config.ts && playwright test",
|
||||
"multmux:install": "npm install --workspaces",
|
||||
"multmux:build": "npm run build --workspace=@multmux/server --workspace=@multmux/cli",
|
||||
"multmux:dev:server": "npm run dev --workspace=@multmux/server",
|
||||
|
|
@ -44,13 +35,9 @@
|
|||
"@automerge/automerge-repo-react-hooks": "^2.2.0",
|
||||
"@automerge/automerge-repo-storage-indexeddb": "^2.5.0",
|
||||
"@chengsokdara/use-whisper": "^0.2.0",
|
||||
"@fal-ai/client": "^1.7.2",
|
||||
"@daily-co/daily-js": "^0.60.0",
|
||||
"@daily-co/daily-react": "^0.20.0",
|
||||
"@mdxeditor/editor": "^3.51.0",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@noble/secp256k1": "^3.0.0",
|
||||
"@react-three/drei": "^9.114.3",
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@tldraw/assets": "^3.15.4",
|
||||
"@tldraw/tldraw": "^3.15.4",
|
||||
"@tldraw/tlschema": "^3.15.4",
|
||||
|
|
@ -58,7 +45,6 @@
|
|||
"@types/markdown-it": "^14.1.1",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@uiw/react-md-editor": "^4.0.5",
|
||||
"@web3modal/wagmi": "^5.1.11",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
|
|
@ -69,7 +55,9 @@
|
|||
"d3": "^7.9.0",
|
||||
"fathom-typescript": "^0.0.36",
|
||||
"gray-matter": "^4.0.3",
|
||||
"gun": "^0.2020.1241",
|
||||
"h3-js": "^4.3.0",
|
||||
"holosphere": "^1.1.20",
|
||||
"html2canvas": "^1.4.1",
|
||||
"itty-router": "^5.0.17",
|
||||
"jotai": "^2.6.0",
|
||||
|
|
@ -79,7 +67,6 @@
|
|||
"marked": "^15.0.4",
|
||||
"one-webcrypto": "^1.0.3",
|
||||
"openai": "^4.79.3",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"rbush": "^4.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-cmdk": "^1.3.9",
|
||||
|
|
@ -88,42 +75,25 @@
|
|||
"react-router-dom": "^7.0.2",
|
||||
"recoil": "^0.7.7",
|
||||
"sharp": "^0.33.5",
|
||||
"three": "^0.168.0",
|
||||
"tldraw": "^3.15.4",
|
||||
"use-whisper": "^0.0.1",
|
||||
"viem": "^2.43.4",
|
||||
"wagmi": "^3.1.4",
|
||||
"webcola": "^3.4.0"
|
||||
"webcola": "^3.4.0",
|
||||
"webnative": "^0.36.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/types": "^6.0.0",
|
||||
"@cloudflare/vitest-pool-workers": "^0.11.0",
|
||||
"@cloudflare/workers-types": "^4.20240821.1",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^14.6.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",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"concurrently": "^9.1.0",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^27.0.1",
|
||||
"miniflare": "^4.20251213.0",
|
||||
"msw": "^2.12.4",
|
||||
"playwright": "^1.57.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vitest": "^4.0.16",
|
||||
"wrangler": "^4.63.0"
|
||||
"wrangler": "^4.33.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 60000, // Increase timeout for canvas loading
|
||||
expect: {
|
||||
timeout: 10000
|
||||
},
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 1, // Retry once locally too
|
||||
workers: process.env.CI ? 1 : 4,
|
||||
reporter: process.env.CI ? 'github' : 'html',
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
// Only run other browsers in CI with full browser install
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
],
|
||||
|
||||
// Run local dev server before starting tests
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
229
src/App.tsx
229
src/App.tsx
|
|
@ -4,18 +4,18 @@ 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 { Default } from "@/routes/Default";
|
||||
import { BrowserRouter, Route, Routes, Navigate, useParams } 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 { Dashboard } from "./routes/Dashboard";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||
|
||||
// Lazy load heavy route components for faster initial load
|
||||
const Default = lazy(() => import("@/routes/Default").then(m => ({ default: m.Default })));
|
||||
const Contact = lazy(() => import("@/routes/Contact").then(m => ({ default: m.Contact })));
|
||||
const Board = lazy(() => import("./routes/Board").then(m => ({ default: m.Board })));
|
||||
const Inbox = lazy(() => import("./routes/Inbox").then(m => ({ default: m.Inbox })));
|
||||
const Presentations = lazy(() => import("./routes/Presentations").then(m => ({ default: m.Presentations })));
|
||||
const Resilience = lazy(() => import("./routes/Resilience").then(m => ({ default: m.Resilience })));
|
||||
const Dashboard = lazy(() => import("./routes/Dashboard").then(m => ({ default: m.Dashboard })));
|
||||
import { DailyProvider } from "@daily-co/daily-react";
|
||||
import Daily from "@daily-co/daily-js";
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Import React Context providers
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
|
|
@ -28,41 +28,22 @@ import { ErrorBoundary } from './components/ErrorBoundary';
|
|||
import CryptID from './components/auth/CryptID';
|
||||
import CryptoDebug from './components/auth/CryptoDebug';
|
||||
|
||||
// Import Web3 provider for wallet integration
|
||||
import { Web3Provider } from './providers/Web3Provider';
|
||||
|
||||
// Import Google Data test component
|
||||
import { GoogleDataTest } from './components/GoogleDataTest';
|
||||
|
||||
// Loading skeleton for lazy-loaded routes
|
||||
const LoadingSpinner = () => (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
|
||||
color: '#fff',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
border: '3px solid rgba(255,255,255,0.1)',
|
||||
borderTopColor: '#4f46e5',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
<p style={{ marginTop: '16px', fontSize: '14px', opacity: 0.7 }}>Loading canvas...</p>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
// Initialize Daily.co call object with error handling
|
||||
let callObject: any = null;
|
||||
try {
|
||||
// Only create call object if we're in a secure context and mediaDevices is available
|
||||
if (typeof window !== 'undefined' &&
|
||||
window.location.protocol === 'https:' &&
|
||||
navigator.mediaDevices) {
|
||||
callObject = Daily.createCallObject();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Daily.co call object initialization failed:', error);
|
||||
// Continue without video chat functionality
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional Auth Route component
|
||||
|
|
@ -88,23 +69,13 @@ const OptionalAuthRoute = ({ children }: { children: React.ReactNode }) => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Component to redirect /board/:slug URLs to clean /:slug/ URLs
|
||||
* On non-canvas hostnames (e.g. jeffemmett.com), redirects to canvas.jeffemmett.com/:slug/
|
||||
* On canvas.jeffemmett.com, does a same-domain redirect to /:slug/
|
||||
* Component to redirect board URLs without trailing slashes
|
||||
*/
|
||||
const RedirectBoardSlug = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const canvasHost = 'canvas.jeffemmett.com';
|
||||
|
||||
if (window.location.hostname !== canvasHost && window.location.hostname !== 'localhost') {
|
||||
window.location.replace(`https://${canvasHost}/${slug}/`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Navigate to={`/${slug}/`} replace />;
|
||||
return <Navigate to={`/board/${slug}/`} replace />;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Main App with context providers
|
||||
*/
|
||||
|
|
@ -131,90 +102,76 @@ const AppWithProviders = () => {
|
|||
return (
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<Web3Provider>
|
||||
<FileSystemProvider>
|
||||
<NotificationProvider>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<BrowserRouter>
|
||||
{/* Display notifications */}
|
||||
<NotificationsDisplay />
|
||||
<FileSystemProvider>
|
||||
<NotificationProvider>
|
||||
<DailyProvider callObject={callObject}>
|
||||
<BrowserRouter>
|
||||
{/* Display notifications */}
|
||||
<NotificationsDisplay />
|
||||
|
||||
<Routes>
|
||||
{/* Redirect routes without trailing slashes to include them */}
|
||||
<Route path="/login" element={<Navigate to="/login/" replace />} />
|
||||
<Route path="/contact" element={<Navigate to="/contact/" replace />} />
|
||||
<Route path="/board/:slug" element={<RedirectBoardSlug />} />
|
||||
<Route path="/inbox" element={<Navigate to="/inbox/" replace />} />
|
||||
<Route path="/debug" element={<Navigate to="/debug/" replace />} />
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/" replace />} />
|
||||
<Route path="/presentations" element={<Navigate to="/presentations/" replace />} />
|
||||
<Route path="/presentations/resilience" element={<Navigate to="/presentations/resilience/" replace />} />
|
||||
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
{/* Redirect routes without trailing slashes to include them */}
|
||||
<Route path="/login" element={<Navigate to="/login/" replace />} />
|
||||
<Route path="/contact" element={<Navigate to="/contact/" replace />} />
|
||||
<Route path="/board/:slug" element={<RedirectBoardSlug />} />
|
||||
<Route path="/inbox" element={<Navigate to="/inbox/" replace />} />
|
||||
<Route path="/debug" element={<Navigate to="/debug/" replace />} />
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/" replace />} />
|
||||
<Route path="/presentations" element={<Navigate to="/presentations/" replace />} />
|
||||
<Route path="/presentations/resilience" element={<Navigate to="/presentations/resilience/" replace />} />
|
||||
{/* Auth routes */}
|
||||
<Route path="/login/" element={<AuthPage />} />
|
||||
|
||||
{/* Auth routes */}
|
||||
<Route path="/login/" element={<AuthPage />} />
|
||||
|
||||
{/* Optional auth routes - all lazy loaded */}
|
||||
<Route path="/" element={
|
||||
<OptionalAuthRoute>
|
||||
<Default />
|
||||
</OptionalAuthRoute>
|
||||
} />
|
||||
<Route path="/contact/" element={
|
||||
<OptionalAuthRoute>
|
||||
<Contact />
|
||||
</OptionalAuthRoute>
|
||||
} />
|
||||
<Route path="/board/:slug/" element={<RedirectBoardSlug />} />
|
||||
<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>
|
||||
} />
|
||||
{/* Google Data routes */}
|
||||
<Route path="/google" element={<GoogleDataTest />} />
|
||||
<Route path="/oauth/google/callback" element={<GoogleDataTest />} />
|
||||
|
||||
{/* Catch-all: Direct slug URLs serve board directly */}
|
||||
{/* e.g., canvas.jeffemmett.com/ccc → shows board "ccc" */}
|
||||
{/* Must be LAST to not interfere with other routes */}
|
||||
<Route path="/:slug" element={
|
||||
<OptionalAuthRoute>
|
||||
<Board />
|
||||
</OptionalAuthRoute>
|
||||
} />
|
||||
<Route path="/:slug/" element={
|
||||
<OptionalAuthRoute>
|
||||
<Board />
|
||||
</OptionalAuthRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</Suspense>
|
||||
</NotificationProvider>
|
||||
</FileSystemProvider>
|
||||
</Web3Provider>
|
||||
{/* 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>
|
||||
} />
|
||||
{/* Google Data routes */}
|
||||
<Route path="/google" element={<GoogleDataTest />} />
|
||||
<Route path="/oauth/google/callback" element={<GoogleDataTest />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</DailyProvider>
|
||||
</NotificationProvider>
|
||||
</FileSystemProvider>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -300,9 +300,11 @@ export function applyAutomergePatchesToTLStore(
|
|||
case "unmark":
|
||||
case "conflict": {
|
||||
// These actions are not currently supported for TLDraw
|
||||
console.log("Unsupported patch action:", patch.action)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
console.log("Unsupported patch:", patch)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -420,6 +422,7 @@ export function applyAutomergePatchesToTLStore(
|
|||
|
||||
// Filter out SharedPiano shapes since they're no longer supported
|
||||
if (record.typeName === 'shape' && (record as any).type === 'SharedPiano') {
|
||||
console.log(`⚠️ Filtering out deprecated SharedPiano shape: ${record.id}`)
|
||||
return // Skip - SharedPiano is deprecated
|
||||
}
|
||||
|
||||
|
|
@ -441,7 +444,24 @@ export function applyAutomergePatchesToTLStore(
|
|||
|
||||
// put / remove the records in the store
|
||||
// Log patch application for debugging
|
||||
console.log(`🔧 AutomergeToTLStore: Applying ${patches.length} patches, ${toPut.length} records to put, ${toRemove.length} records to remove`)
|
||||
|
||||
// DEBUG: Log shape updates being applied to store
|
||||
toPut.forEach(record => {
|
||||
if (record.typeName === 'shape' && (record as any).props?.w) {
|
||||
console.log(`🔧 AutomergeToTLStore: Putting shape ${(record as any).type} ${record.id}:`, {
|
||||
w: (record as any).props.w,
|
||||
h: (record as any).props.h,
|
||||
x: (record as any).x,
|
||||
y: (record as any).y
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (failedRecords.length > 0) {
|
||||
console.log({ patches, toPut: toPut.length, failed: failedRecords.length })
|
||||
}
|
||||
|
||||
if (failedRecords.length > 0) {
|
||||
console.error("Failed to sanitize records:", failedRecords)
|
||||
}
|
||||
|
|
@ -675,12 +695,14 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
|
||||
// Normalize the shape type if it's a custom type with incorrect case
|
||||
if (sanitized.type && typeof sanitized.type === 'string' && customShapeTypeMap[sanitized.type]) {
|
||||
console.log(`🔧 Normalizing shape type: "${sanitized.type}" → "${customShapeTypeMap[sanitized.type]}"`)
|
||||
sanitized.type = customShapeTypeMap[sanitized.type]
|
||||
}
|
||||
|
||||
// CRITICAL: Sanitize Multmux shapes AFTER case normalization - ensure all required props exist
|
||||
// Old shapes may have wsUrl (removed) or undefined values
|
||||
if (sanitized.type === 'Multmux') {
|
||||
console.log(`🔧 Sanitizing Multmux shape ${sanitized.id}:`, JSON.stringify(sanitized.props))
|
||||
// Remove deprecated wsUrl prop
|
||||
if ('wsUrl' in sanitized.props) {
|
||||
delete sanitized.props.wsUrl
|
||||
|
|
@ -740,78 +762,7 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
}
|
||||
|
||||
sanitized.props = cleanProps
|
||||
}
|
||||
|
||||
// CRITICAL: Sanitize Map shapes - ensure all required props have defaults
|
||||
// Old shapes may be missing pinnedToView, isMinimized, or other newer properties
|
||||
if (sanitized.type === 'Map') {
|
||||
// Ensure boolean props have proper defaults (old data may have undefined)
|
||||
if (typeof sanitized.props.pinnedToView !== 'boolean') {
|
||||
sanitized.props.pinnedToView = false
|
||||
}
|
||||
if (typeof sanitized.props.isMinimized !== 'boolean') {
|
||||
sanitized.props.isMinimized = false
|
||||
}
|
||||
if (typeof sanitized.props.showSidebar !== 'boolean') {
|
||||
sanitized.props.showSidebar = true
|
||||
}
|
||||
if (typeof sanitized.props.interactive !== 'boolean') {
|
||||
sanitized.props.interactive = true
|
||||
}
|
||||
if (typeof sanitized.props.showGPS !== 'boolean') {
|
||||
sanitized.props.showGPS = false
|
||||
}
|
||||
if (typeof sanitized.props.showSearch !== 'boolean') {
|
||||
sanitized.props.showSearch = false
|
||||
}
|
||||
if (typeof sanitized.props.showDirections !== 'boolean') {
|
||||
sanitized.props.showDirections = false
|
||||
}
|
||||
if (typeof sanitized.props.sharingLocation !== 'boolean') {
|
||||
sanitized.props.sharingLocation = false
|
||||
}
|
||||
// Ensure array props exist
|
||||
if (!Array.isArray(sanitized.props.annotations)) {
|
||||
sanitized.props.annotations = []
|
||||
}
|
||||
if (!Array.isArray(sanitized.props.waypoints)) {
|
||||
sanitized.props.waypoints = []
|
||||
}
|
||||
if (!Array.isArray(sanitized.props.collaborators)) {
|
||||
sanitized.props.collaborators = []
|
||||
}
|
||||
if (!Array.isArray(sanitized.props.gpsUsers)) {
|
||||
sanitized.props.gpsUsers = []
|
||||
}
|
||||
if (!Array.isArray(sanitized.props.tags)) {
|
||||
sanitized.props.tags = ['map']
|
||||
}
|
||||
// Ensure string props exist
|
||||
if (typeof sanitized.props.styleKey !== 'string') {
|
||||
sanitized.props.styleKey = 'voyager'
|
||||
}
|
||||
if (typeof sanitized.props.title !== 'string') {
|
||||
sanitized.props.title = 'Collaborative Map'
|
||||
}
|
||||
if (typeof sanitized.props.description !== 'string') {
|
||||
sanitized.props.description = ''
|
||||
}
|
||||
// Ensure viewport exists with defaults
|
||||
if (!sanitized.props.viewport || typeof sanitized.props.viewport !== 'object') {
|
||||
sanitized.props.viewport = {
|
||||
center: { lat: 40.7128, lng: -74.006 },
|
||||
zoom: 12,
|
||||
bearing: 0,
|
||||
pitch: 0,
|
||||
}
|
||||
}
|
||||
// Ensure numeric props
|
||||
if (typeof sanitized.props.w !== 'number' || isNaN(sanitized.props.w)) {
|
||||
sanitized.props.w = 800
|
||||
}
|
||||
if (typeof sanitized.props.h !== 'number' || isNaN(sanitized.props.h)) {
|
||||
sanitized.props.h = 550
|
||||
}
|
||||
console.log(`🔧 Sanitized Multmux shape ${sanitized.id} props:`, JSON.stringify(sanitized.props))
|
||||
}
|
||||
|
||||
// CRITICAL: Infer type from properties BEFORE defaulting to 'geo'
|
||||
|
|
@ -1159,37 +1110,6 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
|
||||
// CRITICAL: Fix richText structure for text shapes - REQUIRED field
|
||||
if (sanitized.type === 'text') {
|
||||
// CRITICAL: Convert props.text to props.richText for text shapes (fixes sync issue)
|
||||
// Text shapes may arrive from other clients with props.text instead of props.richText
|
||||
// We must convert BEFORE initializing richText to empty, otherwise content is lost
|
||||
if ('text' in sanitized.props && typeof sanitized.props.text === 'string' && sanitized.props.text.trim()) {
|
||||
const textContent = sanitized.props.text
|
||||
// Only use text content if richText is missing or empty
|
||||
const hasRichTextContent = sanitized.props.richText &&
|
||||
typeof sanitized.props.richText === 'object' &&
|
||||
sanitized.props.richText.content &&
|
||||
Array.isArray(sanitized.props.richText.content) &&
|
||||
sanitized.props.richText.content.length > 0
|
||||
|
||||
if (!hasRichTextContent) {
|
||||
// Convert text string to richText format for tldraw
|
||||
sanitized.props.richText = {
|
||||
type: 'doc',
|
||||
content: [{
|
||||
type: 'paragraph',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: textContent
|
||||
}]
|
||||
}]
|
||||
}
|
||||
console.log(`🔧 AutomergeToTLStore: Converted props.text to richText for text shape ${sanitized.id}`)
|
||||
}
|
||||
// Preserve original text in meta for backward compatibility
|
||||
if (!sanitized.meta) sanitized.meta = {}
|
||||
sanitized.meta.text = textContent
|
||||
}
|
||||
|
||||
// Text shapes MUST have props.richText as an object - initialize if missing
|
||||
if (!sanitized.props.richText || typeof sanitized.props.richText !== 'object' || sanitized.props.richText === null) {
|
||||
sanitized.props.richText = { content: [], type: 'doc' }
|
||||
|
|
|
|||
|
|
@ -23,16 +23,20 @@ export class CloudflareAdapter {
|
|||
|
||||
async getHandle(roomId: string): Promise<DocHandle<TLStoreSnapshot>> {
|
||||
if (!this.handles.has(roomId)) {
|
||||
console.log(`Creating new Automerge handle for room ${roomId}`)
|
||||
const handle = this.repo.create<TLStoreSnapshot>()
|
||||
|
||||
|
||||
// Initialize with default store if this is a new document
|
||||
handle.change((doc) => {
|
||||
if (!doc.store) {
|
||||
console.log("Initializing new document with default store")
|
||||
init(doc)
|
||||
}
|
||||
})
|
||||
|
||||
this.handles.set(roomId, handle)
|
||||
} else {
|
||||
console.log(`Reusing existing Automerge handle for room ${roomId}`)
|
||||
}
|
||||
|
||||
return this.handles.get(roomId)!
|
||||
|
|
@ -68,11 +72,13 @@ export class CloudflareAdapter {
|
|||
async saveToCloudflare(roomId: string): Promise<void> {
|
||||
const handle = this.handles.get(roomId)
|
||||
if (!handle) {
|
||||
console.log(`No handle found for room ${roomId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const doc = handle.doc()
|
||||
if (!doc) {
|
||||
console.log(`No document found for room ${roomId}`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +114,7 @@ export class CloudflareAdapter {
|
|||
|
||||
async loadFromCloudflare(roomId: string): Promise<TLStoreSnapshot | null> {
|
||||
try {
|
||||
|
||||
// Add retry logic for connection issues
|
||||
let response: Response;
|
||||
let retries = 3;
|
||||
|
|
@ -124,7 +131,7 @@ export class CloudflareAdapter {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!response!.ok) {
|
||||
if (response!.status === 404) {
|
||||
return null // Room doesn't exist yet
|
||||
|
|
@ -134,7 +141,12 @@ export class CloudflareAdapter {
|
|||
}
|
||||
|
||||
const doc = await response!.json() as TLStoreSnapshot
|
||||
|
||||
console.log(`Successfully loaded document from Cloudflare for room ${roomId}:`, {
|
||||
hasStore: !!doc.store,
|
||||
storeKeys: doc.store ? Object.keys(doc.store).length : 0
|
||||
})
|
||||
|
||||
|
||||
// Initialize the last persisted state with the loaded document
|
||||
if (doc) {
|
||||
const docHash = this.generateDocHash(doc)
|
||||
|
|
@ -170,7 +182,6 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
private isConnecting: boolean = false
|
||||
private onJsonSyncData?: (data: any) => void
|
||||
private onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => void
|
||||
private onPresenceLeave?: (sessionId: string) => void
|
||||
|
||||
// Binary sync mode - when true, uses native Automerge sync protocol
|
||||
private useBinarySync: boolean = true
|
||||
|
|
@ -190,6 +201,7 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
|
||||
private setConnectionState(state: ConnectionState): void {
|
||||
if (this._connectionState !== state) {
|
||||
console.log(`🔌 Connection state: ${this._connectionState} → ${state}`)
|
||||
this._connectionState = state
|
||||
this.connectionStateListeners.forEach(listener => listener(state))
|
||||
}
|
||||
|
|
@ -209,21 +221,20 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
workerUrl: string,
|
||||
roomId?: string,
|
||||
onJsonSyncData?: (data: any) => void,
|
||||
onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => void,
|
||||
onPresenceLeave?: (sessionId: string) => void
|
||||
onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => void
|
||||
) {
|
||||
super()
|
||||
this.workerUrl = workerUrl
|
||||
this.roomId = roomId || 'default-room'
|
||||
this.onJsonSyncData = onJsonSyncData
|
||||
this.onPresenceUpdate = onPresenceUpdate
|
||||
this.onPresenceLeave = onPresenceLeave
|
||||
this.readyPromise = new Promise((resolve) => {
|
||||
this.readyResolve = resolve
|
||||
})
|
||||
|
||||
// Set up network online/offline listeners
|
||||
this.networkOnlineHandler = () => {
|
||||
console.log('🌐 Network: online')
|
||||
this._isNetworkOnline = true
|
||||
// Trigger reconnect if we were disconnected
|
||||
if (this._connectionState === 'disconnected' && this.peerId) {
|
||||
|
|
@ -232,6 +243,7 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
}
|
||||
}
|
||||
this.networkOfflineHandler = () => {
|
||||
console.log('🌐 Network: offline')
|
||||
this._isNetworkOnline = false
|
||||
if (this._connectionState === 'connected') {
|
||||
this.setConnectionState('disconnected')
|
||||
|
|
@ -258,11 +270,12 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
* @param documentId The Automerge document ID to use for incoming messages
|
||||
*/
|
||||
setDocumentId(documentId: string): void {
|
||||
const previousDocId = this.currentDocumentId
|
||||
console.log('📋 CloudflareAdapter: Setting documentId:', documentId)
|
||||
this.currentDocumentId = documentId
|
||||
|
||||
// Process any buffered binary messages now that we have a documentId
|
||||
if (this.pendingBinaryMessages.length > 0) {
|
||||
console.log(`📦 CloudflareAdapter: Processing ${this.pendingBinaryMessages.length} buffered binary messages`)
|
||||
const bufferedMessages = this.pendingBinaryMessages
|
||||
this.pendingBinaryMessages = []
|
||||
|
||||
|
|
@ -274,20 +287,10 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
targetId: this.peerId || ('unknown' as PeerId),
|
||||
documentId: this.currentDocumentId as any
|
||||
}
|
||||
console.log('📥 CloudflareAdapter: Emitting buffered sync message with documentId:', this.currentDocumentId, 'size:', binaryData.byteLength)
|
||||
this.emit('message', message)
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Re-emit peer-candidate now that we have a documentId
|
||||
// This triggers the Repo to sync this document with the server peer
|
||||
// Without this, the Repo may have connected before the document was created
|
||||
// and won't know to sync the document with the peer
|
||||
if (this.serverPeerId && this.websocket?.readyState === WebSocket.OPEN && !previousDocId) {
|
||||
this.emit('peer-candidate', {
|
||||
peerId: this.serverPeerId,
|
||||
peerMetadata: { storageId: undefined, isEphemeral: false }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -299,6 +302,7 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
|
||||
connect(peerId: PeerId, peerMetadata?: PeerMetadata): void {
|
||||
if (this.isConnecting) {
|
||||
console.log('🔌 CloudflareAdapter: Connection already in progress, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -322,26 +326,33 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
const wsUrl = `${protocol}${baseUrl}/connect/${this.roomId}?sessionId=${sessionId}`
|
||||
|
||||
this.isConnecting = true
|
||||
|
||||
|
||||
// Add a small delay to ensure the server is ready
|
||||
setTimeout(() => {
|
||||
try {
|
||||
console.log('🔌 CloudflareAdapter: Creating WebSocket connection to:', wsUrl)
|
||||
this.websocket = new WebSocket(wsUrl)
|
||||
|
||||
this.websocket.onopen = () => {
|
||||
console.log('🔌 CloudflareAdapter: WebSocket connection opened successfully')
|
||||
this.isConnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
this.setConnectionState('connected')
|
||||
this.readyResolve?.()
|
||||
this.startKeepAlive()
|
||||
|
||||
// Emit 'ready' event for Automerge Repo
|
||||
;(this as any).emit('ready', { network: this })
|
||||
// CRITICAL: Emit 'ready' event for Automerge Repo
|
||||
// This tells the Repo that the network adapter is ready to sync
|
||||
// @ts-expect-error - 'ready' event is valid but not in NetworkAdapterEvents type
|
||||
this.emit('ready', { network: this })
|
||||
|
||||
// Create a server peer ID based on the room
|
||||
// The server acts as a "hub" peer that all clients sync with
|
||||
this.serverPeerId = `server-${this.roomId}` as PeerId
|
||||
|
||||
// Emit 'peer-candidate' to announce the server as a sync peer
|
||||
// CRITICAL: Emit 'peer-candidate' to announce the server as a sync peer
|
||||
// This tells the Automerge Repo there's a peer to sync documents with
|
||||
console.log('🔌 CloudflareAdapter: Announcing server peer for Automerge sync:', this.serverPeerId)
|
||||
this.emit('peer-candidate', {
|
||||
peerId: this.serverPeerId,
|
||||
peerMetadata: { storageId: undefined, isEphemeral: false }
|
||||
|
|
@ -353,8 +364,16 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
// Automerge's native protocol uses binary messages
|
||||
// We need to handle both binary and text messages
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
console.log('🔌 CloudflareAdapter: Received binary message (Automerge protocol)', event.data.byteLength, 'bytes')
|
||||
// Handle binary Automerge sync messages - convert ArrayBuffer to Uint8Array
|
||||
// Automerge Repo expects binary sync messages as Uint8Array
|
||||
// CRITICAL: senderId should be the SERVER (where the message came from)
|
||||
// targetId should be US (where the message is going to)
|
||||
// CRITICAL: Include documentId for Automerge Repo to route the message correctly
|
||||
const binaryData = new Uint8Array(event.data)
|
||||
if (!this.currentDocumentId) {
|
||||
console.log('📦 CloudflareAdapter: Buffering binary sync message (no documentId yet), size:', binaryData.byteLength)
|
||||
// Buffer for later processing when we have a documentId
|
||||
this.pendingBinaryMessages.push(binaryData)
|
||||
return
|
||||
}
|
||||
|
|
@ -363,13 +382,17 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
data: binaryData,
|
||||
senderId: this.serverPeerId || ('server' as PeerId),
|
||||
targetId: this.peerId || ('unknown' as PeerId),
|
||||
documentId: this.currentDocumentId as any
|
||||
documentId: this.currentDocumentId as any // DocumentId type
|
||||
}
|
||||
console.log('📥 CloudflareAdapter: Emitting sync message with documentId:', this.currentDocumentId)
|
||||
this.emit('message', message)
|
||||
} else if (event.data instanceof Blob) {
|
||||
// Handle Blob messages (convert to Uint8Array)
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
console.log('🔌 CloudflareAdapter: Received Blob message, converted to Uint8Array', buffer.byteLength, 'bytes')
|
||||
const binaryData = new Uint8Array(buffer)
|
||||
if (!this.currentDocumentId) {
|
||||
console.log('📦 CloudflareAdapter: Buffering Blob sync message (no documentId yet), size:', binaryData.byteLength)
|
||||
this.pendingBinaryMessages.push(binaryData)
|
||||
return
|
||||
}
|
||||
|
|
@ -380,12 +403,18 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
targetId: this.peerId || ('unknown' as PeerId),
|
||||
documentId: this.currentDocumentId as any
|
||||
}
|
||||
console.log('📥 CloudflareAdapter: Emitting Blob sync message with documentId:', this.currentDocumentId)
|
||||
this.emit('message', message)
|
||||
})
|
||||
} else {
|
||||
// Handle text messages (our custom protocol for backward compatibility)
|
||||
const message = JSON.parse(event.data)
|
||||
|
||||
// Only log non-presence messages to reduce console spam
|
||||
if (message.type !== 'presence' && message.type !== 'pong') {
|
||||
console.log('🔌 CloudflareAdapter: Received WebSocket message:', message.type)
|
||||
}
|
||||
|
||||
// Handle ping/pong messages for keep-alive
|
||||
if (message.type === 'ping') {
|
||||
this.sendPong()
|
||||
|
|
@ -394,44 +423,55 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
|
||||
// Handle test messages
|
||||
if (message.type === 'test') {
|
||||
console.log('🔌 CloudflareAdapter: Received test message:', message.message)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle presence updates from other clients
|
||||
if (message.type === 'presence') {
|
||||
// Pass senderId, userName, and userColor so we can create proper instance_presence records
|
||||
if (this.onPresenceUpdate && message.userId && message.data) {
|
||||
this.onPresenceUpdate(message.userId, message.data, message.senderId, message.userName, message.userColor)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle leave messages (user disconnected)
|
||||
if (message.type === 'leave') {
|
||||
if (this.onPresenceLeave && message.sessionId) {
|
||||
this.onPresenceLeave(message.sessionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Convert the message to the format expected by Automerge
|
||||
if (message.type === 'sync' && message.data) {
|
||||
console.log('🔌 CloudflareAdapter: Received sync message with data:', {
|
||||
hasStore: !!message.data.store,
|
||||
storeKeys: message.data.store ? Object.keys(message.data.store).length : 0,
|
||||
documentId: message.documentId,
|
||||
documentIdType: typeof message.documentId
|
||||
})
|
||||
|
||||
// JSON sync for real-time collaboration
|
||||
// When we receive TLDraw changes from other clients, apply them locally
|
||||
const isJsonDocumentData = message.data && typeof message.data === 'object' && message.data.store
|
||||
|
||||
if (isJsonDocumentData) {
|
||||
console.log('📥 CloudflareAdapter: Received JSON sync message with store data')
|
||||
|
||||
// Call the JSON sync callback to apply changes
|
||||
if (this.onJsonSyncData) {
|
||||
this.onJsonSyncData(message.data)
|
||||
} else {
|
||||
console.warn('⚠️ No JSON sync callback registered')
|
||||
}
|
||||
return
|
||||
return // JSON sync handled
|
||||
}
|
||||
|
||||
// Validate documentId format
|
||||
const isValidDocumentId = message.documentId &&
|
||||
(typeof message.documentId === 'string' &&
|
||||
(message.documentId.startsWith('automerge:') ||
|
||||
message.documentId.includes(':') ||
|
||||
/^[a-f0-9-]{36,}$/i.test(message.documentId)))
|
||||
|
||||
|
||||
// Validate documentId - Automerge requires a valid Automerge URL format
|
||||
// Valid formats: "automerge:xxxxx" or other valid URL formats
|
||||
// Invalid: plain strings like "default", "default-room", etc.
|
||||
const isValidDocumentId = message.documentId &&
|
||||
(typeof message.documentId === 'string' &&
|
||||
(message.documentId.startsWith('automerge:') ||
|
||||
message.documentId.includes(':') ||
|
||||
/^[a-f0-9-]{36,}$/i.test(message.documentId))) // UUID-like format
|
||||
|
||||
// For binary sync messages, use Automerge's sync protocol
|
||||
// Only include documentId if it's a valid Automerge document ID format
|
||||
const syncMessage: Message = {
|
||||
type: 'sync',
|
||||
senderId: message.senderId || this.peerId || ('unknown' as PeerId),
|
||||
|
|
@ -439,22 +479,42 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
data: message.data,
|
||||
...(isValidDocumentId && { documentId: message.documentId })
|
||||
}
|
||||
|
||||
|
||||
if (message.documentId && !isValidDocumentId) {
|
||||
console.warn('⚠️ CloudflareAdapter: Ignoring invalid documentId from server:', message.documentId)
|
||||
}
|
||||
|
||||
this.emit('message', syncMessage)
|
||||
} else if (message.senderId && message.targetId) {
|
||||
this.emit('message', message as Message)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error)
|
||||
console.error('❌ CloudflareAdapter: Error parsing WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.websocket.onclose = (event) => {
|
||||
console.log('Disconnected from Cloudflare WebSocket', {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean,
|
||||
url: wsUrl,
|
||||
reconnectAttempts: this.reconnectAttempts
|
||||
})
|
||||
|
||||
this.isConnecting = false
|
||||
this.stopKeepAlive()
|
||||
|
||||
if (event.code === 1000) {
|
||||
// Log specific error codes for debugging
|
||||
if (event.code === 1005) {
|
||||
console.error('❌ WebSocket closed with code 1005 (No Status Received) - this usually indicates a connection issue or idle timeout')
|
||||
} else if (event.code === 1006) {
|
||||
console.error('❌ WebSocket closed with code 1006 (Abnormal Closure) - connection was lost unexpectedly')
|
||||
} else if (event.code === 1011) {
|
||||
console.error('❌ WebSocket closed with code 1011 (Server Error) - server encountered an error')
|
||||
} else if (event.code === 1000) {
|
||||
console.log('✅ WebSocket closed normally (code 1000)')
|
||||
this.setConnectionState('disconnected')
|
||||
return // Don't reconnect on normal closure
|
||||
}
|
||||
|
|
@ -472,7 +532,15 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
this.scheduleReconnect(peerId, peerMetadata)
|
||||
}
|
||||
|
||||
this.websocket.onerror = () => {
|
||||
this.websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
console.error('WebSocket readyState:', this.websocket?.readyState)
|
||||
console.error('WebSocket URL:', wsUrl)
|
||||
console.error('Error event details:', {
|
||||
type: error.type,
|
||||
target: error.target,
|
||||
isTrusted: error.isTrusted
|
||||
})
|
||||
this.isConnecting = false
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -484,10 +552,25 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
}
|
||||
|
||||
send(message: Message): void {
|
||||
// Capture documentId from outgoing sync messages
|
||||
// Only log non-presence messages to reduce console spam
|
||||
if (message.type !== 'presence') {
|
||||
console.log('📤 CloudflareAdapter.send() called:', {
|
||||
messageType: message.type,
|
||||
dataType: (message as any).data?.constructor?.name || typeof (message as any).data,
|
||||
dataLength: (message as any).data?.byteLength || (message as any).data?.length,
|
||||
documentId: (message as any).documentId,
|
||||
hasTargetId: !!message.targetId,
|
||||
hasSenderId: !!message.senderId,
|
||||
useBinarySync: this.useBinarySync
|
||||
})
|
||||
}
|
||||
|
||||
// CRITICAL: Capture documentId from outgoing sync messages
|
||||
// This allows us to use it for incoming messages from the server
|
||||
if (message.type === 'sync' && (message as any).documentId) {
|
||||
const docId = (message as any).documentId
|
||||
if (this.currentDocumentId !== docId) {
|
||||
console.log('📋 CloudflareAdapter: Captured documentId from outgoing sync:', docId)
|
||||
this.currentDocumentId = docId
|
||||
}
|
||||
}
|
||||
|
|
@ -495,14 +578,49 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||
// Check if this is a binary sync message from Automerge Repo
|
||||
if (message.type === 'sync' && (message as any).data instanceof ArrayBuffer) {
|
||||
console.log('📤 CloudflareAdapter: Sending binary sync message (Automerge protocol)', {
|
||||
dataLength: (message as any).data.byteLength,
|
||||
documentId: (message as any).documentId,
|
||||
targetId: message.targetId
|
||||
})
|
||||
// Send binary data directly for Automerge's native sync protocol
|
||||
this.websocket.send((message as any).data)
|
||||
return
|
||||
return // CRITICAL: Don't fall through to JSON send
|
||||
} else if (message.type === 'sync' && (message as any).data instanceof Uint8Array) {
|
||||
console.log('📤 CloudflareAdapter: Sending Uint8Array sync message (Automerge protocol)', {
|
||||
dataLength: (message as any).data.length,
|
||||
documentId: (message as any).documentId,
|
||||
targetId: message.targetId
|
||||
})
|
||||
// Send Uint8Array directly - WebSocket accepts Uint8Array
|
||||
this.websocket.send((message as any).data)
|
||||
return
|
||||
return // CRITICAL: Don't fall through to JSON send
|
||||
} else {
|
||||
// Handle text-based messages (backward compatibility and control messages)
|
||||
// Only log non-presence messages
|
||||
if (message.type !== 'presence') {
|
||||
console.log('📤 Sending WebSocket message:', message.type)
|
||||
}
|
||||
// Debug: Log patch content if it's a patch message
|
||||
if (message.type === 'patch' && (message as any).patches) {
|
||||
console.log('🔍 Sending patches:', (message as any).patches.length, 'patches')
|
||||
;(message as any).patches.forEach((patch: any, index: number) => {
|
||||
console.log(` Patch ${index}:`, {
|
||||
action: patch.action,
|
||||
path: patch.path,
|
||||
value: patch.value ? (typeof patch.value === 'object' ? 'object' : patch.value) : 'undefined'
|
||||
})
|
||||
})
|
||||
}
|
||||
this.websocket.send(JSON.stringify(message))
|
||||
}
|
||||
} else {
|
||||
if (message.type !== 'presence') {
|
||||
console.warn('⚠️ CloudflareAdapter: Cannot send message - WebSocket not open', {
|
||||
messageType: message.type,
|
||||
readyState: this.websocket?.readyState
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -530,19 +648,8 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
private cleanup(): void {
|
||||
this.stopKeepAlive()
|
||||
this.clearReconnectTimeout()
|
||||
|
||||
|
||||
if (this.websocket) {
|
||||
// Send leave message before closing to notify other clients
|
||||
if (this.websocket.readyState === WebSocket.OPEN && this.sessionId) {
|
||||
try {
|
||||
this.websocket.send(JSON.stringify({
|
||||
type: 'leave',
|
||||
sessionId: this.sessionId
|
||||
}))
|
||||
} catch (e) {
|
||||
// Ignore errors when sending leave message
|
||||
}
|
||||
}
|
||||
this.websocket.close(1000, 'Client disconnecting')
|
||||
this.websocket = null
|
||||
}
|
||||
|
|
@ -552,12 +659,13 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
// Send ping every 30 seconds to prevent idle timeout
|
||||
this.keepAliveInterval = setInterval(() => {
|
||||
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||
console.log('🔌 CloudflareAdapter: Sending keep-alive ping')
|
||||
this.websocket.send(JSON.stringify({
|
||||
type: 'ping',
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
}
|
||||
}, 30000)
|
||||
}, 30000) // 30 seconds
|
||||
}
|
||||
|
||||
private stopKeepAlive(): void {
|
||||
|
|
@ -578,14 +686,18 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
|
||||
private scheduleReconnect(peerId: PeerId, peerMetadata?: PeerMetadata): void {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('❌ CloudflareAdapter: Max reconnection attempts reached, giving up')
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000)
|
||||
|
||||
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000) // Max 30 seconds
|
||||
|
||||
console.log(`🔄 CloudflareAdapter: Scheduling reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`)
|
||||
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
if (this.roomId) {
|
||||
console.log(`🔄 CloudflareAdapter: Attempting reconnect ${this.reconnectAttempts}/${this.maxReconnectAttempts}`)
|
||||
this.connect(peerId, peerMetadata)
|
||||
}
|
||||
}, delay)
|
||||
|
|
|
|||
|
|
@ -462,6 +462,49 @@ export function applyTLStoreChangesToAutomerge(
|
|||
originalX = (record as any).x
|
||||
originalY = (record as any).y
|
||||
}
|
||||
// DEBUG: Log richText, meta.text, and Obsidian note properties before sanitization
|
||||
if (record.typeName === 'shape') {
|
||||
if (record.type === 'geo' && (record.props as any)?.richText) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Geo shape ${record.id} has richText before sanitization:`, {
|
||||
hasRichText: !!(record.props as any).richText,
|
||||
richTextType: typeof (record.props as any).richText,
|
||||
richTextContent: Array.isArray((record.props as any).richText) ? 'array' : (record.props as any).richText?.content ? 'object with content' : 'object without content'
|
||||
})
|
||||
}
|
||||
if (record.type === 'geo' && (record.meta as any)?.text !== undefined) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Geo shape ${record.id} has meta.text before sanitization:`, {
|
||||
hasMetaText: !!(record.meta as any).text,
|
||||
metaTextValue: (record.meta as any).text,
|
||||
metaTextType: typeof (record.meta as any).text
|
||||
})
|
||||
}
|
||||
if (record.type === 'note' && (record.props as any)?.richText) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Note shape ${record.id} has richText before sanitization:`, {
|
||||
hasRichText: !!(record.props as any).richText,
|
||||
richTextType: typeof (record.props as any).richText,
|
||||
richTextContent: Array.isArray((record.props as any).richText) ? 'array' : (record.props as any).richText?.content ? 'object with content' : 'object without content',
|
||||
richTextContentLength: Array.isArray((record.props as any).richText?.content) ? (record.props as any).richText.content.length : 'not array'
|
||||
})
|
||||
}
|
||||
if (record.type === 'arrow' && (record.props as any)?.text !== undefined) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Arrow shape ${record.id} has text before sanitization:`, {
|
||||
hasText: !!(record.props as any).text,
|
||||
textValue: (record.props as any).text,
|
||||
textType: typeof (record.props as any).text
|
||||
})
|
||||
}
|
||||
if (record.type === 'ObsNote') {
|
||||
console.log(`🔍 TLStoreToAutomerge: ObsNote shape ${record.id} before sanitization:`, {
|
||||
hasTitle: !!(record.props as any).title,
|
||||
hasContent: !!(record.props as any).content,
|
||||
hasTags: Array.isArray((record.props as any).tags),
|
||||
title: (record.props as any).title,
|
||||
contentLength: (record.props as any).content?.length || 0,
|
||||
tagsCount: Array.isArray((record.props as any).tags) ? (record.props as any).tags.length : 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedRecord = sanitizeRecord(record)
|
||||
|
||||
// CRITICAL: Restore original coordinates if they were valid
|
||||
|
|
@ -475,11 +518,99 @@ export function applyTLStoreChangesToAutomerge(
|
|||
}
|
||||
}
|
||||
|
||||
// DEBUG: Log richText, meta.text, and Obsidian note properties after sanitization
|
||||
if (sanitizedRecord.typeName === 'shape') {
|
||||
if (sanitizedRecord.type === 'geo' && (sanitizedRecord.props as any)?.richText) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Geo shape ${sanitizedRecord.id} has richText after sanitization:`, {
|
||||
hasRichText: !!(sanitizedRecord.props as any).richText,
|
||||
richTextType: typeof (sanitizedRecord.props as any).richText,
|
||||
richTextContent: Array.isArray((sanitizedRecord.props as any).richText) ? 'array' : (sanitizedRecord.props as any).richText?.content ? 'object with content' : 'object without content'
|
||||
})
|
||||
}
|
||||
if (sanitizedRecord.type === 'geo' && (sanitizedRecord.meta as any)?.text !== undefined) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Geo shape ${sanitizedRecord.id} has meta.text after sanitization:`, {
|
||||
hasMetaText: !!(sanitizedRecord.meta as any).text,
|
||||
metaTextValue: (sanitizedRecord.meta as any).text,
|
||||
metaTextType: typeof (sanitizedRecord.meta as any).text
|
||||
})
|
||||
}
|
||||
if (sanitizedRecord.type === 'note' && (sanitizedRecord.props as any)?.richText) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Note shape ${sanitizedRecord.id} has richText after sanitization:`, {
|
||||
hasRichText: !!(sanitizedRecord.props as any).richText,
|
||||
richTextType: typeof (sanitizedRecord.props as any).richText,
|
||||
richTextContent: Array.isArray((sanitizedRecord.props as any).richText) ? 'array' : (sanitizedRecord.props as any).richText?.content ? 'object with content' : 'object without content',
|
||||
richTextContentLength: Array.isArray((sanitizedRecord.props as any).richText?.content) ? (sanitizedRecord.props as any).richText.content.length : 'not array'
|
||||
})
|
||||
}
|
||||
if (sanitizedRecord.type === 'arrow' && (sanitizedRecord.props as any)?.text !== undefined) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Arrow shape ${sanitizedRecord.id} has text after sanitization:`, {
|
||||
hasText: !!(sanitizedRecord.props as any).text,
|
||||
textValue: (sanitizedRecord.props as any).text,
|
||||
textType: typeof (sanitizedRecord.props as any).text
|
||||
})
|
||||
}
|
||||
if (sanitizedRecord.type === 'ObsNote') {
|
||||
console.log(`🔍 TLStoreToAutomerge: ObsNote shape ${sanitizedRecord.id} after sanitization:`, {
|
||||
hasTitle: !!(sanitizedRecord.props as any).title,
|
||||
hasContent: !!(sanitizedRecord.props as any).content,
|
||||
hasTags: Array.isArray((sanitizedRecord.props as any).tags),
|
||||
title: (sanitizedRecord.props as any).title,
|
||||
contentLength: (sanitizedRecord.props as any).content?.length || 0,
|
||||
tagsCount: Array.isArray((sanitizedRecord.props as any).tags) ? (sanitizedRecord.props as any).tags.length : 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Create a deep copy to ensure all properties (including richText and text) are preserved
|
||||
// This prevents Automerge from treating the object as read-only
|
||||
// Note: sanitizedRecord.props is already a deep copy from sanitizeRecord, but we need to deep copy the entire record
|
||||
const recordToSave = JSON.parse(JSON.stringify(sanitizedRecord))
|
||||
|
||||
// DEBUG: Log richText, meta.text, and Obsidian note properties after deep copy
|
||||
if (recordToSave.typeName === 'shape') {
|
||||
if (recordToSave.type === 'geo' && recordToSave.props?.richText) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Geo shape ${recordToSave.id} has richText after deep copy:`, {
|
||||
hasRichText: !!recordToSave.props.richText,
|
||||
richTextType: typeof recordToSave.props.richText,
|
||||
richTextContent: Array.isArray(recordToSave.props.richText) ? 'array' : recordToSave.props.richText?.content ? 'object with content' : 'object without content',
|
||||
richTextContentLength: Array.isArray(recordToSave.props.richText?.content) ? recordToSave.props.richText.content.length : 'not array'
|
||||
})
|
||||
}
|
||||
if (recordToSave.type === 'geo' && recordToSave.meta?.text !== undefined) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Geo shape ${recordToSave.id} has meta.text after deep copy:`, {
|
||||
hasMetaText: !!recordToSave.meta.text,
|
||||
metaTextValue: recordToSave.meta.text,
|
||||
metaTextType: typeof recordToSave.meta.text
|
||||
})
|
||||
}
|
||||
if (recordToSave.type === 'note' && recordToSave.props?.richText) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Note shape ${recordToSave.id} has richText after deep copy:`, {
|
||||
hasRichText: !!recordToSave.props.richText,
|
||||
richTextType: typeof recordToSave.props.richText,
|
||||
richTextContent: Array.isArray(recordToSave.props.richText) ? 'array' : recordToSave.props.richText?.content ? 'object with content' : 'object without content',
|
||||
richTextContentLength: Array.isArray(recordToSave.props.richText?.content) ? recordToSave.props.richText.content.length : 'not array'
|
||||
})
|
||||
}
|
||||
if (recordToSave.type === 'arrow' && recordToSave.props?.text !== undefined) {
|
||||
console.log(`🔍 TLStoreToAutomerge: Arrow shape ${recordToSave.id} has text after deep copy:`, {
|
||||
hasText: !!recordToSave.props.text,
|
||||
textValue: recordToSave.props.text,
|
||||
textType: typeof recordToSave.props.text
|
||||
})
|
||||
}
|
||||
if (recordToSave.type === 'ObsNote') {
|
||||
console.log(`🔍 TLStoreToAutomerge: ObsNote shape ${recordToSave.id} after deep copy:`, {
|
||||
hasTitle: !!recordToSave.props.title,
|
||||
hasContent: !!recordToSave.props.content,
|
||||
hasTags: Array.isArray(recordToSave.props.tags),
|
||||
title: recordToSave.props.title,
|
||||
contentLength: recordToSave.props.content?.length || 0,
|
||||
tagsCount: Array.isArray(recordToSave.props.tags) ? recordToSave.props.tags.length : 0,
|
||||
allPropsKeys: Object.keys(recordToSave.props || {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the entire record - Automerge will handle merging with concurrent changes
|
||||
doc.store[record.id] = recordToSave
|
||||
})
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export async function saveDocumentId(roomId: string, documentId: string): Promis
|
|||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log(`Saved document mapping: ${roomId} -> ${documentId}`)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
|
@ -170,6 +171,7 @@ export async function deleteDocumentMapping(roomId: string): Promise<void> {
|
|||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log(`Deleted document mapping for: ${roomId}`)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
|
@ -236,6 +238,7 @@ export async function cleanupOldMappings(maxAgeDays: number = 30): Promise<numbe
|
|||
deletedCount++
|
||||
cursor.continue()
|
||||
} else {
|
||||
console.log(`Cleaned up ${deletedCount} old document mappings`)
|
||||
resolve(deletedCount)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,46 +17,6 @@ import throttle from "lodash.throttle"
|
|||
import { applyAutomergePatchesToTLStore, sanitizeRecord } from "./AutomergeToTLStore.js"
|
||||
import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js"
|
||||
|
||||
// Default tldraw shape types (built into the library)
|
||||
const DEFAULT_SHAPE_TYPES = [
|
||||
'arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group',
|
||||
'highlight', 'image', 'line', 'note', 'text', 'video'
|
||||
]
|
||||
|
||||
// Custom shape types registered in this application
|
||||
// IMPORTANT: Keep this in sync with shapeUtils array inside useAutomergeSync
|
||||
const CUSTOM_SHAPE_TYPES = [
|
||||
'ChatBox',
|
||||
'VideoChat',
|
||||
'Embed',
|
||||
'Markdown',
|
||||
'MycrozineTemplate',
|
||||
'MycroZineGenerator',
|
||||
'Slide',
|
||||
'Prompt',
|
||||
'Transcription',
|
||||
'ObsNote',
|
||||
'FathomNote',
|
||||
'Holon',
|
||||
'ObsidianBrowser',
|
||||
'FathomMeetingsBrowser',
|
||||
'ImageGen',
|
||||
'VideoGen',
|
||||
'Multmux',
|
||||
'MycelialIntelligence', // AI-powered collaborative intelligence shape
|
||||
'Map', // Open Mapping - OSM map shape
|
||||
'Calendar', // Calendar with view switching
|
||||
'CalendarEvent', // Calendar individual events
|
||||
'Drawfast', // Drawfast quick sketching
|
||||
'HolonBrowser', // Holon browser
|
||||
'PrivateWorkspace', // Private workspace for Google Export
|
||||
'GoogleItem', // Individual Google items
|
||||
'WorkflowBlock', // Workflow builder blocks
|
||||
]
|
||||
|
||||
// Combined set of all known shape types for validation
|
||||
const KNOWN_SHAPE_TYPES = new Set([...DEFAULT_SHAPE_TYPES, ...CUSTOM_SHAPE_TYPES])
|
||||
|
||||
// Helper function to safely extract plain objects from Automerge proxies
|
||||
// This handles cases where JSON.stringify fails due to functions or getters
|
||||
function safeExtractPlainObject(obj: any, visited = new WeakSet()): any {
|
||||
|
|
@ -156,7 +116,6 @@ import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
|
|||
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
|
||||
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
|
||||
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
|
||||
import { MycroZineGeneratorShape } from "@/shapes/MycroZineGeneratorShapeUtil"
|
||||
import { SlideShape } from "@/shapes/SlideShapeUtil"
|
||||
import { PromptShape } from "@/shapes/PromptShapeUtil"
|
||||
import { TranscriptionShape } from "@/shapes/TranscriptionShapeUtil"
|
||||
|
|
@ -172,27 +131,15 @@ import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
|
|||
import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUtil"
|
||||
// Open Mapping - OSM map shape for geographic visualization
|
||||
import { MapShape } from "@/shapes/MapShapeUtil"
|
||||
// Calendar shape for calendar functionality
|
||||
import { CalendarShape } from "@/shapes/CalendarShapeUtil"
|
||||
import { CalendarEventShape } from "@/shapes/CalendarEventShapeUtil"
|
||||
// Drawfast shape for quick drawing/sketching
|
||||
import { DrawfastShape } from "@/shapes/DrawfastShapeUtil"
|
||||
// Additional shapes from Board.tsx
|
||||
import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
|
||||
import { PrivateWorkspaceShape } from "@/shapes/PrivateWorkspaceShapeUtil"
|
||||
import { GoogleItemShape } from "@/shapes/GoogleItemShapeUtil"
|
||||
import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil"
|
||||
|
||||
export function useAutomergeStoreV2({
|
||||
handle,
|
||||
userId: _userId,
|
||||
adapter,
|
||||
isNetworkOnline = true,
|
||||
}: {
|
||||
handle: DocHandle<any>
|
||||
userId: string
|
||||
adapter?: any
|
||||
isNetworkOnline?: boolean
|
||||
}): TLStoreWithStatus {
|
||||
// useAutomergeStoreV2 initializing
|
||||
|
||||
|
|
@ -205,7 +152,6 @@ export function useAutomergeStoreV2({
|
|||
EmbedShape,
|
||||
MarkdownShape,
|
||||
MycrozineTemplateShape,
|
||||
MycroZineGeneratorShape,
|
||||
SlideShape,
|
||||
PromptShape,
|
||||
TranscriptionShape,
|
||||
|
|
@ -217,20 +163,32 @@ export function useAutomergeStoreV2({
|
|||
ImageGenShape,
|
||||
VideoGenShape,
|
||||
MultmuxShape,
|
||||
MycelialIntelligenceShape, // AI-powered collaborative intelligence shape
|
||||
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
|
||||
MapShape, // Open Mapping - OSM map shape
|
||||
CalendarShape, // Calendar with view switching
|
||||
CalendarEventShape, // Calendar individual events
|
||||
DrawfastShape, // Drawfast quick sketching
|
||||
HolonBrowserShape, // Holon browser
|
||||
PrivateWorkspaceShape, // Private workspace for Google Export
|
||||
GoogleItemShape, // Individual Google items
|
||||
WorkflowBlockShape, // Workflow builder blocks
|
||||
]
|
||||
|
||||
// Use the module-level CUSTOM_SHAPE_TYPES constant
|
||||
// This ensures schema registration stays in sync with the filtering logic
|
||||
const knownCustomShapeTypes = CUSTOM_SHAPE_TYPES
|
||||
// CRITICAL: Explicitly list ALL custom shape types to ensure they're registered
|
||||
// This is a fallback in case dynamic extraction from shape utils fails
|
||||
const knownCustomShapeTypes = [
|
||||
'ChatBox',
|
||||
'VideoChat',
|
||||
'Embed',
|
||||
'Markdown',
|
||||
'MycrozineTemplate',
|
||||
'Slide',
|
||||
'Prompt',
|
||||
'Transcription',
|
||||
'ObsNote',
|
||||
'FathomNote',
|
||||
'Holon',
|
||||
'ObsidianBrowser',
|
||||
'FathomMeetingsBrowser',
|
||||
'ImageGen',
|
||||
'VideoGen',
|
||||
'Multmux',
|
||||
'MycelialIntelligence', // Deprecated - kept for backwards compatibility
|
||||
'Map', // Open Mapping - OSM map shape
|
||||
]
|
||||
|
||||
// Build schema with explicit entries for all custom shapes
|
||||
const customShapeSchemas: Record<string, any> = {}
|
||||
|
|
@ -316,7 +274,12 @@ export function useAutomergeStoreV2({
|
|||
return
|
||||
}
|
||||
|
||||
// Broadcasting changes via JSON sync (logging disabled for performance)
|
||||
// Broadcasting changes via JSON sync
|
||||
const shapeRecords = addedOrUpdatedRecords.filter(r => r?.typeName === 'shape')
|
||||
const deletedShapes = deletedRecordIds.filter(id => id.startsWith('shape:'))
|
||||
if (shapeRecords.length > 0 || deletedShapes.length > 0) {
|
||||
console.log(`📤 Broadcasting ${shapeRecords.length} shape changes and ${deletedShapes.length} deletions via JSON sync`)
|
||||
}
|
||||
|
||||
if (adapter && typeof (adapter as any).send === 'function') {
|
||||
// Send changes to other clients via the network adapter
|
||||
|
|
@ -340,23 +303,50 @@ export function useAutomergeStoreV2({
|
|||
// Listen for changes from Automerge and apply them to TLDraw
|
||||
const automergeChangeHandler = (payload: DocHandleChangePayload<any>) => {
|
||||
const patchCount = payload.patches?.length || 0
|
||||
const shapePatches = payload.patches?.filter((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||
}) || []
|
||||
|
||||
// Debug logging for sync issues
|
||||
console.log(`🔄 automergeChangeHandler: ${patchCount} patches (${shapePatches.length} shapes), pendingLocalChanges=${pendingLocalChanges}`)
|
||||
|
||||
// Skip echoes of our own local changes using a counter.
|
||||
// Each local handle.change() increments the counter, and each echo decrements it.
|
||||
// Only process changes when counter is 0 (those are remote changes from other clients).
|
||||
if (pendingLocalChanges > 0) {
|
||||
console.log(`⏭️ Skipping echo (pendingLocalChanges was ${pendingLocalChanges}, now ${pendingLocalChanges - 1})`)
|
||||
pendingLocalChanges--
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`✅ Processing ${patchCount} patches as REMOTE changes (${shapePatches.length} shape patches)`)
|
||||
|
||||
try {
|
||||
// Apply patches from Automerge to TLDraw store
|
||||
if (payload.patches && payload.patches.length > 0) {
|
||||
// Debug: Check if patches contain shapes
|
||||
if (shapePatches.length > 0) {
|
||||
console.log(`📥 Applying ${shapePatches.length} shape patches from remote`)
|
||||
}
|
||||
|
||||
try {
|
||||
const recordsBefore = store.allRecords()
|
||||
const shapesBefore = recordsBefore.filter((r: any) => r.typeName === 'shape')
|
||||
|
||||
// CRITICAL: Pass Automerge document to patch handler so it can read full records
|
||||
// This prevents coordinates from defaulting to 0,0 when patches create new records
|
||||
const automergeDoc = handle.doc()
|
||||
applyAutomergePatchesToTLStore(payload.patches, store, automergeDoc)
|
||||
|
||||
const recordsAfter = store.allRecords()
|
||||
const shapesAfter = recordsAfter.filter((r: any) => r.typeName === 'shape')
|
||||
|
||||
if (shapesAfter.length !== shapesBefore.length) {
|
||||
// Patches applied
|
||||
}
|
||||
|
||||
// Patches processed successfully
|
||||
} catch (patchError) {
|
||||
console.error("Error applying patches batch, attempting individual patch application:", patchError)
|
||||
// Try applying patches one by one to identify problematic ones
|
||||
|
|
@ -392,6 +382,7 @@ export function useAutomergeStoreV2({
|
|||
if (existingRecord && (existingRecord as any).typeName === 'shape' && (existingRecord as any).type === 'geo') {
|
||||
const geoRecord = existingRecord as any
|
||||
if (!geoRecord.props || !geoRecord.props.geo) {
|
||||
console.log(`🔧 Attempting to fix geo shape ${recordId} missing props.geo`)
|
||||
// This won't help with the current patch, but might help future patches
|
||||
// The real fix should happen in AutomergeToTLStore sanitization
|
||||
}
|
||||
|
|
@ -449,6 +440,7 @@ export function useAutomergeStoreV2({
|
|||
const storeShapeCount = store.allRecords().filter((r: any) => r.typeName === 'shape').length
|
||||
|
||||
if (docShapeCount > 0 && storeShapeCount === 0) {
|
||||
console.log(`🔧 Handler set up after data was written. Manually processing ${docShapeCount} shapes that were loaded before handler was ready...`)
|
||||
// Since patches were already emitted when handle.change() was called in useAutomergeSyncRepo,
|
||||
// we need to manually process the data that's already in the doc
|
||||
try {
|
||||
|
|
@ -475,31 +467,17 @@ export function useAutomergeStoreV2({
|
|||
}
|
||||
})
|
||||
|
||||
// Filter out unknown/unsupported shape types to prevent validation errors
|
||||
// This keeps the board functional even if some shapes can't be loaded
|
||||
const unknownShapeTypes: string[] = []
|
||||
// Filter out SharedPiano shapes since they're no longer supported
|
||||
const filteredRecords = allRecords.filter((record: any) => {
|
||||
if (record.typeName === 'shape') {
|
||||
const shapeType = record.type
|
||||
if (!KNOWN_SHAPE_TYPES.has(shapeType)) {
|
||||
// Track unknown types for error logging
|
||||
if (!unknownShapeTypes.includes(shapeType)) {
|
||||
unknownShapeTypes.push(shapeType)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (record.typeName === 'shape' && record.type === 'SharedPiano') {
|
||||
console.log(`⚠️ Filtering out deprecated SharedPiano shape: ${record.id}`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Log errors for any unknown shape types that were filtered out
|
||||
if (unknownShapeTypes.length > 0) {
|
||||
console.error(`❌ Unknown shape types filtered out (shapes not loaded):`, unknownShapeTypes)
|
||||
console.error(` These shapes exist in the document but are not registered in KNOWN_SHAPE_TYPES.`)
|
||||
console.error(` To fix: Add these types to CUSTOM_SHAPE_TYPES in useAutomergeStoreV2.ts`)
|
||||
}
|
||||
|
||||
if (filteredRecords.length > 0) {
|
||||
console.log(`🔧 Manually applying ${filteredRecords.length} records to store (patches were missed during initial load, filtered out ${allRecords.length - filteredRecords.length} SharedPiano shapes)`)
|
||||
store.mergeRemoteChanges(() => {
|
||||
const pageRecords = filteredRecords.filter(r => r.typeName === 'page')
|
||||
const shapeRecords = filteredRecords.filter(r => r.typeName === 'shape')
|
||||
|
|
@ -507,6 +485,7 @@ export function useAutomergeStoreV2({
|
|||
const recordsToAdd = [...pageRecords, ...otherRecords, ...shapeRecords]
|
||||
store.put(recordsToAdd)
|
||||
})
|
||||
console.log(`✅ Manually applied ${filteredRecords.length} records to store`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error manually processing initial data:`, error)
|
||||
|
|
@ -601,91 +580,78 @@ export function useAutomergeStoreV2({
|
|||
// Track recent eraser activity to detect active eraser drags
|
||||
let lastEraserActivity = 0
|
||||
let eraserToolSelected = false
|
||||
let lastEraserCheckTime = 0
|
||||
let cachedEraserActive = false
|
||||
const ERASER_ACTIVITY_THRESHOLD = 2000 // Increased to 2 seconds to handle longer eraser drags
|
||||
const ERASER_CHECK_CACHE_MS = 100 // Only refresh eraser state every 100ms to avoid expensive checks
|
||||
let eraserChangeQueue: RecordsDiff<TLRecord> | null = null
|
||||
let eraserCheckInterval: NodeJS.Timeout | null = null
|
||||
|
||||
|
||||
// Helper to check if eraser tool is actively erasing (to prevent saves during eraser drag)
|
||||
// OPTIMIZED: Uses cached state and only refreshes periodically to avoid expensive store.allRecords() calls
|
||||
const isEraserActive = (): boolean => {
|
||||
const now = Date.now()
|
||||
|
||||
// Use cached result if checked recently
|
||||
if (now - lastEraserCheckTime < ERASER_CHECK_CACHE_MS) {
|
||||
return cachedEraserActive
|
||||
}
|
||||
lastEraserCheckTime = now
|
||||
|
||||
// If eraser was selected and recent activity, assume still active
|
||||
if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
|
||||
cachedEraserActive = true
|
||||
return true
|
||||
}
|
||||
|
||||
// If no recent eraser activity and not marked as selected, quickly return false
|
||||
if (!eraserToolSelected && now - lastEraserActivity > ERASER_ACTIVITY_THRESHOLD) {
|
||||
cachedEraserActive = false
|
||||
return false
|
||||
}
|
||||
|
||||
// Only do expensive check if eraser might be transitioning
|
||||
try {
|
||||
// Use store.get() for specific records instead of allRecords() for better performance
|
||||
const instancePageState = store.get('instance_page_state:page:page' as any)
|
||||
|
||||
const allRecords = store.allRecords()
|
||||
|
||||
// Check instance_page_state for erasingShapeIds (most reliable indicator)
|
||||
if (instancePageState &&
|
||||
(instancePageState as any).erasingShapeIds &&
|
||||
Array.isArray((instancePageState as any).erasingShapeIds) &&
|
||||
(instancePageState as any).erasingShapeIds.length > 0) {
|
||||
lastEraserActivity = now
|
||||
const instancePageState = allRecords.find((r: any) =>
|
||||
r.typeName === 'instance_page_state' &&
|
||||
(r as any).erasingShapeIds &&
|
||||
Array.isArray((r as any).erasingShapeIds) &&
|
||||
(r as any).erasingShapeIds.length > 0
|
||||
)
|
||||
|
||||
if (instancePageState) {
|
||||
lastEraserActivity = Date.now()
|
||||
eraserToolSelected = true
|
||||
cachedEraserActive = true
|
||||
return true // Eraser is actively erasing shapes
|
||||
}
|
||||
|
||||
|
||||
// Check if eraser tool is selected
|
||||
const instance = store.get('instance:instance' as any)
|
||||
const instance = allRecords.find((r: any) => r.typeName === 'instance')
|
||||
const currentToolId = instance ? (instance as any).currentToolId : null
|
||||
|
||||
|
||||
if (currentToolId === 'eraser') {
|
||||
eraserToolSelected = true
|
||||
lastEraserActivity = now
|
||||
cachedEraserActive = true
|
||||
const now = Date.now()
|
||||
// If eraser tool is selected, keep it active for longer to handle drags
|
||||
// Also check if there was recent activity
|
||||
if (now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
|
||||
return true
|
||||
}
|
||||
// If tool is selected but no recent activity, still consider it active
|
||||
// (user might be mid-drag)
|
||||
return true
|
||||
} else {
|
||||
// Tool switched away - only consider active if very recent activity
|
||||
eraserToolSelected = false
|
||||
const now = Date.now()
|
||||
if (now - lastEraserActivity < 300) {
|
||||
return true // Very recent activity, might still be processing
|
||||
}
|
||||
}
|
||||
|
||||
cachedEraserActive = false
|
||||
|
||||
return false
|
||||
} catch (e) {
|
||||
// If we can't check, use last known state with timeout
|
||||
const now = Date.now()
|
||||
if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
|
||||
cachedEraserActive = true
|
||||
return true
|
||||
}
|
||||
cachedEraserActive = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Track eraser activity from shape deletions
|
||||
// OPTIMIZED: Only check for eraser tool when shapes are removed, and use cached tool state
|
||||
const checkForEraserActivity = (changes: RecordsDiff<TLRecord>) => {
|
||||
// If shapes are being removed and eraser tool might be active, mark activity
|
||||
if (changes.removed) {
|
||||
const removedKeys = Object.keys(changes.removed)
|
||||
// Quick check: if no shape keys, skip
|
||||
const hasRemovedShapes = removedKeys.some(key => key.startsWith('shape:'))
|
||||
if (hasRemovedShapes) {
|
||||
// Use cached eraserToolSelected state if recent, avoid expensive allRecords() call
|
||||
const now = Date.now()
|
||||
if (eraserToolSelected || now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
|
||||
lastEraserActivity = now
|
||||
const removedShapes = Object.values(changes.removed).filter((r: any) =>
|
||||
r && r.typeName === 'shape'
|
||||
)
|
||||
if (removedShapes.length > 0) {
|
||||
// Check if eraser tool is currently selected
|
||||
const allRecords = store.allRecords()
|
||||
const instance = allRecords.find((r: any) => r.typeName === 'instance')
|
||||
if (instance && (instance as any).currentToolId === 'eraser') {
|
||||
lastEraserActivity = Date.now()
|
||||
eraserToolSelected = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -722,6 +688,17 @@ export function useAutomergeStoreV2({
|
|||
id.startsWith('pointer:')
|
||||
)
|
||||
|
||||
// DEBUG: Log why records are being filtered or not
|
||||
const shouldFilter = (typeName && ephemeralTypes.includes(typeName)) || idMatchesEphemeral
|
||||
if (shouldFilter) {
|
||||
console.log(`🚫 Filtering out ephemeral record:`, {
|
||||
id,
|
||||
typeName,
|
||||
idMatchesEphemeral,
|
||||
typeNameMatches: typeName && ephemeralTypes.includes(typeName)
|
||||
})
|
||||
}
|
||||
|
||||
// Filter out if typeName matches OR if ID pattern matches ephemeral types
|
||||
if (typeName && ephemeralTypes.includes(typeName)) {
|
||||
// Skip - this is an ephemeral record
|
||||
|
|
@ -744,9 +721,183 @@ export function useAutomergeStoreV2({
|
|||
removed: filterEphemeral(changes.removed),
|
||||
}
|
||||
|
||||
// Calculate change counts (minimal, needed for early return)
|
||||
// DEBUG: Log all changes to see what's being detected
|
||||
const totalChanges = Object.keys(changes.added || {}).length + Object.keys(changes.updated || {}).length + Object.keys(changes.removed || {}).length
|
||||
const filteredTotalChanges = Object.keys(filteredChanges.added || {}).length + Object.keys(filteredChanges.updated || {}).length + Object.keys(filteredChanges.removed || {}).length
|
||||
|
||||
|
||||
// DEBUG: Log ALL changes (before filtering) to see what's actually being updated
|
||||
if (totalChanges > 0) {
|
||||
const allChangedRecords: Array<{id: string, typeName: string, changeType: string}> = []
|
||||
if (changes.added) {
|
||||
Object.entries(changes.added).forEach(([id, record]: [string, any]) => {
|
||||
const recordObj = Array.isArray(record) ? record[1] : record
|
||||
allChangedRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'added' })
|
||||
})
|
||||
}
|
||||
if (changes.updated) {
|
||||
Object.entries(changes.updated).forEach(([id, [_, record]]: [string, [any, any]]) => {
|
||||
allChangedRecords.push({ id, typeName: record?.typeName || 'unknown', changeType: 'updated' })
|
||||
})
|
||||
}
|
||||
if (changes.removed) {
|
||||
Object.entries(changes.removed).forEach(([id, record]: [string, any]) => {
|
||||
const recordObj = Array.isArray(record) ? record[1] : record
|
||||
allChangedRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'removed' })
|
||||
})
|
||||
}
|
||||
console.log(`🔍 ALL changes detected (before filtering):`, {
|
||||
total: totalChanges,
|
||||
records: allChangedRecords,
|
||||
// Also log the actual record objects to see their structure
|
||||
recordDetails: allChangedRecords.map(r => {
|
||||
let record: any = null
|
||||
if (r.changeType === 'added' && changes.added) {
|
||||
const rec = (changes.added as any)[r.id]
|
||||
record = Array.isArray(rec) ? rec[1] : rec
|
||||
} else if (r.changeType === 'updated' && changes.updated) {
|
||||
const rec = (changes.updated as any)[r.id]
|
||||
record = Array.isArray(rec) ? rec[1] : rec
|
||||
} else if (r.changeType === 'removed' && changes.removed) {
|
||||
const rec = (changes.removed as any)[r.id]
|
||||
record = Array.isArray(rec) ? rec[1] : rec
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
typeName: r.typeName,
|
||||
changeType: r.changeType,
|
||||
hasTypeName: !!record?.typeName,
|
||||
actualTypeName: record?.typeName,
|
||||
recordKeys: record ? Object.keys(record).slice(0, 10) : []
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Log if we filtered out any ephemeral changes
|
||||
if (totalChanges > 0 && filteredTotalChanges < totalChanges) {
|
||||
const filteredCount = totalChanges - filteredTotalChanges
|
||||
const filteredTypes = new Set<string>()
|
||||
const filteredIds: string[] = []
|
||||
if (changes.added) {
|
||||
Object.entries(changes.added).forEach(([id, record]: [string, any]) => {
|
||||
const recordObj = Array.isArray(record) ? record[1] : record
|
||||
if (recordObj && ephemeralTypes.includes(recordObj.typeName)) {
|
||||
filteredTypes.add(recordObj.typeName)
|
||||
filteredIds.push(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (changes.updated) {
|
||||
Object.entries(changes.updated).forEach(([id, [_, record]]: [string, [any, any]]) => {
|
||||
if (ephemeralTypes.includes(record.typeName)) {
|
||||
filteredTypes.add(record.typeName)
|
||||
filteredIds.push(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (changes.removed) {
|
||||
Object.entries(changes.removed).forEach(([id, record]: [string, any]) => {
|
||||
const recordObj = Array.isArray(record) ? record[1] : record
|
||||
if (recordObj && ephemeralTypes.includes(recordObj.typeName)) {
|
||||
filteredTypes.add(recordObj.typeName)
|
||||
filteredIds.push(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
console.log(`🚫 Filtered out ${filteredCount} ephemeral change(s) (${Array.from(filteredTypes).join(', ')}) - not persisting`, {
|
||||
filteredIds: filteredIds.slice(0, 5), // Show first 5 IDs
|
||||
totalFiltered: filteredIds.length
|
||||
})
|
||||
}
|
||||
|
||||
if (filteredTotalChanges > 0) {
|
||||
// Log what records are passing through the filter (shouldn't happen for ephemeral records)
|
||||
const passingRecords: Array<{id: string, typeName: string, changeType: string}> = []
|
||||
if (filteredChanges.added) {
|
||||
Object.entries(filteredChanges.added).forEach(([id, record]: [string, any]) => {
|
||||
const recordObj = Array.isArray(record) ? record[1] : record
|
||||
passingRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'added' })
|
||||
})
|
||||
}
|
||||
if (filteredChanges.updated) {
|
||||
Object.entries(filteredChanges.updated).forEach(([id, recordTuple]: [string, any]) => {
|
||||
const record = Array.isArray(recordTuple) && recordTuple.length === 2 ? recordTuple[1] : recordTuple
|
||||
passingRecords.push({ id, typeName: (record as any)?.typeName || 'unknown', changeType: 'updated' })
|
||||
})
|
||||
}
|
||||
if (filteredChanges.removed) {
|
||||
Object.entries(filteredChanges.removed).forEach(([id, record]: [string, any]) => {
|
||||
const recordObj = Array.isArray(record) ? record[1] : record
|
||||
passingRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'removed' })
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`🔍 TLDraw store changes detected (source: ${source}):`, {
|
||||
added: Object.keys(filteredChanges.added || {}).length,
|
||||
updated: Object.keys(filteredChanges.updated || {}).length,
|
||||
removed: Object.keys(filteredChanges.removed || {}).length,
|
||||
source: source,
|
||||
passingRecords: passingRecords // Show what's actually passing through
|
||||
})
|
||||
|
||||
// DEBUG: Check for richText/text changes in updated records
|
||||
if (filteredChanges.updated) {
|
||||
Object.values(filteredChanges.updated).forEach((recordTuple: any) => {
|
||||
const record = Array.isArray(recordTuple) && recordTuple.length === 2 ? recordTuple[1] : recordTuple
|
||||
if ((record as any)?.typeName === 'shape') {
|
||||
const rec = record as any
|
||||
if (rec.type === 'geo' && rec.props?.richText) {
|
||||
console.log(`🔍 Geo shape ${rec.id} richText change detected:`, {
|
||||
hasRichText: !!rec.props.richText,
|
||||
richTextType: typeof rec.props.richText,
|
||||
source: source
|
||||
})
|
||||
}
|
||||
if (rec.type === 'note' && rec.props?.richText) {
|
||||
console.log(`🔍 Note shape ${rec.id} richText change detected:`, {
|
||||
hasRichText: !!rec.props.richText,
|
||||
richTextType: typeof rec.props.richText,
|
||||
richTextContentLength: Array.isArray(rec.props.richText?.content)
|
||||
? rec.props.richText.content.length
|
||||
: 'not array',
|
||||
source: source
|
||||
})
|
||||
}
|
||||
if (rec.type === 'arrow' && rec.props?.text !== undefined) {
|
||||
console.log(`🔍 Arrow shape ${rec.id} text change detected:`, {
|
||||
hasText: !!rec.props.text,
|
||||
textValue: rec.props.text,
|
||||
source: source
|
||||
})
|
||||
}
|
||||
if (rec.type === 'text' && rec.props?.richText) {
|
||||
console.log(`🔍 Text shape ${rec.id} richText change detected:`, {
|
||||
hasRichText: !!rec.props.richText,
|
||||
richTextType: typeof rec.props.richText,
|
||||
source: source
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// DEBUG: Log added shapes to track what's being created
|
||||
if (filteredChanges.added) {
|
||||
Object.values(filteredChanges.added).forEach((record: any) => {
|
||||
const rec = Array.isArray(record) ? record[1] : record
|
||||
if (rec?.typeName === 'shape') {
|
||||
console.log(`🔍 Shape added: ${rec.type} (${rec.id})`, {
|
||||
type: rec.type,
|
||||
id: rec.id,
|
||||
hasRichText: !!rec.props?.richText,
|
||||
hasText: !!rec.props?.text,
|
||||
source: source
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if no meaningful changes after filtering ephemeral records
|
||||
if (filteredTotalChanges === 0) {
|
||||
return
|
||||
|
|
@ -755,6 +906,7 @@ export function useAutomergeStoreV2({
|
|||
// CRITICAL: Skip broadcasting changes that came from remote sources to prevent feedback loops
|
||||
// Only broadcast changes that originated from user interactions (source === 'user')
|
||||
if (source === 'remote') {
|
||||
console.log('🔄 Skipping broadcast for remote change to prevent feedback loop')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -847,6 +999,7 @@ export function useAutomergeStoreV2({
|
|||
|
||||
// If only position changed (x/y), restore original coordinates
|
||||
if (!otherPropsChanged && (newX !== originalX || newY !== originalY)) {
|
||||
console.log(`🚫 Filtering out x/y coordinate change for pinned shape ${id}: (${newX}, ${newY}) -> keeping original (${originalX}, ${originalY})`)
|
||||
// Restore original coordinates
|
||||
const recordWithOriginalCoords = {
|
||||
...record,
|
||||
|
|
@ -891,6 +1044,38 @@ export function useAutomergeStoreV2({
|
|||
// Check if this is a position-only update that should be throttled
|
||||
const isPositionOnly = isPositionOnlyUpdate(finalFilteredChanges)
|
||||
|
||||
// Log what type of change this is for debugging
|
||||
const changeType = Object.keys(finalFilteredChanges.added || {}).length > 0 ? 'added' :
|
||||
Object.keys(finalFilteredChanges.removed || {}).length > 0 ? 'removed' :
|
||||
isPositionOnly ? 'position-only' : 'property-change'
|
||||
|
||||
// DEBUG: Log dimension changes for shapes
|
||||
if (finalFilteredChanges.updated) {
|
||||
Object.entries(finalFilteredChanges.updated).forEach(([id, recordTuple]: [string, any]) => {
|
||||
const isTuple = Array.isArray(recordTuple) && recordTuple.length === 2
|
||||
const oldRecord = isTuple ? recordTuple[0] : null
|
||||
const newRecord = isTuple ? recordTuple[1] : recordTuple
|
||||
if (newRecord?.typeName === 'shape') {
|
||||
const oldProps = oldRecord?.props || {}
|
||||
const newProps = newRecord?.props || {}
|
||||
if (oldProps.w !== newProps.w || oldProps.h !== newProps.h) {
|
||||
console.log(`🔍 Shape dimension change detected for ${newRecord.type} ${id}:`, {
|
||||
oldDims: { w: oldProps.w, h: oldProps.h },
|
||||
newDims: { w: newProps.w, h: newProps.h },
|
||||
source
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`🔍 Change detected: ${changeType}, will ${isPositionOnly ? 'throttle' : 'broadcast immediately'}`, {
|
||||
added: Object.keys(finalFilteredChanges.added || {}).length,
|
||||
updated: Object.keys(finalFilteredChanges.updated || {}).length,
|
||||
removed: Object.keys(finalFilteredChanges.removed || {}).length,
|
||||
source
|
||||
})
|
||||
|
||||
if (isPositionOnly && positionUpdateQueue === null) {
|
||||
// Start a new queue for position updates
|
||||
positionUpdateQueue = finalFilteredChanges
|
||||
|
|
@ -1073,7 +1258,12 @@ export function useAutomergeStoreV2({
|
|||
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
|
||||
}
|
||||
|
||||
// Logging disabled for performance during continuous drawing
|
||||
// Only log if there are many changes or if debugging is needed
|
||||
if (filteredTotalChanges > 3) {
|
||||
console.log(`✅ Applied ${filteredTotalChanges} TLDraw changes to Automerge document`)
|
||||
} else if (filteredTotalChanges > 0) {
|
||||
console.log(`✅ Applied ${filteredTotalChanges} TLDraw change(s) to Automerge document`)
|
||||
}
|
||||
|
||||
// Check if the document actually changed
|
||||
const docAfter = handle.doc()
|
||||
|
|
@ -1126,115 +1316,58 @@ export function useAutomergeStoreV2({
|
|||
try {
|
||||
await handle.whenReady()
|
||||
const doc = handle.doc()
|
||||
|
||||
|
||||
// Check if store is already populated from patches
|
||||
const existingStoreRecords = store.allRecords()
|
||||
const existingStoreShapes = existingStoreRecords.filter((r: any) => r.typeName === 'shape')
|
||||
|
||||
// Determine connection status based on network state
|
||||
const connectionStatus = isNetworkOnline ? "online" : "offline"
|
||||
|
||||
|
||||
if (doc.store) {
|
||||
const storeKeys = Object.keys(doc.store)
|
||||
const docShapes = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
|
||||
console.log(`📊 Patch-based initialization: doc has ${storeKeys.length} records (${docShapes} shapes), store has ${existingStoreRecords.length} records (${existingStoreShapes.length} shapes)`)
|
||||
|
||||
// If store already has shapes, patches have been applied (dev mode behavior)
|
||||
if (existingStoreShapes.length > 0) {
|
||||
|
||||
console.log(`✅ Store already populated from patches (${existingStoreShapes.length} shapes) - using patch-based loading like dev`)
|
||||
|
||||
// REMOVED: Aggressive shape refresh that was causing coordinate loss
|
||||
// Shapes should be visible through normal patch application
|
||||
// If shapes aren't visible, it's likely a different issue that refresh won't fix
|
||||
|
||||
|
||||
setStoreWithStatus({
|
||||
store,
|
||||
status: "synced-remote",
|
||||
connectionStatus,
|
||||
connectionStatus: "online",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// OFFLINE FAST PATH: When offline with local data, load immediately
|
||||
// Don't wait for patches that will never come from the network
|
||||
if (!isNetworkOnline && docShapes > 0) {
|
||||
|
||||
// Manually load data from Automerge doc since patches won't come through
|
||||
try {
|
||||
const allRecords: TLRecord[] = []
|
||||
Object.entries(doc.store).forEach(([id, record]: [string, any]) => {
|
||||
if (!record || !record.typeName || !record.id) return
|
||||
if (record.typeName === 'obsidian_vault' || (typeof record.id === 'string' && record.id.startsWith('obsidian_vault:'))) return
|
||||
|
||||
try {
|
||||
let cleanRecord: any
|
||||
try {
|
||||
cleanRecord = JSON.parse(JSON.stringify(record))
|
||||
} catch {
|
||||
cleanRecord = safeExtractPlainObject(record)
|
||||
}
|
||||
|
||||
if (cleanRecord && typeof cleanRecord === 'object') {
|
||||
const sanitized = sanitizeRecord(cleanRecord)
|
||||
const plainSanitized = JSON.parse(JSON.stringify(sanitized))
|
||||
allRecords.push(plainSanitized)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ Could not process record ${id}:`, e)
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out SharedPiano shapes since they're no longer supported
|
||||
const filteredRecords = allRecords.filter((record: any) => {
|
||||
if (record.typeName === 'shape' && record.type === 'SharedPiano') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (filteredRecords.length > 0) {
|
||||
store.mergeRemoteChanges(() => {
|
||||
const pageRecords = filteredRecords.filter(r => r.typeName === 'page')
|
||||
const shapeRecords = filteredRecords.filter(r => r.typeName === 'shape')
|
||||
const otherRecords = filteredRecords.filter(r => r.typeName !== 'page' && r.typeName !== 'shape')
|
||||
const recordsToAdd = [...pageRecords, ...otherRecords, ...shapeRecords]
|
||||
store.put(recordsToAdd)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error loading offline data:`, error)
|
||||
}
|
||||
|
||||
setStoreWithStatus({
|
||||
store,
|
||||
status: "synced-remote", // Use synced-remote so Board renders
|
||||
connectionStatus: "offline",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// If doc has data but store doesn't, patches should have been generated when data was written
|
||||
// The automergeChangeHandler (set up above) should process them automatically
|
||||
// Just wait a bit for patches to be processed, then set status
|
||||
if (docShapes > 0 && existingStoreShapes.length === 0) {
|
||||
|
||||
console.log(`📊 Doc has ${docShapes} shapes but store is empty. Waiting for patches to be processed by handler...`)
|
||||
|
||||
// Wait briefly for patches to be processed by automergeChangeHandler
|
||||
// The handler is already set up, so it should catch patches from the initial data load
|
||||
let attempts = 0
|
||||
const maxAttempts = 10 // Wait up to 2 seconds (10 * 200ms)
|
||||
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const checkForPatches = () => {
|
||||
attempts++
|
||||
const currentShapes = store.allRecords().filter((r: any) => r.typeName === 'shape')
|
||||
|
||||
|
||||
if (currentShapes.length > 0) {
|
||||
|
||||
console.log(`✅ Patches applied successfully: ${currentShapes.length} shapes loaded via patches`)
|
||||
|
||||
// REMOVED: Aggressive shape refresh that was causing coordinate loss
|
||||
// Shapes loaded via patches should be visible without forced refresh
|
||||
|
||||
|
||||
setStoreWithStatus({
|
||||
store,
|
||||
status: "synced-remote",
|
||||
connectionStatus,
|
||||
connectionStatus: "online",
|
||||
})
|
||||
resolve()
|
||||
} else if (attempts < maxAttempts) {
|
||||
|
|
@ -1245,43 +1378,45 @@ export function useAutomergeStoreV2({
|
|||
console.warn(`⚠️ No patches received after ${maxAttempts} attempts for room initialization.`)
|
||||
console.warn(`⚠️ This may happen if Automerge doc was initialized with server data before handler was ready.`)
|
||||
console.warn(`⚠️ Store will remain empty - patches should handle data loading in normal operation.`)
|
||||
|
||||
|
||||
// Simplified fallback: Just log and continue with empty store
|
||||
// Patches should handle data loading, so if they don't come through,
|
||||
// it's likely the document is actually empty or there's a timing issue
|
||||
// that will resolve on next sync
|
||||
|
||||
|
||||
setStoreWithStatus({
|
||||
store,
|
||||
status: "synced-remote",
|
||||
connectionStatus,
|
||||
connectionStatus: "online",
|
||||
})
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Start checking immediately since handler is already set up
|
||||
setTimeout(checkForPatches, 100)
|
||||
})
|
||||
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// If doc is empty, just set status
|
||||
if (docShapes === 0) {
|
||||
console.log(`📊 Empty document - starting fresh (patch-based loading)`)
|
||||
setStoreWithStatus({
|
||||
store,
|
||||
status: "synced-remote",
|
||||
connectionStatus,
|
||||
connectionStatus: "online",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// No store in doc - empty document
|
||||
console.log(`📊 No store in Automerge doc - starting fresh (patch-based loading)`)
|
||||
setStoreWithStatus({
|
||||
store,
|
||||
status: "synced-remote",
|
||||
connectionStatus: isNetworkOnline ? "online" : "offline",
|
||||
connectionStatus: "online",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -1290,17 +1425,17 @@ export function useAutomergeStoreV2({
|
|||
setStoreWithStatus({
|
||||
store,
|
||||
status: "synced-remote",
|
||||
connectionStatus: isNetworkOnline ? "online" : "offline",
|
||||
connectionStatus: "online",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
initializeStore()
|
||||
|
||||
|
||||
return () => {
|
||||
unsubs.forEach((unsub) => unsub())
|
||||
}
|
||||
}, [handle, store, isNetworkOnline])
|
||||
}, [handle, store])
|
||||
|
||||
/* -------------------- Presence -------------------- */
|
||||
// Create a safe handle that won't cause null errors
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ function migrateStoreData(store: Record<string, any>): Record<string, any> {
|
|||
return store
|
||||
}
|
||||
|
||||
console.log('🔄 Migrating store data: fixing invalid shape indices')
|
||||
|
||||
// Copy non-shape records as-is
|
||||
for (const [id, record] of nonShapes) {
|
||||
|
|
@ -98,6 +99,7 @@ function migrateStoreData(store: Record<string, any>): Record<string, any> {
|
|||
migratedStore[id] = migratedRecord
|
||||
}
|
||||
|
||||
console.log(`✅ Migrated ${shapes.length} shapes with new indices`)
|
||||
return migratedStore
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +119,6 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
presence: ReturnType<typeof useAutomergePresence>;
|
||||
connectionState: ConnectionState;
|
||||
isNetworkOnline: boolean;
|
||||
syncVersion: number;
|
||||
} {
|
||||
const { uri, user } = config
|
||||
|
||||
|
|
@ -136,8 +137,6 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('connecting')
|
||||
const [isNetworkOnline, setIsNetworkOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
||||
// Sync version counter - increments when server data is merged, forces re-render
|
||||
const [syncVersion, setSyncVersion] = useState(0)
|
||||
const handleRef = useRef<any>(null)
|
||||
const storeRef = useRef<any>(null)
|
||||
const adapterRef = useRef<any>(null)
|
||||
|
|
@ -161,6 +160,22 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const deletedRecordIds = data.deleted || []
|
||||
const deletedShapes = deletedRecordIds.filter(id => id.startsWith('shape:'))
|
||||
|
||||
// Log incoming sync data for debugging
|
||||
console.log(`📥 Received JSON sync: ${changedRecordCount} records (${shapeRecords.length} shapes), ${deletedRecordIds.length} deletions (${deletedShapes.length} shapes)`)
|
||||
if (shapeRecords.length > 0) {
|
||||
shapeRecords.forEach((shape: any) => {
|
||||
console.log(`📥 Shape update: ${shape.type} ${shape.id}`, {
|
||||
x: shape.x,
|
||||
y: shape.y,
|
||||
w: shape.props?.w,
|
||||
h: shape.props?.h
|
||||
})
|
||||
})
|
||||
}
|
||||
if (deletedShapes.length > 0) {
|
||||
console.log(`📥 Shape deletions:`, deletedShapes)
|
||||
}
|
||||
|
||||
// Apply changes to the Automerge document
|
||||
// This will trigger patches which will update the TLDraw store
|
||||
// NOTE: We do NOT increment pendingLocalChanges here because these are REMOTE changes
|
||||
|
|
@ -185,31 +200,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
}
|
||||
})
|
||||
|
||||
}, [])
|
||||
|
||||
// Presence update batching to prevent "Maximum update depth exceeded" errors
|
||||
// We batch presence updates and apply them in a single mergeRemoteChanges call
|
||||
const pendingPresenceUpdates = useRef<Map<string, any>>(new Map())
|
||||
const presenceUpdateTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
const PRESENCE_BATCH_INTERVAL_MS = 16 // ~60fps, batch updates every frame
|
||||
|
||||
// Flush pending presence updates to the store
|
||||
const flushPresenceUpdates = useCallback(() => {
|
||||
const currentStore = storeRef.current
|
||||
if (!currentStore || pendingPresenceUpdates.current.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const updates = Array.from(pendingPresenceUpdates.current.values())
|
||||
pendingPresenceUpdates.current.clear()
|
||||
|
||||
try {
|
||||
currentStore.mergeRemoteChanges(() => {
|
||||
currentStore.put(updates)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ Error flushing presence updates:', error)
|
||||
}
|
||||
console.log(`✅ Applied ${changedRecordCount} records and ${deletedRecordIds.length} deletions to Automerge document`)
|
||||
}, [])
|
||||
|
||||
// Presence update callback - applies presence from other clients
|
||||
|
|
@ -265,54 +256,23 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
lastActivityTimestamp: Date.now()
|
||||
})
|
||||
|
||||
// Queue the presence update for batched application
|
||||
pendingPresenceUpdates.current.set(presenceId, instancePresence)
|
||||
|
||||
// Schedule a flush if not already scheduled
|
||||
if (!presenceUpdateTimer.current) {
|
||||
presenceUpdateTimer.current = setTimeout(() => {
|
||||
presenceUpdateTimer.current = null
|
||||
flushPresenceUpdates()
|
||||
}, PRESENCE_BATCH_INTERVAL_MS)
|
||||
}
|
||||
// Apply the instance_presence record using mergeRemoteChanges for atomic updates
|
||||
currentStore.mergeRemoteChanges(() => {
|
||||
currentStore.put([instancePresence])
|
||||
})
|
||||
|
||||
// Presence applied for remote user
|
||||
} catch (error) {
|
||||
console.error('❌ Error applying presence:', error)
|
||||
}
|
||||
}, [flushPresenceUpdates])
|
||||
|
||||
// Handle presence leave - remove the user's presence record from the store
|
||||
const handlePresenceLeave = useCallback((sessionId: string) => {
|
||||
const currentStore = storeRef.current
|
||||
if (!currentStore) return
|
||||
|
||||
try {
|
||||
// Find and remove the presence record for this session
|
||||
// Presence IDs are formatted as "instance_presence:{sessionId}"
|
||||
const presenceId = `instance_presence:${sessionId}`
|
||||
|
||||
// Check if this record exists before trying to remove it
|
||||
const allRecords = currentStore.allRecords()
|
||||
const presenceRecord = allRecords.find((r: any) =>
|
||||
r.id === presenceId ||
|
||||
r.id?.includes(sessionId)
|
||||
)
|
||||
|
||||
if (presenceRecord) {
|
||||
currentStore.remove([presenceRecord.id])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing presence on leave:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
const { repo, adapter, storageAdapter } = useMemo(() => {
|
||||
const adapter = new CloudflareNetworkAdapter(
|
||||
workerUrl,
|
||||
roomId,
|
||||
applyJsonSyncData,
|
||||
applyPresenceUpdate,
|
||||
handlePresenceLeave
|
||||
applyPresenceUpdate
|
||||
)
|
||||
|
||||
// Store adapter ref for use in callbacks
|
||||
|
|
@ -335,7 +295,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
})
|
||||
|
||||
return { repo, adapter, storageAdapter }
|
||||
}, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate, handlePresenceLeave])
|
||||
}, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate])
|
||||
|
||||
// Subscribe to connection state changes
|
||||
useEffect(() => {
|
||||
|
|
@ -352,8 +312,11 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
const initializeHandle = async () => {
|
||||
try {
|
||||
// OFFLINE-FIRST: Load from IndexedDB immediately, don't wait for network
|
||||
// Network sync happens in the background after local data is loaded
|
||||
// CRITICAL: Wait for the network adapter to be ready before creating document
|
||||
// This ensures the WebSocket connection is established for sync
|
||||
await adapter.whenReady()
|
||||
|
||||
if (!mounted) return
|
||||
|
||||
let handle: DocHandle<TLStoreSnapshot>
|
||||
let loadedFromLocal = false
|
||||
|
|
@ -363,6 +326,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const storedDocumentId = await getDocumentId(roomId)
|
||||
|
||||
if (storedDocumentId) {
|
||||
console.log(`Found stored document ID for room ${roomId}: ${storedDocumentId}`)
|
||||
try {
|
||||
// Parse the URL to get the DocumentId
|
||||
const parsed = parseAutomergeUrl(storedDocumentId as AutomergeUrl)
|
||||
|
|
@ -374,6 +338,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
let foundHandle: DocHandle<TLStoreSnapshot>
|
||||
if (existingHandle) {
|
||||
console.log(`Document ${docId} already in repo cache, reusing handle`)
|
||||
foundHandle = existingHandle
|
||||
} else {
|
||||
// Try to find the existing document in the repo (loads from IndexedDB)
|
||||
|
|
@ -389,12 +354,14 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const localShapeCount = localDoc?.store ? Object.values(localDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
|
||||
if (localRecordCount > 0) {
|
||||
console.log(`Loaded document from IndexedDB: ${localRecordCount} records, ${localShapeCount} shapes`)
|
||||
|
||||
// CRITICAL: Migrate local IndexedDB data to fix any invalid indices
|
||||
// This ensures shapes with old-format indices like "b1" are fixed
|
||||
if (localDoc?.store) {
|
||||
const migratedStore = migrateStoreData(localDoc.store)
|
||||
if (migratedStore !== localDoc.store) {
|
||||
console.log('🔄 Applying index migration to local IndexedDB data')
|
||||
handle.change((doc: any) => {
|
||||
doc.store = migratedStore
|
||||
})
|
||||
|
|
@ -403,6 +370,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
loadedFromLocal = true
|
||||
} else {
|
||||
console.log(`Document found in IndexedDB but is empty, will load from server`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load document ${storedDocumentId} from IndexedDB:`, error)
|
||||
|
|
@ -412,6 +380,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
// If we didn't load from local storage, create a new document
|
||||
if (!loadedFromLocal || !handle!) {
|
||||
console.log(`Creating new Automerge document for room ${roomId}`)
|
||||
handle = repo.create<TLStoreSnapshot>()
|
||||
await handle.whenReady()
|
||||
|
||||
|
|
@ -419,166 +388,113 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const documentId = handle.url
|
||||
if (documentId) {
|
||||
await saveDocumentId(roomId, documentId)
|
||||
console.log(`Saved new document mapping: ${roomId} -> ${documentId}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return
|
||||
|
||||
// OFFLINE-FIRST: Set the handle and mark as ready BEFORE network sync
|
||||
// This allows the UI to render immediately with local data
|
||||
if (handle.url) {
|
||||
adapter.setDocumentId(handle.url)
|
||||
}
|
||||
// Sync with server to get latest data (or upload local changes if offline was edited)
|
||||
// This ensures we're in sync even if we loaded from IndexedDB
|
||||
try {
|
||||
const response = await fetch(`${workerUrl}/room/${roomId}`)
|
||||
if (response.ok) {
|
||||
let serverDoc = await response.json() as TLStoreSnapshot
|
||||
|
||||
// If we loaded from local, set handle immediately so UI can render
|
||||
if (loadedFromLocal) {
|
||||
const localDoc = handle.doc() as any
|
||||
const localShapeCount = localDoc?.store ? Object.values(localDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
setHandle(handle)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
// Sync with server in the background (non-blocking for offline-first)
|
||||
// This runs in parallel - if it fails, we still have local data
|
||||
const syncWithServer = async () => {
|
||||
try {
|
||||
// Wait for network adapter with a timeout
|
||||
const networkReadyPromise = adapter.whenReady()
|
||||
const timeoutPromise = new Promise<'timeout'>((resolve) =>
|
||||
setTimeout(() => resolve('timeout'), 5000)
|
||||
)
|
||||
|
||||
const result = await Promise.race([networkReadyPromise, timeoutPromise])
|
||||
|
||||
if (result === 'timeout') {
|
||||
// If we haven't set the handle yet (no local data), set it now
|
||||
if (!loadedFromLocal && mounted) {
|
||||
setHandle(handle)
|
||||
setIsLoading(false)
|
||||
// Migrate server data to fix any invalid indices
|
||||
if (serverDoc.store) {
|
||||
serverDoc = {
|
||||
...serverDoc,
|
||||
store: migrateStoreData(serverDoc.store)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!mounted) return
|
||||
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
const serverRecordCount = Object.keys(serverDoc.store || {}).length
|
||||
|
||||
const response = await fetch(`${workerUrl}/room/${roomId}`)
|
||||
if (response.ok) {
|
||||
let serverDoc = await response.json() as TLStoreSnapshot
|
||||
// Get current local state
|
||||
const localDoc = handle.doc()
|
||||
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
|
||||
|
||||
// Migrate server data to fix any invalid indices
|
||||
if (serverDoc.store) {
|
||||
serverDoc = {
|
||||
...serverDoc,
|
||||
store: migrateStoreData(serverDoc.store)
|
||||
// Merge server data with local data
|
||||
// Strategy:
|
||||
// 1. If local is EMPTY, use server data (bootstrap from R2)
|
||||
// 2. If local HAS data, only add server records that don't exist locally
|
||||
// (preserve offline changes, let Automerge CRDT sync handle conflicts)
|
||||
if (serverDoc.store && serverRecordCount > 0) {
|
||||
handle.change((doc: any) => {
|
||||
// Initialize store if it doesn't exist
|
||||
if (!doc.store) {
|
||||
doc.store = {}
|
||||
}
|
||||
}
|
||||
|
||||
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
const serverRecordCount = Object.keys(serverDoc.store || {}).length
|
||||
const localIsEmpty = Object.keys(doc.store).length === 0
|
||||
let addedFromServer = 0
|
||||
let skippedExisting = 0
|
||||
|
||||
// Get current local state
|
||||
const localDoc = handle.doc()
|
||||
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
|
||||
|
||||
// Merge server data with local data
|
||||
// Strategy (IMPROVED):
|
||||
// 1. Server is the source of truth for initial page load
|
||||
// 2. Always update local with server data for shape records
|
||||
// 3. Keep local-only records (potential offline additions not yet synced)
|
||||
// 4. This ensures stale IndexedDB cache doesn't override server data
|
||||
if (serverDoc.store && serverRecordCount > 0) {
|
||||
// Track if we merged any data (needed outside the change callback)
|
||||
let totalMerged = 0
|
||||
|
||||
handle.change((doc: any) => {
|
||||
// Initialize store if it doesn't exist
|
||||
if (!doc.store) {
|
||||
doc.store = {}
|
||||
Object.entries(serverDoc.store).forEach(([id, record]) => {
|
||||
if (localIsEmpty) {
|
||||
// Local is empty - bootstrap everything from server
|
||||
doc.store[id] = record
|
||||
addedFromServer++
|
||||
} else if (!doc.store[id]) {
|
||||
// Local has data but missing this record - add from server
|
||||
// This handles: shapes created on another device and synced to R2
|
||||
doc.store[id] = record
|
||||
addedFromServer++
|
||||
} else {
|
||||
// Record exists locally - preserve local version
|
||||
// The Automerge binary sync will handle merging conflicts via CRDT
|
||||
// This preserves offline edits to existing shapes
|
||||
skippedExisting++
|
||||
}
|
||||
|
||||
// Count LOCAL SHAPES (not just records - ignore ephemeral camera/instance records)
|
||||
const localShapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
const localIsEmpty = Object.keys(doc.store).length === 0
|
||||
|
||||
// IMPROVED: Server is source of truth on initial load
|
||||
// Prefer server if:
|
||||
// - Local is empty (first load or cleared cache)
|
||||
// - Server has more shapes (local is likely stale/incomplete)
|
||||
// - Local has shapes but server has different/more content
|
||||
const serverHasMoreContent = serverShapeCount > localShapeCount
|
||||
const shouldPreferServer = localIsEmpty || localShapeCount === 0 || serverHasMoreContent
|
||||
|
||||
let addedFromServer = 0
|
||||
let updatedFromServer = 0
|
||||
let keptLocal = 0
|
||||
|
||||
Object.entries(serverDoc.store).forEach(([id, record]) => {
|
||||
const existsLocally = !!doc.store[id]
|
||||
|
||||
if (!existsLocally) {
|
||||
// Record doesn't exist locally - add from server
|
||||
doc.store[id] = record
|
||||
addedFromServer++
|
||||
} else if (shouldPreferServer) {
|
||||
// Record exists locally but server has more content - update with server version
|
||||
// This handles stale IndexedDB cache scenarios
|
||||
doc.store[id] = record
|
||||
updatedFromServer++
|
||||
} else {
|
||||
// Local has equal or more content - keep local version
|
||||
// Local changes will sync to server via normal CRDT mechanism
|
||||
keptLocal++
|
||||
}
|
||||
})
|
||||
|
||||
totalMerged = addedFromServer + updatedFromServer
|
||||
console.log(`🔄 Server sync: added=${addedFromServer}, updated=${updatedFromServer}, keptLocal=${keptLocal}, serverShapes=${serverShapeCount}, localShapes=${localShapeCount}, preferServer=${shouldPreferServer}`)
|
||||
})
|
||||
|
||||
const finalDoc = handle.doc()
|
||||
const finalRecordCount = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
||||
console.log(`📥 Merge strategy: local was ${localIsEmpty ? 'EMPTY' : 'populated'}, added ${addedFromServer} from server, preserved ${skippedExisting} local records`)
|
||||
})
|
||||
|
||||
// CRITICAL: Force React to re-render after merging server data
|
||||
// The handle object reference doesn't change, so we increment syncVersion
|
||||
if (totalMerged > 0 && mounted) {
|
||||
console.log(`🔄 Forcing UI update after server sync (${totalMerged} records merged)`)
|
||||
// Increment sync version to trigger React re-render
|
||||
setSyncVersion(v => v + 1)
|
||||
}
|
||||
} else if (!loadedFromLocal) {
|
||||
// Server is empty and we didn't load from local - fresh start
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
// No document found on server
|
||||
if (loadedFromLocal) {
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
console.warn(`Failed to load document from server: ${response.status} ${response.statusText}`)
|
||||
const finalDoc = handle.doc()
|
||||
const finalRecordCount = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
||||
console.log(`Merged server data: server had ${serverRecordCount}, local had ${localRecordCount}, final has ${finalRecordCount} records`)
|
||||
} else if (!loadedFromLocal) {
|
||||
// Server is empty and we didn't load from local - fresh start
|
||||
console.log(`Starting fresh - no data on server or locally`)
|
||||
}
|
||||
} catch (error) {
|
||||
// Network error - continue with local data if available
|
||||
} else if (response.status === 404) {
|
||||
// No document found on server
|
||||
if (loadedFromLocal) {
|
||||
console.log(`No server document, but loaded ${handle.doc()?.store ? Object.keys(handle.doc()!.store).length : 0} records from local storage`)
|
||||
} else {
|
||||
console.error("Error loading from server (offline?):", error)
|
||||
console.log(`No document found on server - starting fresh`)
|
||||
}
|
||||
} else {
|
||||
console.warn(`Failed to load document from server: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Verify final document state
|
||||
const finalDoc = handle.doc() as any
|
||||
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
||||
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
|
||||
// If we haven't set the handle yet (no local data), set it now after server sync
|
||||
if (!loadedFromLocal && mounted) {
|
||||
setHandle(handle)
|
||||
setIsLoading(false)
|
||||
} catch (error) {
|
||||
// Network error - continue with local data if available
|
||||
if (loadedFromLocal) {
|
||||
console.log(`Offline mode: using local data from IndexedDB`)
|
||||
} else {
|
||||
console.error("Error loading from server (offline?):", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Start server sync in background (don't await - non-blocking)
|
||||
syncWithServer()
|
||||
// Verify final document state
|
||||
const finalDoc = handle.doc() as any
|
||||
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
||||
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
console.log(`Automerge handle ready: ${finalStoreKeys} records, ${finalShapeCount} shapes (loaded from ${loadedFromLocal ? 'IndexedDB' : 'server/new'})`)
|
||||
|
||||
// CRITICAL: Set the documentId on the adapter BEFORE setHandle
|
||||
// This ensures the adapter can properly route incoming binary sync messages
|
||||
// The server may send sync messages immediately after connection, before we send anything
|
||||
if (handle.url) {
|
||||
adapter.setDocumentId(handle.url)
|
||||
console.log(`📋 Set documentId on adapter: ${handle.url}`)
|
||||
}
|
||||
|
||||
setHandle(handle)
|
||||
setIsLoading(false)
|
||||
} catch (error) {
|
||||
console.error("Error initializing Automerge handle:", error)
|
||||
if (mounted) {
|
||||
|
|
@ -591,11 +507,6 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
return () => {
|
||||
mounted = false
|
||||
// Clear any pending presence update timer
|
||||
if (presenceUpdateTimer.current) {
|
||||
clearTimeout(presenceUpdateTimer.current)
|
||||
presenceUpdateTimer.current = null
|
||||
}
|
||||
// Disconnect adapter on unmount to clean up WebSocket connection
|
||||
if (adapter) {
|
||||
adapter.disconnect?.()
|
||||
|
|
@ -638,6 +549,18 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
return
|
||||
}
|
||||
|
||||
// Log significant changes for debugging
|
||||
const shapePatches = payload.patches.filter((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||
})
|
||||
|
||||
if (shapePatches.length > 0) {
|
||||
console.log('🔄 Automerge document changed (binary sync will propagate):', {
|
||||
patchCount: patchCount,
|
||||
shapePatches: shapePatches.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
handle.on('change', changeHandler)
|
||||
|
|
@ -661,26 +584,20 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
}
|
||||
|
||||
// Get user metadata for presence
|
||||
// Color is generated from the username (name) for consistency across sessions,
|
||||
// not from the unique session ID (userId) which changes per tab/session
|
||||
const userMetadata: { userId: string; name: string; color: string } = (() => {
|
||||
if (user && 'userId' in user) {
|
||||
const uid = (user as { userId: string; name: string; color?: string }).userId
|
||||
const name = (user as { userId: string; name: string; color?: string }).name
|
||||
return {
|
||||
userId: uid,
|
||||
name: name,
|
||||
// Use name for color (consistent across sessions), fall back to uid if no name
|
||||
color: (user as { userId: string; name: string; color?: string }).color || generateUserColor(name || uid)
|
||||
name: (user as { userId: string; name: string; color?: string }).name,
|
||||
color: (user as { userId: string; name: string; color?: string }).color || generateUserColor(uid)
|
||||
}
|
||||
}
|
||||
const uid = user?.id || 'anonymous'
|
||||
const name = user?.name || 'Anonymous'
|
||||
return {
|
||||
userId: uid,
|
||||
name: name,
|
||||
// Use name for color (consistent across sessions), fall back to uid if no name
|
||||
color: generateUserColor(name !== 'Anonymous' ? name : uid)
|
||||
name: user?.name || 'Anonymous',
|
||||
color: generateUserColor(uid)
|
||||
}
|
||||
})()
|
||||
|
||||
|
|
@ -688,8 +605,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const storeWithStatus = useAutomergeStoreV2({
|
||||
handle: handle || null as any,
|
||||
userId: userMetadata.userId,
|
||||
adapter: adapter, // Pass adapter for JSON sync broadcasting
|
||||
isNetworkOnline // Pass network state for offline support
|
||||
adapter: adapter // Pass adapter for JSON sync broadcasting
|
||||
})
|
||||
|
||||
// Update store ref when store is available
|
||||
|
|
@ -712,7 +628,6 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
handle,
|
||||
presence,
|
||||
connectionState,
|
||||
isNetworkOnline,
|
||||
syncVersion // Increments when server data is merged, forces re-render
|
||||
isNetworkOnline
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
getActivityLog,
|
||||
ActivityEntry,
|
||||
formatActivityTime,
|
||||
getShapeDisplayName,
|
||||
groupActivitiesByDate,
|
||||
} from '../lib/activityLogger';
|
||||
import '../css/activity-panel.css';
|
||||
|
||||
interface ActivityPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ActivityPanel({ isOpen, onClose }: ActivityPanelProps) {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [activities, setActivities] = useState<ActivityEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load activities and refresh periodically
|
||||
useEffect(() => {
|
||||
if (!slug || !isOpen) return;
|
||||
|
||||
const loadActivities = () => {
|
||||
const log = getActivityLog(slug, 50);
|
||||
setActivities(log);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadActivities();
|
||||
|
||||
// Refresh every 5 seconds when panel is open
|
||||
const interval = setInterval(loadActivities, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [slug, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const groupedActivities = groupActivitiesByDate(activities);
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
switch (action) {
|
||||
case 'created': return '+';
|
||||
case 'deleted': return '-';
|
||||
case 'updated': return '~';
|
||||
default: return '?';
|
||||
}
|
||||
};
|
||||
|
||||
const getActionClass = (action: string) => {
|
||||
switch (action) {
|
||||
case 'created': return 'activity-action-created';
|
||||
case 'deleted': return 'activity-action-deleted';
|
||||
case 'updated': return 'activity-action-updated';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="activity-panel">
|
||||
<div className="activity-panel-header">
|
||||
<h3>Activity</h3>
|
||||
<button className="activity-panel-close" onClick={onClose} title="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="activity-panel-content">
|
||||
{isLoading ? (
|
||||
<div className="activity-loading">Loading...</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="activity-empty">
|
||||
<div className="activity-empty-icon">~</div>
|
||||
<p>No activity yet</p>
|
||||
<p className="activity-empty-hint">Actions will appear here as you work</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="activity-list">
|
||||
{Array.from(groupedActivities.entries()).map(([dateGroup, entries]) => (
|
||||
<div key={dateGroup} className="activity-group">
|
||||
<div className="activity-group-header">{dateGroup}</div>
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="activity-item">
|
||||
<span className={`activity-icon ${getActionClass(entry.action)}`}>
|
||||
{getActionIcon(entry.action)}
|
||||
</span>
|
||||
<div className="activity-details">
|
||||
<span className="activity-text">
|
||||
<span className="activity-user">{entry.user}</span>
|
||||
{' '}
|
||||
{entry.action === 'created' ? 'added' :
|
||||
entry.action === 'deleted' ? 'deleted' : 'updated'}
|
||||
{' '}
|
||||
<span className="activity-shape">{getShapeDisplayName(entry.shapeType)}</span>
|
||||
</span>
|
||||
<span className="activity-time">{formatActivityTime(entry.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Note: ActivityToggleButton has been removed - activity panel is now toggled
|
||||
// from the settings dropdown via a custom event 'toggle-activity-panel'
|
||||
|
|
@ -1,629 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { WORKER_URL } from '../constants/workerUrl';
|
||||
import * as crypto from '../lib/auth/crypto';
|
||||
|
||||
interface BoardSettingsDropdownProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface BoardInfo {
|
||||
id: string;
|
||||
name: string | null;
|
||||
isProtected: boolean;
|
||||
ownerUsername: string | null;
|
||||
}
|
||||
|
||||
interface Editor {
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string;
|
||||
permission: string;
|
||||
grantedAt: string;
|
||||
}
|
||||
|
||||
const BoardSettingsDropdown: React.FC<BoardSettingsDropdownProps> = ({ className = '' }) => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { session } = useAuth();
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [boardInfo, setBoardInfo] = useState<BoardInfo | null>(null);
|
||||
const [editors, setEditors] = useState<Editor[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [isGlobalAdmin, setIsGlobalAdmin] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [requestingAdmin, setRequestingAdmin] = useState(false);
|
||||
const [adminRequestSent, setAdminRequestSent] = useState(false);
|
||||
const [adminRequestError, setAdminRequestError] = useState<string | null>(null);
|
||||
const [inviteInput, setInviteInput] = useState('');
|
||||
const [inviteStatus, setInviteStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownMenuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
|
||||
|
||||
const boardId = slug || 'mycofi33';
|
||||
|
||||
// Get auth headers
|
||||
const getAuthHeaders = (): Record<string, string> => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (session.authed && session.username) {
|
||||
const publicKey = crypto.getPublicKey(session.username);
|
||||
if (publicKey) {
|
||||
headers['X-CryptID-PublicKey'] = publicKey;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
// Fetch board info and admin status
|
||||
const fetchBoardData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
|
||||
// Fetch board info
|
||||
const infoRes = await fetch(`${WORKER_URL}/boards/${boardId}/info`, { headers });
|
||||
const infoData = await infoRes.json() as { board?: BoardInfo };
|
||||
if (infoData.board) {
|
||||
setBoardInfo(infoData.board);
|
||||
}
|
||||
|
||||
// Fetch permission to check if admin
|
||||
const permRes = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, { headers });
|
||||
const permData = await permRes.json() as { permission?: string; isGlobalAdmin?: boolean };
|
||||
setIsAdmin(permData.permission === 'admin');
|
||||
setIsGlobalAdmin(permData.isGlobalAdmin || false);
|
||||
|
||||
// If admin, fetch editors list
|
||||
if (permData.permission === 'admin') {
|
||||
const editorsRes = await fetch(`${WORKER_URL}/boards/${boardId}/editors`, { headers });
|
||||
const editorsData = await editorsRes.json() as { editors?: Editor[] };
|
||||
setEditors(editorsData.editors || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch board data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle board protection
|
||||
const toggleProtection = async () => {
|
||||
if (!boardInfo || updating) return;
|
||||
|
||||
setUpdating(true);
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
const res = await fetch(`${WORKER_URL}/boards/${boardId}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({ isProtected: !boardInfo.isProtected }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setBoardInfo(prev => prev ? { ...prev, isProtected: !prev.isProtected } : null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle protection:', error);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Request admin access
|
||||
const requestAdminAccess = async () => {
|
||||
if (requestingAdmin || adminRequestSent) return;
|
||||
|
||||
setRequestingAdmin(true);
|
||||
setAdminRequestError(null);
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
const res = await fetch(`${WORKER_URL}/admin/request`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ reason: `Requesting admin access for board: ${boardId}` }),
|
||||
});
|
||||
|
||||
const data = await res.json() as { success?: boolean; message?: string; error?: string };
|
||||
|
||||
if (res.ok && data.success) {
|
||||
setAdminRequestSent(true);
|
||||
} else {
|
||||
setAdminRequestError(data.error || data.message || 'Failed to send request');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to request admin:', error);
|
||||
setAdminRequestError('Network error - please try again');
|
||||
} finally {
|
||||
setRequestingAdmin(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Invite user as editor
|
||||
const inviteEditor = async () => {
|
||||
if (!inviteInput.trim() || inviteStatus === 'sending') return;
|
||||
|
||||
setInviteStatus('sending');
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
const res = await fetch(`${WORKER_URL}/boards/${boardId}/permissions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
usernameOrEmail: inviteInput.trim(),
|
||||
permission: 'edit',
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setInviteStatus('sent');
|
||||
setInviteInput('');
|
||||
// Refresh editors list
|
||||
fetchBoardData();
|
||||
setTimeout(() => setInviteStatus('idle'), 2000);
|
||||
} else {
|
||||
setInviteStatus('error');
|
||||
setTimeout(() => setInviteStatus('idle'), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to invite editor:', error);
|
||||
setInviteStatus('error');
|
||||
setTimeout(() => setInviteStatus('idle'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove editor
|
||||
const removeEditor = async (userId: string) => {
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
await fetch(`${WORKER_URL}/boards/${boardId}/permissions/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
setEditors(prev => prev.filter(e => e.userId !== userId));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove editor:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Update dropdown position when it opens
|
||||
useEffect(() => {
|
||||
if (showDropdown && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + 8,
|
||||
right: window.innerWidth - rect.right,
|
||||
});
|
||||
fetchBoardData();
|
||||
}
|
||||
}, [showDropdown]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
const isInsideTrigger = dropdownRef.current && dropdownRef.current.contains(target);
|
||||
const isInsideMenu = dropdownMenuRef.current && dropdownMenuRef.current.contains(target);
|
||||
if (!isInsideTrigger && !isInsideMenu) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
if (showDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [showDropdown]);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} style={{ pointerEvents: 'all' }}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className={`board-settings-button ${className}`}
|
||||
title="Board Settings"
|
||||
style={{
|
||||
background: showDropdown ? 'var(--color-muted-2)' : 'none',
|
||||
border: 'none',
|
||||
padding: '6px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--color-text-1)',
|
||||
opacity: showDropdown ? 1 : 0.7,
|
||||
transition: 'opacity 0.15s, background 0.15s',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!showDropdown) {
|
||||
e.currentTarget.style.opacity = '0.7';
|
||||
e.currentTarget.style.background = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Settings gear icon */}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{showDropdown && dropdownPosition && createPortal(
|
||||
<div
|
||||
ref={dropdownMenuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: dropdownPosition.top,
|
||||
right: dropdownPosition.right,
|
||||
width: '320px',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-panel)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
zIndex: 100000,
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
}}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '12px 14px',
|
||||
borderBottom: '1px solid var(--color-panel-contrast)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '14px' }}>⚙</span> Board Settings
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowDropdown(false)}
|
||||
style={{
|
||||
background: 'var(--color-muted-2)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
color: 'var(--color-text-3)',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'inherit',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--color-text-3)' }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
|
||||
{/* Board Info Section */}
|
||||
<div style={{ padding: '14px', background: 'var(--color-muted-2)', borderBottom: '1px solid var(--color-panel-contrast)' }}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
Board Info
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-1)' }}>
|
||||
<div style={{ marginBottom: '6px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ color: 'var(--color-text-3)', minWidth: '50px' }}>ID:</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '11px' }}>{boardId}</span>
|
||||
</div>
|
||||
{boardInfo?.ownerUsername && (
|
||||
<div style={{ marginBottom: '6px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ color: 'var(--color-text-3)', minWidth: '50px' }}>Owner:</span>
|
||||
<span>@{boardInfo.ownerUsername}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ color: 'var(--color-text-3)', minWidth: '50px' }}>Status:</span>
|
||||
<span style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
background: boardInfo?.isProtected ? '#fef3c7' : '#d1fae5',
|
||||
color: boardInfo?.isProtected ? '#92400e' : '#065f46',
|
||||
}}>
|
||||
{boardInfo?.isProtected ? 'Protected' : 'Open'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Section - Protection Settings */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div style={{ padding: '14px', background: 'var(--color-panel)', borderBottom: '1px solid var(--color-panel-contrast)' }}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Protection {isGlobalAdmin && <span style={{ color: '#3b82f6', fontWeight: 500, fontSize: '10px' }}>(Global Admin)</span>}
|
||||
</div>
|
||||
|
||||
{/* Protection Toggle */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--color-muted-2)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||
View-only Mode
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: 'var(--color-text-3)' }}>
|
||||
Only listed editors can make changes
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleProtection}
|
||||
disabled={updating}
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '24px',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
cursor: updating ? 'not-allowed' : 'pointer',
|
||||
background: boardInfo?.isProtected ? '#3b82f6' : '#d1d5db',
|
||||
position: 'relative',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '10px',
|
||||
background: 'white',
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
left: boardInfo?.isProtected ? '22px' : '2px',
|
||||
transition: 'left 0.2s',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Management (only when protected) */}
|
||||
{boardInfo?.isProtected && (
|
||||
<div style={{ padding: '14px', background: 'var(--color-muted-2)', borderBottom: '1px solid var(--color-panel-contrast)' }}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
Editors ({editors.length})
|
||||
</div>
|
||||
|
||||
{/* Add Editor Input */}
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username or email..."
|
||||
value={inviteInput}
|
||||
onChange={(e) => setInviteInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') inviteEditor();
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'inherit',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--color-panel)',
|
||||
color: 'var(--color-text)',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={inviteEditor}
|
||||
disabled={!inviteInput.trim() || inviteStatus === 'sending'}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
backgroundColor: inviteStatus === 'sent' ? '#10b981' : '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: !inviteInput.trim() || inviteStatus === 'sending' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'inherit',
|
||||
opacity: !inviteInput.trim() ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{inviteStatus === 'sending' ? '...' : inviteStatus === 'sent' ? 'Added' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Editor List */}
|
||||
<div style={{ maxHeight: '150px', overflowY: 'auto' }}>
|
||||
{editors.length === 0 ? (
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', textAlign: 'center', padding: '10px' }}>
|
||||
No editors added yet
|
||||
</div>
|
||||
) : (
|
||||
editors.map((editor) => (
|
||||
<div
|
||||
key={editor.userId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '4px',
|
||||
background: 'var(--color-panel)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||
@{editor.username}
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: 'var(--color-text-3)' }}>
|
||||
{editor.permission}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeEditor(editor.userId)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#ef4444',
|
||||
fontSize: '14px',
|
||||
padding: '4px',
|
||||
}}
|
||||
title="Remove editor"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Request Admin Access (for non-admins) */}
|
||||
{!isAdmin && session.authed && (
|
||||
<div style={{ padding: '14px', background: 'var(--color-panel)', borderBottom: '1px solid var(--color-panel-contrast)' }}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="8.5" cy="7" r="4"/>
|
||||
<line x1="20" y1="8" x2="20" y2="14"/>
|
||||
<line x1="23" y1="11" x2="17" y2="11"/>
|
||||
</svg>
|
||||
Admin Access
|
||||
</div>
|
||||
<button
|
||||
onClick={requestAdminAccess}
|
||||
disabled={requestingAdmin || adminRequestSent}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
backgroundColor: adminRequestSent ? '#10b981' : adminRequestError ? '#ef4444' : 'var(--color-muted-2)',
|
||||
color: adminRequestSent || adminRequestError ? 'white' : 'var(--color-text)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '8px',
|
||||
cursor: requestingAdmin || adminRequestSent ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{requestingAdmin ? 'Sending request...' : adminRequestSent ? 'Request Sent!' : adminRequestError ? 'Retry Request' : 'Request Admin Access'}
|
||||
</button>
|
||||
{adminRequestError && (
|
||||
<div style={{ fontSize: '10px', color: '#ef4444', marginTop: '6px', textAlign: 'center' }}>
|
||||
{adminRequestError}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '10px', color: 'var(--color-text-3)', marginTop: '6px', textAlign: 'center' }}>
|
||||
Admin requests are sent to jeffemmett@gmail.com
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sign in prompt for anonymous users */}
|
||||
{!session.authed && (
|
||||
<div style={{ padding: '14px', background: 'var(--color-muted-2)', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
|
||||
Sign in to access board settings
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSettingsDropdown;
|
||||
|
|
@ -1,670 +0,0 @@
|
|||
// Calendar Panel - Month/Week view with event list
|
||||
// Used inside CalendarBrowserShape
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react"
|
||||
import {
|
||||
useCalendarEvents,
|
||||
type DecryptedCalendarEvent,
|
||||
} from "@/hooks/useCalendarEvents"
|
||||
|
||||
interface CalendarPanelProps {
|
||||
onClose?: () => void
|
||||
onEventSelect?: (event: DecryptedCalendarEvent) => void
|
||||
shapeMode?: boolean
|
||||
initialView?: "month" | "week"
|
||||
initialDate?: Date
|
||||
}
|
||||
|
||||
type ViewMode = "month" | "week"
|
||||
|
||||
// Helper functions
|
||||
const getDaysInMonth = (year: number, month: number) => {
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
const getFirstDayOfMonth = (year: number, month: number) => {
|
||||
const day = new Date(year, month, 1).getDay()
|
||||
// Convert Sunday (0) to 7 for Monday-first week
|
||||
return day === 0 ? 6 : day - 1
|
||||
}
|
||||
|
||||
const formatMonthYear = (date: Date) => {
|
||||
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
||||
}
|
||||
|
||||
const isSameDay = (date1: Date, date2: Date) => {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
const isToday = (date: Date) => {
|
||||
return isSameDay(date, new Date())
|
||||
}
|
||||
|
||||
export function CalendarPanel({
|
||||
onClose: _onClose,
|
||||
onEventSelect,
|
||||
shapeMode: _shapeMode = false,
|
||||
initialView = "month",
|
||||
initialDate,
|
||||
}: CalendarPanelProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(initialView)
|
||||
const [currentDate, setCurrentDate] = useState(initialDate || new Date())
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
||||
|
||||
// Detect dark mode
|
||||
const isDarkMode =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark")
|
||||
|
||||
// Get calendar events for the visible range
|
||||
const startOfVisibleRange = useMemo(() => {
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth()
|
||||
// Start from previous month to show leading days
|
||||
return new Date(year, month - 1, 1)
|
||||
}, [currentDate])
|
||||
|
||||
const endOfVisibleRange = useMemo(() => {
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth()
|
||||
// End at next month to show trailing days
|
||||
return new Date(year, month + 2, 0)
|
||||
}, [currentDate])
|
||||
|
||||
const { events, loading, error, refresh, getEventsForDate, getUpcoming } =
|
||||
useCalendarEvents({
|
||||
startDate: startOfVisibleRange,
|
||||
endDate: endOfVisibleRange,
|
||||
})
|
||||
|
||||
// Colors
|
||||
const colors = isDarkMode
|
||||
? {
|
||||
bg: "#1a1a1a",
|
||||
cardBg: "#252525",
|
||||
headerBg: "#22c55e",
|
||||
text: "#e4e4e7",
|
||||
textMuted: "#a1a1aa",
|
||||
border: "#404040",
|
||||
todayBg: "#22c55e20",
|
||||
selectedBg: "#3b82f620",
|
||||
eventDot: "#3b82f6",
|
||||
buttonBg: "#374151",
|
||||
buttonHover: "#4b5563",
|
||||
}
|
||||
: {
|
||||
bg: "#ffffff",
|
||||
cardBg: "#f9fafb",
|
||||
headerBg: "#22c55e",
|
||||
text: "#1f2937",
|
||||
textMuted: "#6b7280",
|
||||
border: "#e5e7eb",
|
||||
todayBg: "#22c55e15",
|
||||
selectedBg: "#3b82f615",
|
||||
eventDot: "#3b82f6",
|
||||
buttonBg: "#f3f4f6",
|
||||
buttonHover: "#e5e7eb",
|
||||
}
|
||||
|
||||
// Navigation handlers
|
||||
const goToPrevious = () => {
|
||||
if (viewMode === "month") {
|
||||
setCurrentDate(
|
||||
new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)
|
||||
)
|
||||
} else {
|
||||
const newDate = new Date(currentDate)
|
||||
newDate.setDate(newDate.getDate() - 7)
|
||||
setCurrentDate(newDate)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (viewMode === "month") {
|
||||
setCurrentDate(
|
||||
new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
|
||||
)
|
||||
} else {
|
||||
const newDate = new Date(currentDate)
|
||||
newDate.setDate(newDate.getDate() + 7)
|
||||
setCurrentDate(newDate)
|
||||
}
|
||||
}
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date())
|
||||
setSelectedDate(new Date())
|
||||
}
|
||||
|
||||
// Generate month grid
|
||||
const monthGrid = useMemo(() => {
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth()
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const firstDay = getFirstDayOfMonth(year, month)
|
||||
|
||||
const days: { date: Date; isCurrentMonth: boolean }[] = []
|
||||
|
||||
// Previous month days
|
||||
const prevMonth = month === 0 ? 11 : month - 1
|
||||
const prevYear = month === 0 ? year - 1 : year
|
||||
const daysInPrevMonth = getDaysInMonth(prevYear, prevMonth)
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
days.push({
|
||||
date: new Date(prevYear, prevMonth, daysInPrevMonth - i),
|
||||
isCurrentMonth: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Current month days
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push({
|
||||
date: new Date(year, month, i),
|
||||
isCurrentMonth: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Next month days to complete grid
|
||||
const nextMonth = month === 11 ? 0 : month + 1
|
||||
const nextYear = month === 11 ? year + 1 : year
|
||||
const remainingDays = 42 - days.length // 6 rows * 7 days
|
||||
for (let i = 1; i <= remainingDays; i++) {
|
||||
days.push({
|
||||
date: new Date(nextYear, nextMonth, i),
|
||||
isCurrentMonth: false,
|
||||
})
|
||||
}
|
||||
|
||||
return days
|
||||
}, [currentDate])
|
||||
|
||||
// Format event time
|
||||
const formatEventTime = (event: DecryptedCalendarEvent) => {
|
||||
if (event.isAllDay) return "All day"
|
||||
return event.startTime.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Upcoming events for sidebar
|
||||
const upcomingEvents = useMemo(() => {
|
||||
return getUpcoming(10)
|
||||
}, [getUpcoming])
|
||||
|
||||
// Events for selected date
|
||||
const selectedDateEvents = useMemo(() => {
|
||||
if (!selectedDate) return []
|
||||
return getEventsForDate(selectedDate)
|
||||
}, [selectedDate, getEventsForDate])
|
||||
|
||||
// Day cell component
|
||||
const DayCell = ({
|
||||
date,
|
||||
isCurrentMonth,
|
||||
}: {
|
||||
date: Date
|
||||
isCurrentMonth: boolean
|
||||
}) => {
|
||||
const dayEvents = getEventsForDate(date)
|
||||
const isSelectedDate = selectedDate && isSameDay(date, selectedDate)
|
||||
const isTodayDate = isToday(date)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => setSelectedDate(date)}
|
||||
style={{
|
||||
padding: "4px",
|
||||
minHeight: "60px",
|
||||
cursor: "pointer",
|
||||
backgroundColor: isSelectedDate
|
||||
? colors.selectedBg
|
||||
: isTodayDate
|
||||
? colors.todayBg
|
||||
: "transparent",
|
||||
borderRadius: "4px",
|
||||
border: isTodayDate
|
||||
? `2px solid ${colors.headerBg}`
|
||||
: "1px solid transparent",
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelectedDate && !isTodayDate) {
|
||||
e.currentTarget.style.backgroundColor = colors.buttonBg
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelectedDate && !isTodayDate) {
|
||||
e.currentTarget.style.backgroundColor = "transparent"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: isTodayDate ? "700" : "500",
|
||||
color: isCurrentMonth ? colors.text : colors.textMuted,
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{date.getDate()}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px" }}>
|
||||
{dayEvents.slice(0, 3).map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
style={{
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: colors.eventDot,
|
||||
}}
|
||||
title={event.summary}
|
||||
/>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "9px",
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
+{dayEvents.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Event list item
|
||||
const EventItem = ({ event }: { event: DecryptedCalendarEvent }) => (
|
||||
<div
|
||||
onClick={() => onEventSelect?.(event)}
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
backgroundColor: colors.cardBg,
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
borderLeft: `3px solid ${colors.eventDot}`,
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = colors.buttonBg
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = colors.cardBg
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: "600",
|
||||
color: colors.text,
|
||||
marginBottom: "4px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{event.summary}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: colors.textMuted,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<span>{formatEventTime(event)}</span>
|
||||
{event.location && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{event.location}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div style={{ fontSize: "24px", marginBottom: "8px" }}>Loading...</div>
|
||||
<div style={{ fontSize: "12px" }}>Fetching calendar events</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center", padding: "20px" }}>
|
||||
<div style={{ fontSize: "24px", marginBottom: "8px" }}>Error</div>
|
||||
<div style={{ fontSize: "12px", marginBottom: "16px" }}>{error}</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
backgroundColor: colors.headerBg,
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No events state
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center", padding: "20px" }}>
|
||||
<div style={{ fontSize: "48px", marginBottom: "16px" }}>📅</div>
|
||||
<div style={{ fontSize: "16px", fontWeight: "600", marginBottom: "8px" }}>
|
||||
No Calendar Events
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", marginBottom: "16px" }}>
|
||||
Import your Google Calendar to see events here.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
backgroundColor: colors.bg,
|
||||
color: colors.text,
|
||||
}}
|
||||
>
|
||||
{/* Main Calendar Area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRight: `1px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
{/* Navigation Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
backgroundColor: colors.buttonBg,
|
||||
color: colors.text,
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
fontSize: "16px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{formatMonthYear(currentDate)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
backgroundColor: colors.buttonBg,
|
||||
color: colors.text,
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToToday}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
backgroundColor: colors.headerBg,
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
|
||||
{/* View toggle */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
backgroundColor: colors.buttonBg,
|
||||
borderRadius: "6px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setViewMode("month")}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
backgroundColor:
|
||||
viewMode === "month" ? colors.headerBg : "transparent",
|
||||
color: viewMode === "month" ? "#fff" : colors.text,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
Month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("week")}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
backgroundColor:
|
||||
viewMode === "week" ? colors.headerBg : "transparent",
|
||||
color: viewMode === "week" ? "#fff" : colors.text,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
Week
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div style={{ flex: 1, padding: "12px", overflow: "auto" }}>
|
||||
{/* Day headers */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "4px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "11px",
|
||||
fontWeight: "600",
|
||||
color: colors.textMuted,
|
||||
padding: "4px",
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
{monthGrid.map(({ date, isCurrentMonth }, i) => (
|
||||
<DayCell key={i} date={date} isCurrentMonth={isCurrentMonth} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Events */}
|
||||
<div
|
||||
style={{
|
||||
width: "280px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Selected Date Events or Upcoming */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "600",
|
||||
color: colors.textMuted,
|
||||
marginBottom: "12px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
}}
|
||||
>
|
||||
{selectedDate
|
||||
? selectedDate.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
: "Upcoming Events"}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{(selectedDate ? selectedDateEvents : upcomingEvents).map(
|
||||
(event) => (
|
||||
<EventItem key={event.id} event={event} />
|
||||
)
|
||||
)}
|
||||
|
||||
{(selectedDate ? selectedDateEvents : upcomingEvents).length ===
|
||||
0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "20px",
|
||||
color: colors.textMuted,
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{selectedDate
|
||||
? "No events on this day"
|
||||
: "No upcoming events"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Click hint */}
|
||||
{onEventSelect && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px",
|
||||
borderTop: `1px solid ${colors.border}`,
|
||||
fontSize: "11px",
|
||||
color: colors.textMuted,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Click an event to add it to the canvas
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CalendarPanel
|
||||
|
|
@ -40,8 +40,8 @@ export function ConnectionStatusIndicator({
|
|||
color: '#8b5cf6', // Purple - calm, not alarming
|
||||
icon: '🍄',
|
||||
pulse: false,
|
||||
description: 'Viewing locally saved canvas',
|
||||
detailedMessage: `You're viewing your locally cached canvas. All your previous work is safely stored in your browser. Any changes you make will be saved locally and automatically synced when you reconnect — no data will be lost.`,
|
||||
description: 'Your data is safe and encrypted locally',
|
||||
detailedMessage: `Your canvas is stored securely in your browser using encrypted local storage. All changes are preserved with your personal encryption key. When you reconnect, your work will automatically sync with the shared canvas — no data will be lost.`,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export function FathomMeetingsPanel({ onClose, onMeetingSelect, shapeMode = fals
|
|||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('Production worker failed, trying local worker...')
|
||||
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings`, {
|
||||
headers: {
|
||||
'X-Api-Key': key,
|
||||
|
|
@ -149,6 +150,13 @@ export function FathomMeetingsPanel({ onClose, onMeetingSelect, shapeMode = fals
|
|||
|
||||
// Handler for individual data type buttons - creates shapes directly
|
||||
const handleDataButtonClick = async (meeting: FathomMeeting, dataType: 'summary' | 'transcript' | 'actionItems' | 'video') => {
|
||||
// Log to verify the correct meeting is being used
|
||||
console.log('🔵 handleDataButtonClick called with meeting:', {
|
||||
recording_id: meeting.recording_id,
|
||||
title: meeting.title,
|
||||
dataType
|
||||
})
|
||||
|
||||
if (!onMeetingSelect) {
|
||||
// Fallback for non-browser mode
|
||||
const options = {
|
||||
|
|
@ -243,6 +251,7 @@ export function FathomMeetingsPanel({ onClose, onMeetingSelect, shapeMode = fals
|
|||
(callId ? `https://fathom.video/calls/${callId}` : null)
|
||||
|
||||
if (videoUrl) {
|
||||
console.log('Opening Fathom video URL:', videoUrl, 'for meeting:', { callId, recording_id: meeting.recording_id })
|
||||
window.open(videoUrl, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
console.error('Could not determine Fathom video URL for meeting:', meeting)
|
||||
|
|
@ -263,6 +272,7 @@ export function FathomMeetingsPanel({ onClose, onMeetingSelect, shapeMode = fals
|
|||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('Production worker failed, trying local worker...')
|
||||
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings/${meeting.recording_id}${includeTranscript ? '?include_transcript=true' : ''}`, {
|
||||
headers: {
|
||||
'X-Api-Key': apiKey,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export function GoogleDataTest() {
|
|||
const [viewItems, setViewItems] = useState<ShareableItem[]>([]);
|
||||
|
||||
const addLog = (msg: string) => {
|
||||
console.log(msg);
|
||||
setLogs(prev => [...prev.slice(-20), `${new Date().toLocaleTimeString()}: ${msg}`]);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { holosphereService, HoloSphereService, HolonData, HolonLens, HOLON_ENABLED } from '@/lib/HoloSphereService'
|
||||
import { holosphereService, HoloSphereService, HolonData, HolonLens } from '@/lib/HoloSphereService'
|
||||
import * as h3 from 'h3-js'
|
||||
|
||||
interface HolonBrowserProps {
|
||||
|
|
@ -32,66 +32,6 @@ export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false
|
|||
const [isLoadingData, setIsLoadingData] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// If Holon functionality is disabled, show a disabled message
|
||||
if (!HOLON_ENABLED) {
|
||||
if (!isOpen) return null
|
||||
|
||||
const disabledContent = (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
height: '100%',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '64px', marginBottom: '24px' }}>🌐</div>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#374151', marginBottom: '12px' }}>
|
||||
Holon Browser Disabled
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#6b7280', maxWidth: '400px' }}>
|
||||
Holon functionality is currently disabled while awaiting Nostr integration.
|
||||
This feature will be re-enabled in a future update.
|
||||
</p>
|
||||
{!shapeMode && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6b7280',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (shapeMode) {
|
||||
return disabledContent
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden z-[10000]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{disabledContent}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
|
|
@ -142,6 +82,7 @@ export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false
|
|||
try {
|
||||
metadata = await holosphereService.getData(holonId, 'metadata')
|
||||
} catch (error) {
|
||||
console.log('No metadata found for holon')
|
||||
}
|
||||
|
||||
// Get available lenses by trying to fetch data from common lens types
|
||||
|
|
@ -160,6 +101,7 @@ export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false
|
|||
const data = await holosphereService.getDataWithWait(holonId, lens, 1000)
|
||||
if (data && (Array.isArray(data) ? data.length > 0 : Object.keys(data).length > 0)) {
|
||||
availableLenses.push(lens)
|
||||
console.log(`✓ Found lens: ${lens} with ${Object.keys(data).length} keys`)
|
||||
}
|
||||
} catch (error) {
|
||||
// Lens doesn't exist or is empty, skip
|
||||
|
|
@ -205,6 +147,7 @@ export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false
|
|||
// Use getDataWithWait for better Gun data retrieval
|
||||
const data = await holosphereService.getDataWithWait(holonInfo.id, lens, 2000)
|
||||
setLensData(data)
|
||||
console.log(`📊 Loaded lens data for ${lens}:`, data)
|
||||
} catch (error) {
|
||||
console.error('Error loading lens data:', error)
|
||||
setLensData(null)
|
||||
|
|
|
|||
|
|
@ -1,477 +0,0 @@
|
|||
/**
|
||||
* Miro Import Dialog
|
||||
*
|
||||
* A dialog component for importing Miro boards into the tldraw canvas.
|
||||
* Supports both JSON file upload and pasting JSON directly.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useEditor } from 'tldraw'
|
||||
import { importMiroJson, isValidMiroUrl, MiroImportResult } from '@/lib/miroImport'
|
||||
|
||||
interface MiroImportDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ImportMethod = 'json-file' | 'json-paste'
|
||||
|
||||
export function MiroImportDialog({ isOpen, onClose }: MiroImportDialogProps) {
|
||||
const editor = useEditor()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [importMethod, setImportMethod] = useState<ImportMethod>('json-file')
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [progress, setProgress] = useState({ stage: '', percent: 0 })
|
||||
const [result, setResult] = useState<MiroImportResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setJsonText('')
|
||||
setIsImporting(false)
|
||||
setProgress({ stage: '', percent: 0 })
|
||||
setResult(null)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [onClose, resetState])
|
||||
|
||||
const handleImport = useCallback(async (jsonString: string) => {
|
||||
setIsImporting(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
// Get current viewport center for import offset
|
||||
const viewportBounds = editor.getViewportPageBounds()
|
||||
const offset = {
|
||||
x: viewportBounds.x + viewportBounds.w / 2,
|
||||
y: viewportBounds.y + viewportBounds.h / 2,
|
||||
}
|
||||
|
||||
const importResult = await importMiroJson(
|
||||
jsonString,
|
||||
{
|
||||
migrateAssets: true,
|
||||
offset,
|
||||
},
|
||||
{
|
||||
onProgress: (stage, percent) => {
|
||||
setProgress({ stage, percent: Math.round(percent * 100) })
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
setResult(importResult)
|
||||
|
||||
if (importResult.success && importResult.shapes.length > 0) {
|
||||
// Create assets first
|
||||
if (importResult.assets.length > 0) {
|
||||
for (const asset of importResult.assets) {
|
||||
try {
|
||||
editor.createAssets([asset])
|
||||
} catch (e) {
|
||||
console.warn('Failed to create asset:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create shapes
|
||||
editor.createShapes(importResult.shapes)
|
||||
|
||||
// Select and zoom to imported shapes
|
||||
const shapeIds = importResult.shapes.map((s: any) => s.id)
|
||||
editor.setSelectedShapes(shapeIds)
|
||||
editor.zoomToSelection()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Import error:', e)
|
||||
setError(e instanceof Error ? e.message : 'Failed to import Miro board')
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const handleFileSelect = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
await handleImport(text)
|
||||
} catch (e) {
|
||||
setError('Failed to read file')
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}, [handleImport])
|
||||
|
||||
const handlePasteImport = useCallback(() => {
|
||||
if (!jsonText.trim()) {
|
||||
setError('Please paste Miro JSON data')
|
||||
return
|
||||
}
|
||||
handleImport(jsonText)
|
||||
}, [jsonText, handleImport])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="miro-import-overlay" onClick={handleClose}>
|
||||
<div className="miro-import-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="miro-import-header">
|
||||
<h2>Import from Miro</h2>
|
||||
<button className="miro-import-close" onClick={handleClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="miro-import-content">
|
||||
{/* Import Method Tabs */}
|
||||
<div className="miro-import-tabs">
|
||||
<button
|
||||
className={`miro-import-tab ${importMethod === 'json-file' ? 'active' : ''}`}
|
||||
onClick={() => setImportMethod('json-file')}
|
||||
disabled={isImporting}
|
||||
>
|
||||
Upload JSON File
|
||||
</button>
|
||||
<button
|
||||
className={`miro-import-tab ${importMethod === 'json-paste' ? 'active' : ''}`}
|
||||
onClick={() => setImportMethod('json-paste')}
|
||||
disabled={isImporting}
|
||||
>
|
||||
Paste JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* JSON File Upload */}
|
||||
{importMethod === 'json-file' && (
|
||||
<div className="miro-import-section">
|
||||
<p className="miro-import-help">
|
||||
Upload a JSON file exported from Miro using the{' '}
|
||||
<a href="https://github.com/jolle/miro-export" target="_blank" rel="noopener noreferrer">
|
||||
miro-export
|
||||
</a>{' '}
|
||||
CLI tool:
|
||||
</p>
|
||||
<pre className="miro-import-code">
|
||||
npx miro-export -b YOUR_BOARD_ID -e json -o board.json
|
||||
</pre>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
disabled={isImporting}
|
||||
className="miro-import-file-input"
|
||||
/>
|
||||
<button
|
||||
className="miro-import-button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isImporting}
|
||||
>
|
||||
Choose JSON File
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON Paste */}
|
||||
{importMethod === 'json-paste' && (
|
||||
<div className="miro-import-section">
|
||||
<p className="miro-import-help">
|
||||
Paste your Miro board JSON data below:
|
||||
</p>
|
||||
<textarea
|
||||
className="miro-import-textarea"
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
placeholder='[{"type":"sticky_note","id":"...","x":0,"y":0,...}]'
|
||||
disabled={isImporting}
|
||||
rows={10}
|
||||
/>
|
||||
<button
|
||||
className="miro-import-button"
|
||||
onClick={handlePasteImport}
|
||||
disabled={isImporting || !jsonText.trim()}
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress */}
|
||||
{isImporting && (
|
||||
<div className="miro-import-progress">
|
||||
<div className="miro-import-progress-bar">
|
||||
<div
|
||||
className="miro-import-progress-fill"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="miro-import-progress-text">{progress.stage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="miro-import-error">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className={`miro-import-result ${result.success ? 'success' : 'failed'}`}>
|
||||
{result.success ? (
|
||||
<>
|
||||
<p>Successfully imported {result.shapesCreated} shapes!</p>
|
||||
{result.assetsUploaded > 0 && (
|
||||
<p>Migrated {result.assetsUploaded} images to local storage.</p>
|
||||
)}
|
||||
{result.errors.length > 0 && (
|
||||
<p className="miro-import-warnings">
|
||||
Warnings: {result.errors.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<button className="miro-import-button" onClick={handleClose}>
|
||||
Done
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Import failed: {result.errors.join(', ')}</p>
|
||||
<button className="miro-import-button" onClick={resetState}>
|
||||
Try Again
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.miro-import-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;
|
||||
}
|
||||
|
||||
.miro-import-dialog {
|
||||
background: var(--color-panel, white);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.miro-import-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-divider, #eee);
|
||||
}
|
||||
|
||||
.miro-import-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.miro-import-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #333);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.miro-import-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.miro-import-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.miro-import-tab {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--color-divider, #ddd);
|
||||
background: var(--color-background, #f5f5f5);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.miro-import-tab:hover:not(:disabled) {
|
||||
background: var(--color-muted-1, #e0e0e0);
|
||||
}
|
||||
|
||||
.miro-import-tab.active {
|
||||
background: var(--color-primary, #2563eb);
|
||||
color: white;
|
||||
border-color: var(--color-primary, #2563eb);
|
||||
}
|
||||
|
||||
.miro-import-tab:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.miro-import-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.miro-import-help {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1, #666);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.miro-import-help a {
|
||||
color: var(--color-primary, #2563eb);
|
||||
}
|
||||
|
||||
.miro-import-code {
|
||||
background: var(--color-background, #f5f5f5);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.miro-import-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.miro-import-textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-divider, #ddd);
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
background: var(--color-background, white);
|
||||
color: var(--color-text, #333);
|
||||
}
|
||||
|
||||
.miro-import-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #2563eb);
|
||||
}
|
||||
|
||||
.miro-import-button {
|
||||
padding: 12px 24px;
|
||||
background: var(--color-primary, #2563eb);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.miro-import-button:hover:not(:disabled) {
|
||||
background: var(--color-primary-dark, #1d4ed8);
|
||||
}
|
||||
|
||||
.miro-import-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.miro-import-progress {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.miro-import-progress-bar {
|
||||
height: 8px;
|
||||
background: var(--color-background, #f0f0f0);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.miro-import-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary, #2563eb);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.miro-import-progress-text {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-1, #666);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.miro-import-error {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.miro-import-result {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.miro-import-result.success {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.miro-import-result.failed {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.miro-import-result p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.miro-import-warnings {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MiroImportDialog
|
||||
|
|
@ -1,884 +0,0 @@
|
|||
/**
|
||||
* Miro Integration Modal
|
||||
*
|
||||
* Allows users to import Miro boards into their canvas.
|
||||
* Supports two methods:
|
||||
* 1. Paste JSON from miro-export CLI tool (recommended for casual use)
|
||||
* 2. Connect Miro API for direct imports (power users)
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useEditor } from 'tldraw';
|
||||
import { importMiroJson } from '@/lib/miroImport';
|
||||
import {
|
||||
getMiroApiKey,
|
||||
saveMiroApiKey,
|
||||
removeMiroApiKey,
|
||||
isMiroApiKeyConfigured,
|
||||
extractMiroBoardId,
|
||||
isValidMiroBoardUrl,
|
||||
} from '@/lib/miroApiKey';
|
||||
|
||||
interface MiroIntegrationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
username: string;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
type Tab = 'import' | 'api-setup' | 'help';
|
||||
|
||||
export function MiroIntegrationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
username,
|
||||
isDarkMode: _isDarkMode = false,
|
||||
}: MiroIntegrationModalProps) {
|
||||
const editor = useEditor();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('import');
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [boardUrl, setBoardUrl] = useState('');
|
||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [progress, setProgress] = useState({ stage: '', percent: 0 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const hasApiKey = isMiroApiKeyConfigured(username);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setJsonText('');
|
||||
setBoardUrl('');
|
||||
setIsImporting(false);
|
||||
setProgress({ stage: '', percent: 0 });
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [onClose, resetState]);
|
||||
|
||||
// Import from JSON string
|
||||
const handleJsonImport = useCallback(async (json: string) => {
|
||||
setIsImporting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const viewportBounds = editor.getViewportPageBounds();
|
||||
const offset = {
|
||||
x: viewportBounds.x + viewportBounds.w / 2,
|
||||
y: viewportBounds.y + viewportBounds.h / 2,
|
||||
};
|
||||
|
||||
const result = await importMiroJson(
|
||||
json,
|
||||
{ migrateAssets: true, offset },
|
||||
{
|
||||
onProgress: (stage, percent) => {
|
||||
setProgress({ stage, percent: Math.round(percent * 100) });
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (result.success && result.shapes.length > 0) {
|
||||
// Create assets first
|
||||
for (const asset of result.assets) {
|
||||
try {
|
||||
editor.createAssets([asset]);
|
||||
} catch (e) {
|
||||
console.warn('Failed to create asset:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create shapes
|
||||
editor.createShapes(result.shapes);
|
||||
|
||||
// Select and zoom to imported shapes
|
||||
const shapeIds = result.shapes.map((s: any) => s.id);
|
||||
editor.setSelectedShapes(shapeIds);
|
||||
editor.zoomToSelection();
|
||||
|
||||
setSuccess(`Imported ${result.shapesCreated} shapes${result.assetsUploaded > 0 ? ` and ${result.assetsUploaded} images` : ''}!`);
|
||||
|
||||
// Auto-close after success
|
||||
setTimeout(() => handleClose(), 2000);
|
||||
} else {
|
||||
setError(result.errors.join(', ') || 'No shapes found in the import');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Import error:', e);
|
||||
setError(e instanceof Error ? e.message : 'Failed to import Miro board');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [editor, handleClose]);
|
||||
|
||||
// Handle file upload
|
||||
const handleFileSelect = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
await handleJsonImport(text);
|
||||
} catch (e) {
|
||||
setError('Failed to read file');
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}, [handleJsonImport]);
|
||||
|
||||
// Handle paste import
|
||||
const handlePasteImport = useCallback(() => {
|
||||
if (!jsonText.trim()) {
|
||||
setError('Please paste Miro JSON data');
|
||||
return;
|
||||
}
|
||||
handleJsonImport(jsonText);
|
||||
}, [jsonText, handleJsonImport]);
|
||||
|
||||
// Save API key
|
||||
const handleSaveApiKey = useCallback(() => {
|
||||
if (!apiKeyInput.trim()) {
|
||||
setError('Please enter your Miro API token');
|
||||
return;
|
||||
}
|
||||
saveMiroApiKey(apiKeyInput.trim(), username);
|
||||
setApiKeyInput('');
|
||||
setSuccess('Miro API token saved!');
|
||||
setTimeout(() => setSuccess(null), 2000);
|
||||
}, [apiKeyInput, username]);
|
||||
|
||||
// Disconnect API
|
||||
const handleDisconnectApi = useCallback(() => {
|
||||
removeMiroApiKey(username);
|
||||
setSuccess('Miro API disconnected');
|
||||
setTimeout(() => setSuccess(null), 2000);
|
||||
}, [username]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="miro-modal-overlay"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 999999,
|
||||
isolation: 'isolate',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) handleClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="miro-modal"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-panel, #ffffff)',
|
||||
borderRadius: '16px',
|
||||
width: '520px',
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 25px 80px rgba(0, 0, 0, 0.5)',
|
||||
position: 'relative',
|
||||
zIndex: 1000000,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid var(--color-panel-contrast)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #FFD02F 0%, #F2CA00 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
}}>
|
||||
📋
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Import from Miro
|
||||
</h2>
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)' }}>
|
||||
Bring your Miro boards into the canvas
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
fontSize: '18px',
|
||||
cursor: 'pointer',
|
||||
color: '#6b7280',
|
||||
padding: '6px 10px',
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#e5e7eb';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
e.currentTarget.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#f3f4f6';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
e.currentTarget.style.color = '#6b7280';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid var(--color-panel-contrast)',
|
||||
padding: '0 16px',
|
||||
}}>
|
||||
{[
|
||||
{ id: 'import', label: 'Import Board' },
|
||||
{ id: 'api-setup', label: 'API Setup' },
|
||||
{ id: 'help', label: 'How It Works' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as Tab)}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab.id ? '2px solid #FFD02F' : '2px solid transparent',
|
||||
color: activeTab === tab.id ? 'var(--color-text)' : 'var(--color-text-3)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}>
|
||||
{/* Import Tab */}
|
||||
{activeTab === 'import' && (
|
||||
<div>
|
||||
{/* Method 1: JSON Upload */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
background: '#FFD02F',
|
||||
color: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
}}>1</span>
|
||||
Upload JSON File
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 12px 0', fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.5 }}>
|
||||
Export your board using the miro-export CLI, then upload the JSON file here.
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isImporting}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '8px',
|
||||
border: '2px dashed #9ca3af',
|
||||
background: '#f9fafb',
|
||||
color: '#374151',
|
||||
cursor: isImporting ? 'wait' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isImporting) {
|
||||
e.currentTarget.style.borderColor = '#FFD02F';
|
||||
e.currentTarget.style.background = '#fffbeb';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#9ca3af';
|
||||
e.currentTarget.style.background = '#f9fafb';
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Choose JSON File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
margin: '20px 0',
|
||||
}}>
|
||||
<div style={{ flex: 1, height: '1px', background: 'var(--color-panel-contrast)' }} />
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-3)', textTransform: 'uppercase' }}>or</span>
|
||||
<div style={{ flex: 1, height: '1px', background: 'var(--color-panel-contrast)' }} />
|
||||
</div>
|
||||
|
||||
{/* Method 2: Paste JSON */}
|
||||
<div>
|
||||
<h3 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
background: '#FFD02F',
|
||||
color: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
}}>2</span>
|
||||
Paste JSON
|
||||
</h3>
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
placeholder='[{"type":"sticky_note","id":"..."}]'
|
||||
disabled={isImporting}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '120px',
|
||||
padding: '12px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #d1d5db',
|
||||
background: '#ffffff',
|
||||
color: '#1f2937',
|
||||
resize: 'vertical',
|
||||
marginBottom: '12px',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#FFD02F';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255, 208, 47, 0.2)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handlePasteImport}
|
||||
disabled={isImporting || !jsonText.trim()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '8px',
|
||||
border: jsonText.trim() ? '2px solid #e6b800' : '2px solid #d1d5db',
|
||||
background: jsonText.trim() ? 'linear-gradient(135deg, #FFD02F 0%, #F2CA00 100%)' : '#f3f4f6',
|
||||
color: jsonText.trim() ? '#000' : '#9ca3af',
|
||||
cursor: isImporting || !jsonText.trim() ? 'not-allowed' : 'pointer',
|
||||
boxShadow: jsonText.trim() ? '0 2px 8px rgba(255, 208, 47, 0.3)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{isImporting ? 'Importing...' : 'Import to Canvas'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{isImporting && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<div style={{
|
||||
height: '4px',
|
||||
background: 'var(--color-muted-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${progress.percent}%`,
|
||||
background: '#FFD02F',
|
||||
transition: 'width 0.3s',
|
||||
}} />
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 0', fontSize: '12px', color: 'var(--color-text-3)', textAlign: 'center' }}>
|
||||
{progress.stage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error/Success messages */}
|
||||
{error && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
fontSize: '13px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: '#dcfce7',
|
||||
color: '#16a34a',
|
||||
fontSize: '13px',
|
||||
}}>
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Setup Tab */}
|
||||
{activeTab === 'api-setup' && (
|
||||
<div>
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
background: hasApiKey ? 'rgba(34, 197, 94, 0.1)' : 'var(--color-muted-1)',
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<span style={{ fontSize: '24px' }}>{hasApiKey ? '✅' : '🔑'}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{hasApiKey ? 'Miro API Connected' : 'Connect Miro API'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)' }}>
|
||||
{hasApiKey
|
||||
? 'You can import boards directly from Miro'
|
||||
: 'For power users who want direct board imports'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasApiKey ? (
|
||||
<>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '8px',
|
||||
}}>
|
||||
Miro API Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
placeholder="Enter your Miro access token..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
fontSize: '14px',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #d1d5db',
|
||||
background: '#ffffff',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#FFD02F';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255, 208, 47, 0.2)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveApiKey();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveApiKey}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #e6b800',
|
||||
background: 'linear-gradient(135deg, #FFD02F 0%, #F2CA00 100%)',
|
||||
color: '#000',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 208, 47, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
Save API Token
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDisconnectApi}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #fca5a5',
|
||||
background: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#fecaca';
|
||||
e.currentTarget.style.borderColor = '#f87171';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#fee2e2';
|
||||
e.currentTarget.style.borderColor = '#fca5a5';
|
||||
}}
|
||||
>
|
||||
Disconnect Miro API
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* API Setup Instructions */}
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--color-muted-1)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px', fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
How to get your Miro API Token
|
||||
</h4>
|
||||
<ol style={{
|
||||
margin: 0,
|
||||
paddingLeft: '20px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-3)',
|
||||
lineHeight: 1.8,
|
||||
}}>
|
||||
<li>Go to <a href="https://miro.com/app/settings/user-profile/apps" target="_blank" rel="noopener noreferrer" style={{ color: '#FFD02F' }}>Miro Developer Settings</a></li>
|
||||
<li>Click "Create new app"</li>
|
||||
<li>Give it a name (e.g., "Canvas Import")</li>
|
||||
<li>Under "Permissions", enable:
|
||||
<ul style={{ margin: '4px 0', paddingLeft: '16px' }}>
|
||||
<li>boards:read</li>
|
||||
<li>boards:write (optional)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Click "Install app and get OAuth token"</li>
|
||||
<li>Select your team and authorize</li>
|
||||
<li>Copy the access token and paste it above</li>
|
||||
</ol>
|
||||
<p style={{ margin: '12px 0 0', fontSize: '11px', color: 'var(--color-text-3)' }}>
|
||||
Note: This is a one-time setup. Your token is stored locally and never sent to our servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
fontSize: '13px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: '#dcfce7',
|
||||
color: '#16a34a',
|
||||
fontSize: '13px',
|
||||
}}>
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help Tab */}
|
||||
{activeTab === 'help' && (
|
||||
<div>
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
background: 'linear-gradient(135deg, rgba(255, 208, 47, 0.1) 0%, rgba(242, 202, 0, 0.1) 100%)',
|
||||
border: '1px solid rgba(255, 208, 47, 0.3)',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Quick Start (Recommended)
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: '13px', color: 'var(--color-text-3)', lineHeight: 1.6 }}>
|
||||
The easiest way to import a Miro board is using the <code style={{
|
||||
background: 'var(--color-muted-2)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
}}>miro-export</code> CLI tool. This runs on your computer and exports your board as JSON.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h4 style={{ margin: '0 0 12px', fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Step-by-Step Instructions
|
||||
</h4>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{/* Step 1 */}
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
background: '#FFD02F',
|
||||
color: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}>1</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', marginBottom: '4px' }}>
|
||||
Find your Miro Board ID
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.5 }}>
|
||||
Open your board in Miro. The Board ID is in the URL:
|
||||
</p>
|
||||
<code style={{
|
||||
display: 'block',
|
||||
margin: '8px 0',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--color-muted-1)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--color-text)',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
miro.com/app/board/<span style={{ background: '#FFD02F', color: '#000', padding: '0 4px', borderRadius: '2px' }}>uXjVLxxxxxxxx=</span>/
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
background: '#FFD02F',
|
||||
color: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}>2</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', marginBottom: '4px' }}>
|
||||
Run the Export Command
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.5 }}>
|
||||
Open your terminal and run:
|
||||
</p>
|
||||
<code style={{
|
||||
display: 'block',
|
||||
margin: '8px 0',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--color-muted-1)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--color-text)',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
npx miro-export -b YOUR_BOARD_ID -e json -o board.json
|
||||
</code>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--color-text-3)' }}>
|
||||
This will open Miro in a browser window. Sign in if prompted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
background: '#FFD02F',
|
||||
color: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}>3</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', marginBottom: '4px' }}>
|
||||
Upload the JSON
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.5 }}>
|
||||
Go to the "Import Board" tab and upload your <code style={{
|
||||
background: 'var(--color-muted-2)',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '11px',
|
||||
}}>board.json</code> file. That's it!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What Gets Imported */}
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--color-muted-1)',
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px', fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
What Gets Imported
|
||||
</h4>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
}}>
|
||||
{[
|
||||
{ icon: '📝', label: 'Sticky Notes' },
|
||||
{ icon: '🔷', label: 'Shapes' },
|
||||
{ icon: '📄', label: 'Text' },
|
||||
{ icon: '🖼️', label: 'Images' },
|
||||
{ icon: '🔗', label: 'Connectors' },
|
||||
{ icon: '🖼️', label: 'Frames' },
|
||||
{ icon: '🃏', label: 'Cards' },
|
||||
].map((item) => (
|
||||
<div key={item.label} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
color: 'var(--color-text-3)',
|
||||
}}>
|
||||
<span style={{ fontSize: '14px' }}>{item.icon}</span>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p style={{
|
||||
margin: '12px 0 0',
|
||||
fontSize: '11px',
|
||||
color: 'var(--color-text-3)',
|
||||
fontStyle: 'italic',
|
||||
}}>
|
||||
Images are automatically downloaded and stored locally, so they'll persist even if you lose Miro access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MiroIntegrationModal;
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import React, { useState, useEffect, useMemo, useContext, useRef, useCallback } from 'react'
|
||||
import { ObsidianImporter, ObsidianObsNote, ObsidianVault, FolderNode, ObsidianVaultRecord, ObsidianVaultRecordLight, ObsidianObsNoteMeta, FolderNodeMeta } from '@/lib/obsidianImporter'
|
||||
import React, { useState, useEffect, useMemo, useContext, useRef } from 'react'
|
||||
import { ObsidianImporter, ObsidianObsNote, ObsidianVault, FolderNode, ObsidianVaultRecord } from '@/lib/obsidianImporter'
|
||||
import { AuthContext } from '@/context/AuthContext'
|
||||
import { useEditor } from '@tldraw/tldraw'
|
||||
import { useAutomergeHandle } from '@/context/AutomergeHandleContext'
|
||||
import { saveVaultNoteContents, getVaultNoteContents, getNoteContent, deleteVaultNoteContents } from '@/lib/noteContentStore'
|
||||
|
||||
interface ObsidianVaultBrowserProps {
|
||||
onObsNoteSelect: (obs_note: ObsidianObsNote) => void
|
||||
|
|
@ -80,156 +79,76 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
}
|
||||
}, [vault])
|
||||
|
||||
// Save vault to Automerge store (metadata only) and IndexedDB (content)
|
||||
const saveVaultToAutomerge = async (vault: ObsidianVault) => {
|
||||
// First, save full note content to IndexedDB (local-only, never synced)
|
||||
try {
|
||||
const noteContents = importer.extractNoteContents(vault)
|
||||
await saveVaultNoteContents(vault.name, noteContents)
|
||||
console.log(`Saved ${noteContents.length} note contents to IndexedDB for vault: ${vault.name}`)
|
||||
} catch (idbError) {
|
||||
console.error('Error saving note contents to IndexedDB:', idbError)
|
||||
}
|
||||
|
||||
// Create light record (no content) for Automerge sync
|
||||
const lightRecord = importer.vaultToLightRecord(vault)
|
||||
|
||||
// Save vault to Automerge store
|
||||
const saveVaultToAutomerge = (vault: ObsidianVault) => {
|
||||
if (!automergeHandle) {
|
||||
// No Automerge, just save light metadata to localStorage
|
||||
console.warn('⚠️ Automerge handle not available, saving to localStorage only')
|
||||
try {
|
||||
const vaultRecord = importer.vaultToRecord(vault)
|
||||
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
|
||||
...lightRecord,
|
||||
lastImported: lightRecord.lastImported instanceof Date ? lightRecord.lastImported.toISOString() : lightRecord.lastImported
|
||||
...vaultRecord,
|
||||
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
|
||||
}))
|
||||
console.log('🔧 Saved vault to localStorage (Automerge handle not available):', vaultRecord.id)
|
||||
} catch (localStorageError) {
|
||||
console.warn('Could not save vault metadata to localStorage:', localStorageError)
|
||||
console.warn('⚠️ Could not save vault to localStorage:', localStorageError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Save LIGHT record (no content) to Automerge - this is safe and won't overflow
|
||||
const vaultRecord = importer.vaultToRecord(vault)
|
||||
|
||||
// Save directly to Automerge, bypassing TLDraw store validation
|
||||
// This allows us to save custom record types like obsidian_vault
|
||||
automergeHandle.change((doc: any) => {
|
||||
// Ensure doc.store exists
|
||||
if (!doc.store) {
|
||||
doc.store = {}
|
||||
}
|
||||
|
||||
|
||||
// Save the vault record directly to Automerge store
|
||||
// Convert Date to ISO string for serialization
|
||||
const recordToSave = {
|
||||
...lightRecord,
|
||||
lastImported: lightRecord.lastImported instanceof Date
|
||||
? lightRecord.lastImported.toISOString()
|
||||
: lightRecord.lastImported
|
||||
...vaultRecord,
|
||||
lastImported: vaultRecord.lastImported instanceof Date
|
||||
? vaultRecord.lastImported.toISOString()
|
||||
: vaultRecord.lastImported
|
||||
}
|
||||
|
||||
doc.store[lightRecord.id] = recordToSave
|
||||
|
||||
doc.store[vaultRecord.id] = recordToSave
|
||||
})
|
||||
|
||||
console.log(`Saved light vault record to Automerge: ${lightRecord.id} (${lightRecord.totalObsNotes} notes, content stored locally)`)
|
||||
|
||||
// Also save light metadata to localStorage as backup
|
||||
|
||||
console.log('🔧 Saved vault to Automerge:', vaultRecord.id)
|
||||
|
||||
// Also save to localStorage as a backup
|
||||
try {
|
||||
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
|
||||
...lightRecord,
|
||||
lastImported: lightRecord.lastImported instanceof Date ? lightRecord.lastImported.toISOString() : lightRecord.lastImported
|
||||
...vaultRecord,
|
||||
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
|
||||
}))
|
||||
console.log('🔧 Saved vault to localStorage as backup:', vaultRecord.id)
|
||||
} catch (localStorageError) {
|
||||
// Silent fail for backup
|
||||
console.warn('⚠️ Could not save vault to localStorage:', localStorageError)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving vault to Automerge:', error)
|
||||
console.error('❌ Error saving vault to Automerge:', error)
|
||||
// Don't throw - allow vault loading to continue even if saving fails
|
||||
// Try localStorage as fallback
|
||||
try {
|
||||
const vaultRecord = importer.vaultToRecord(vault)
|
||||
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
|
||||
...lightRecord,
|
||||
lastImported: lightRecord.lastImported instanceof Date ? lightRecord.lastImported.toISOString() : lightRecord.lastImported
|
||||
...vaultRecord,
|
||||
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
|
||||
}))
|
||||
console.log('🔧 Saved vault to localStorage as fallback:', vaultRecord.id)
|
||||
} catch (localStorageError) {
|
||||
console.warn('Could not save vault to localStorage:', localStorageError)
|
||||
console.warn('⚠️ Could not save vault to localStorage:', localStorageError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear vault data from Automerge (for privacy - removes any synced vault data)
|
||||
const clearVaultFromAutomerge = useCallback((vaultName: string) => {
|
||||
const vaultId = `obsidian_vault:${vaultName}`
|
||||
|
||||
if (automergeHandle) {
|
||||
try {
|
||||
automergeHandle.change((doc: any) => {
|
||||
if (doc.store && doc.store[vaultId]) {
|
||||
delete doc.store[vaultId]
|
||||
console.log(`Cleared vault from Automerge: ${vaultId}`)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error clearing vault from Automerge:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Also clear from localStorage
|
||||
try {
|
||||
localStorage.removeItem(`obsidian_vault_cache:${vaultName}`)
|
||||
console.log(`Cleared vault from localStorage: ${vaultName}`)
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
}
|
||||
|
||||
// Clear from IndexedDB
|
||||
deleteVaultNoteContents(vaultName).then(() => {
|
||||
console.log(`Cleared vault content from IndexedDB: ${vaultName}`)
|
||||
}).catch(e => {
|
||||
console.error('Error clearing vault from IndexedDB:', e)
|
||||
})
|
||||
}, [automergeHandle])
|
||||
|
||||
// Helper to check if a note has content (distinguishes light vs full records)
|
||||
const noteHasContent = (note: ObsidianObsNote | ObsidianObsNoteMeta): note is ObsidianObsNote => {
|
||||
return 'content' in note && typeof note.content === 'string' && note.content.length > 0
|
||||
}
|
||||
|
||||
// Convert light note to full note with placeholder content
|
||||
const lightNoteToFullNote = (note: ObsidianObsNoteMeta, vaultName: string): ObsidianObsNote => {
|
||||
return {
|
||||
...note,
|
||||
content: '', // Will be loaded on-demand from IndexedDB
|
||||
vaultPath: note.vaultPath || vaultName
|
||||
}
|
||||
}
|
||||
|
||||
// Convert light folder tree to full folder tree (with empty content placeholders)
|
||||
const lightFolderTreeToFull = (node: FolderNodeMeta, vaultName: string): FolderNode => {
|
||||
return {
|
||||
name: node.name,
|
||||
path: node.path,
|
||||
children: node.children.map(child => lightFolderTreeToFull(child, vaultName)),
|
||||
notes: node.notes.map(note => lightNoteToFullNote(note, vaultName)),
|
||||
isExpanded: node.isExpanded,
|
||||
level: node.level
|
||||
}
|
||||
}
|
||||
|
||||
// Load content for a note from IndexedDB
|
||||
const loadNoteContentFromIDB = async (noteId: string): Promise<string> => {
|
||||
try {
|
||||
const content = await getNoteContent(noteId)
|
||||
return content || ''
|
||||
} catch (e) {
|
||||
console.error('Failed to load note content from IndexedDB:', e)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Load content for multiple notes from IndexedDB
|
||||
const loadVaultContentFromIDB = async (vaultName: string): Promise<Map<string, string>> => {
|
||||
try {
|
||||
return await getVaultNoteContents(vaultName)
|
||||
} catch (e) {
|
||||
console.error('Failed to load vault content from IndexedDB:', e)
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
// Load vault from Automerge store (handles both light and full records)
|
||||
// Load vault from Automerge store
|
||||
const loadVaultFromAutomerge = (vaultName: string): ObsidianVault | null => {
|
||||
// Try loading from Automerge first
|
||||
if (automergeHandle) {
|
||||
|
|
@ -237,69 +156,41 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
const doc = automergeHandle.doc()
|
||||
if (doc && doc.store) {
|
||||
const vaultId = `obsidian_vault:${vaultName}`
|
||||
const vaultRecord = doc.store[vaultId] as (ObsidianVaultRecord | ObsidianVaultRecordLight) | undefined
|
||||
|
||||
const vaultRecord = doc.store[vaultId] as ObsidianVaultRecord | undefined
|
||||
|
||||
if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') {
|
||||
console.log('🔧 Loaded vault from Automerge:', vaultId)
|
||||
// Convert date string back to Date object if needed
|
||||
const recordCopy = JSON.parse(JSON.stringify(vaultRecord))
|
||||
if (typeof recordCopy.lastImported === 'string') {
|
||||
recordCopy.lastImported = new Date(recordCopy.lastImported)
|
||||
}
|
||||
|
||||
// Check if this is a light record (notes don't have content)
|
||||
const isLightRecord = recordCopy.obs_notes.length > 0 && !noteHasContent(recordCopy.obs_notes[0])
|
||||
|
||||
if (isLightRecord) {
|
||||
// Convert light record to full vault with empty content placeholders
|
||||
return {
|
||||
name: recordCopy.name,
|
||||
path: recordCopy.path,
|
||||
obs_notes: recordCopy.obs_notes.map((n: ObsidianObsNoteMeta) => lightNoteToFullNote(n, vaultName)),
|
||||
totalObsNotes: recordCopy.totalObsNotes,
|
||||
lastImported: recordCopy.lastImported,
|
||||
folderTree: lightFolderTreeToFull(recordCopy.folderTree, vaultName)
|
||||
}
|
||||
}
|
||||
|
||||
return importer.recordToVault(recordCopy)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading from Automerge:', error)
|
||||
// Fall through to localStorage
|
||||
console.warn('⚠️ Could not load vault from Automerge:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Try localStorage as fallback
|
||||
try {
|
||||
const cached = localStorage.getItem(`obsidian_vault_cache:${vaultName}`)
|
||||
if (cached) {
|
||||
const vaultRecord = JSON.parse(cached) as (ObsidianVaultRecord | ObsidianVaultRecordLight)
|
||||
const vaultRecord = JSON.parse(cached) as ObsidianVaultRecord
|
||||
if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') {
|
||||
console.log('🔧 Loaded vault from localStorage cache:', vaultName)
|
||||
// Convert date string back to Date object
|
||||
if (typeof vaultRecord.lastImported === 'string') {
|
||||
vaultRecord.lastImported = new Date(vaultRecord.lastImported)
|
||||
}
|
||||
|
||||
// Check if this is a light record
|
||||
const isLightRecord = vaultRecord.obs_notes.length > 0 && !noteHasContent(vaultRecord.obs_notes[0])
|
||||
|
||||
if (isLightRecord) {
|
||||
return {
|
||||
name: vaultRecord.name,
|
||||
path: vaultRecord.path,
|
||||
obs_notes: vaultRecord.obs_notes.map((n: any) => lightNoteToFullNote(n, vaultName)),
|
||||
totalObsNotes: vaultRecord.totalObsNotes,
|
||||
lastImported: vaultRecord.lastImported,
|
||||
folderTree: lightFolderTreeToFull(vaultRecord.folderTree as FolderNodeMeta, vaultName)
|
||||
}
|
||||
}
|
||||
|
||||
return importer.recordToVault(vaultRecord as ObsidianVaultRecord)
|
||||
return importer.recordToVault(vaultRecord)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
console.warn('⚠️ Could not load vault from localStorage:', e)
|
||||
}
|
||||
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -307,31 +198,47 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
useEffect(() => {
|
||||
// Prevent multiple loads if already loading or already loaded once
|
||||
if (isLoadingVault || hasLoadedOnce) {
|
||||
console.log('🔧 ObsidianVaultBrowser: Skipping load - already loading or loaded once')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🔧 ObsidianVaultBrowser: Component mounted, checking user identity for vault...')
|
||||
console.log('🔧 Current session vault data:', {
|
||||
path: session.obsidianVaultPath,
|
||||
name: session.obsidianVaultName,
|
||||
authed: session.authed,
|
||||
username: session.username
|
||||
})
|
||||
|
||||
// FIRST PRIORITY: Try to load from user's configured vault in session (user identity)
|
||||
if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') {
|
||||
console.log('✅ Found configured vault in user identity:', session.obsidianVaultPath)
|
||||
console.log('🔧 Loading vault from user identity...')
|
||||
|
||||
// First try to load from Automerge cache for faster loading
|
||||
if (session.obsidianVaultName) {
|
||||
const cachedVault = loadVaultFromAutomerge(session.obsidianVaultName)
|
||||
if (cachedVault) {
|
||||
console.log('✅ Loaded vault from Automerge cache')
|
||||
setVault(cachedVault)
|
||||
setIsLoading(false)
|
||||
setHasLoadedOnce(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If not in cache, load from source (Quartz URL or local path)
|
||||
console.log('🔧 Loading vault from source:', session.obsidianVaultPath)
|
||||
loadVault(session.obsidianVaultPath)
|
||||
} else if (session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) {
|
||||
console.log('🔧 Vault was previously selected via folder picker, showing reselect interface')
|
||||
// For folder-selected vaults, we can't reload them, so show a special reselect interface
|
||||
setVault(null)
|
||||
setShowFolderReselect(true)
|
||||
setIsLoading(false)
|
||||
setHasLoadedOnce(true)
|
||||
} else {
|
||||
console.log('⚠️ No vault configured in user identity, showing empty state...')
|
||||
setVault(null)
|
||||
setIsLoading(false)
|
||||
setHasLoadedOnce(true)
|
||||
|
|
@ -343,28 +250,30 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
// Check if values actually changed (not just object reference)
|
||||
const vaultPathChanged = previousVaultPathRef.current !== session.obsidianVaultPath
|
||||
const vaultNameChanged = previousVaultNameRef.current !== session.obsidianVaultName
|
||||
|
||||
|
||||
// If vault is already loaded and values haven't changed, don't do anything
|
||||
if (hasLoadedOnce && !vaultPathChanged && !vaultNameChanged) {
|
||||
return
|
||||
return // Already loaded and nothing changed, no need to reload
|
||||
}
|
||||
|
||||
|
||||
// Update refs to current values
|
||||
previousVaultPathRef.current = session.obsidianVaultPath
|
||||
previousVaultNameRef.current = session.obsidianVaultName
|
||||
|
||||
|
||||
// Only proceed if values actually changed and we haven't loaded yet
|
||||
if (!vaultPathChanged && !vaultNameChanged) {
|
||||
return
|
||||
return // Values haven't changed, no need to reload
|
||||
}
|
||||
|
||||
|
||||
if (hasLoadedOnce || isLoadingVault) {
|
||||
return
|
||||
return // Don't reload if we've already loaded or are currently loading
|
||||
}
|
||||
|
||||
if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') {
|
||||
console.log('🔧 Session vault path changed, loading vault:', session.obsidianVaultPath)
|
||||
loadVault(session.obsidianVaultPath)
|
||||
} else if (session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) {
|
||||
console.log('🔧 Session shows folder-selected vault, showing reselect interface')
|
||||
setVault(null)
|
||||
setShowFolderReselect(true)
|
||||
setIsLoading(false)
|
||||
|
|
@ -375,6 +284,7 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
// Auto-open folder picker if requested
|
||||
useEffect(() => {
|
||||
if (autoOpenFolderPicker) {
|
||||
console.log('Auto-opening folder picker...')
|
||||
handleFolderPicker()
|
||||
}
|
||||
}, [autoOpenFolderPicker])
|
||||
|
|
@ -402,6 +312,7 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
console.log('🔧 ESC key pressed, closing vault browser')
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
|
@ -415,38 +326,57 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
const loadVault = async (path?: string) => {
|
||||
// Prevent concurrent loading operations
|
||||
if (isLoadingVault) {
|
||||
console.log('🔧 loadVault: Already loading, skipping concurrent request')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoadingVault(true)
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
|
||||
try {
|
||||
if (path) {
|
||||
// Check if it's a Quartz URL
|
||||
if (path.startsWith('http') || path.includes('quartz') || path.includes('.xyz') || path.includes('.com')) {
|
||||
// Load from Quartz URL - always get latest data
|
||||
console.log('🔧 Loading Quartz vault from URL (getting latest data):', path)
|
||||
const loadedVault = await importer.importFromQuartzUrl(path)
|
||||
console.log('Loaded Quartz vault from URL:', loadedVault)
|
||||
setVault(loadedVault)
|
||||
setShowVaultInput(false)
|
||||
setShowFolderReselect(false)
|
||||
updateSession({
|
||||
// Save the vault path and name to user session
|
||||
console.log('🔧 Saving Quartz vault to session:', { path, name: loadedVault.name })
|
||||
updateSession({
|
||||
obsidianVaultPath: path,
|
||||
obsidianVaultName: loadedVault.name
|
||||
})
|
||||
console.log('🔧 Quartz vault saved to session successfully')
|
||||
|
||||
// Save vault to Automerge for persistence
|
||||
saveVaultToAutomerge(loadedVault)
|
||||
} else {
|
||||
// Load from local directory
|
||||
console.log('🔧 Loading vault from local directory:', path)
|
||||
const loadedVault = await importer.importFromDirectory(path)
|
||||
console.log('Loaded vault from path:', loadedVault)
|
||||
setVault(loadedVault)
|
||||
setShowVaultInput(false)
|
||||
setShowFolderReselect(false)
|
||||
updateSession({
|
||||
// Save the vault path and name to user session
|
||||
console.log('🔧 Saving vault to session:', { path, name: loadedVault.name })
|
||||
updateSession({
|
||||
obsidianVaultPath: path,
|
||||
obsidianVaultName: loadedVault.name
|
||||
})
|
||||
console.log('🔧 Vault saved to session successfully')
|
||||
|
||||
// Save vault to Automerge for persistence
|
||||
saveVaultToAutomerge(loadedVault)
|
||||
}
|
||||
} else {
|
||||
// No vault configured - show empty state
|
||||
console.log('No vault configured, showing empty state...')
|
||||
setVault(null)
|
||||
setShowVaultInput(false)
|
||||
}
|
||||
|
|
@ -454,6 +384,8 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
console.error('Failed to load vault:', err)
|
||||
setError('Failed to load Obsidian vault. Please try again.')
|
||||
setVault(null)
|
||||
// Don't show vault input if user already has a vault configured
|
||||
// Only show vault input if this is a fresh attempt
|
||||
if (!session.obsidianVaultPath) {
|
||||
setShowVaultInput(true)
|
||||
}
|
||||
|
|
@ -469,8 +401,11 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
setError('Please enter a vault path or URL')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
console.log('📝 Submitting vault path:', vaultPath.trim(), 'Method:', inputMethod)
|
||||
|
||||
if (inputMethod === 'quartz') {
|
||||
// Handle Quartz URL
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
|
@ -478,49 +413,70 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
setVault(loadedVault)
|
||||
setShowVaultInput(false)
|
||||
setShowFolderReselect(false)
|
||||
updateSession({
|
||||
|
||||
// Save Quartz vault to user identity (session)
|
||||
console.log('🔧 Saving Quartz vault to user identity:', {
|
||||
path: vaultPath.trim(),
|
||||
name: loadedVault.name
|
||||
})
|
||||
updateSession({
|
||||
obsidianVaultPath: vaultPath.trim(),
|
||||
obsidianVaultName: loadedVault.name
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error loading Quartz vault:', error)
|
||||
console.error('❌ Error loading Quartz vault:', error)
|
||||
setError(error instanceof Error ? error.message : 'Failed to load Quartz vault')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
} else {
|
||||
// Handle regular vault path (local folder or URL)
|
||||
loadVault(vaultPath.trim())
|
||||
}
|
||||
}
|
||||
|
||||
const handleFolderPicker = async () => {
|
||||
console.log('📁 Folder picker button clicked')
|
||||
|
||||
if (!('showDirectoryPicker' in window)) {
|
||||
setError('File System Access API is not supported in this browser. Please use "Enter Path" instead.')
|
||||
setShowVaultInput(true)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
console.log('📁 Opening directory picker...')
|
||||
|
||||
const loadedVault = await importer.importFromFileSystem()
|
||||
|
||||
console.log('✅ Vault loaded from folder picker:', loadedVault.name)
|
||||
|
||||
setVault(loadedVault)
|
||||
setShowVaultInput(false)
|
||||
setShowFolderReselect(false)
|
||||
|
||||
updateSession({
|
||||
|
||||
// Note: We can't get the actual path from importFromFileSystem,
|
||||
// but we can save a flag that a folder was selected
|
||||
console.log('🔧 Saving folder-selected vault to user identity:', {
|
||||
path: 'folder-selected',
|
||||
name: loadedVault.name
|
||||
})
|
||||
updateSession({
|
||||
obsidianVaultPath: 'folder-selected',
|
||||
obsidianVaultName: loadedVault.name
|
||||
})
|
||||
|
||||
console.log('✅ Folder-selected vault saved to user identity successfully')
|
||||
|
||||
// Save vault to Automerge for persistence
|
||||
saveVaultToAutomerge(loadedVault)
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to load vault from folder picker:', err)
|
||||
if ((err as any).name === 'AbortError') {
|
||||
setError(null)
|
||||
// User cancelled the folder picker
|
||||
console.log('📁 User cancelled folder picker')
|
||||
setError(null) // Don't show error for cancellation
|
||||
} else {
|
||||
console.error('Failed to load vault from folder picker:', err)
|
||||
setError('Failed to load Obsidian vault. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -558,27 +514,45 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
const folderNotes = importer.getAllNotesFromTree(folder)
|
||||
obs_notes = obs_notes.filter(note => folderNotes.some(folderNote => folderNote.id === note.id))
|
||||
}
|
||||
} else if (viewMode === 'tree' && selectedFolder === null) {
|
||||
// In tree view but no folder selected, show all notes
|
||||
// This allows users to see all notes when no specific folder is selected
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('Search query:', debouncedSearchQuery)
|
||||
console.log('View mode:', viewMode)
|
||||
console.log('Selected folder:', selectedFolder)
|
||||
console.log('Total notes:', vault.obs_notes.length)
|
||||
console.log('Filtered notes:', obs_notes.length)
|
||||
|
||||
return obs_notes
|
||||
}, [vault, debouncedSearchQuery, viewMode, selectedFolder, folderTree, importer])
|
||||
|
||||
// Listen for trigger-obsnote-creation event from CustomToolbar
|
||||
useEffect(() => {
|
||||
const handleTriggerCreation = () => {
|
||||
console.log('🎯 ObsidianVaultBrowser: Received trigger-obsnote-creation event')
|
||||
|
||||
if (selectedNotes.size > 0) {
|
||||
// Create shapes from currently selected notes
|
||||
const selectedObsNotes = filteredObsNotes.filter(obs_note => selectedNotes.has(obs_note.id))
|
||||
console.log('🎯 Creating shapes from selected notes:', selectedObsNotes.length)
|
||||
onObsNotesSelect(selectedObsNotes)
|
||||
} else {
|
||||
// If no notes are selected, select all visible notes
|
||||
const allVisibleNotes = filteredObsNotes
|
||||
if (allVisibleNotes.length > 0) {
|
||||
console.log('🎯 No notes selected, creating shapes from all visible notes:', allVisibleNotes.length)
|
||||
onObsNotesSelect(allVisibleNotes)
|
||||
} else {
|
||||
console.log('🎯 No notes available to create shapes from')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('trigger-obsnote-creation', handleTriggerCreation as EventListener)
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('trigger-obsnote-creation', handleTriggerCreation as EventListener)
|
||||
}
|
||||
|
|
@ -688,14 +662,9 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
const handleObsNoteClick = async (obs_note: ObsidianObsNote) => {
|
||||
// Load content from IndexedDB if not already loaded
|
||||
if (!obs_note.content && vault) {
|
||||
const content = await loadNoteContentFromIDB(obs_note.id)
|
||||
onObsNoteSelect({ ...obs_note, content })
|
||||
} else {
|
||||
onObsNoteSelect(obs_note)
|
||||
}
|
||||
const handleObsNoteClick = (obs_note: ObsidianObsNote) => {
|
||||
console.log('🎯 ObsidianVaultBrowser: handleObsNoteClick called with:', obs_note)
|
||||
onObsNoteSelect(obs_note)
|
||||
}
|
||||
|
||||
const handleObsNoteToggle = (obs_note: ObsidianObsNote) => {
|
||||
|
|
@ -708,21 +677,10 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
setSelectedNotes(newSelected)
|
||||
}
|
||||
|
||||
const handleBulkImport = async () => {
|
||||
const handleBulkImport = () => {
|
||||
const selectedObsNotes = filteredObsNotes.filter(obs_note => selectedNotes.has(obs_note.id))
|
||||
|
||||
// Load content from IndexedDB for all selected notes
|
||||
if (vault) {
|
||||
const contentMap = await loadVaultContentFromIDB(vault.name)
|
||||
const notesWithContent = selectedObsNotes.map(note => ({
|
||||
...note,
|
||||
content: note.content || contentMap.get(note.id) || ''
|
||||
}))
|
||||
onObsNotesSelect(notesWithContent)
|
||||
} else {
|
||||
onObsNotesSelect(selectedObsNotes)
|
||||
}
|
||||
|
||||
console.log('🎯 ObsidianVaultBrowser: handleBulkImport called with:', selectedObsNotes.length, 'notes')
|
||||
onObsNotesSelect(selectedObsNotes)
|
||||
setSelectedNotes(new Set())
|
||||
}
|
||||
|
||||
|
|
@ -772,16 +730,13 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
|
||||
|
||||
const handleDisconnectVault = () => {
|
||||
// Clear vault from Automerge/sync when disconnecting
|
||||
if (vault) {
|
||||
clearVaultFromAutomerge(vault.name)
|
||||
}
|
||||
|
||||
updateSession({
|
||||
// Clear the vault from session
|
||||
updateSession({
|
||||
obsidianVaultPath: undefined,
|
||||
obsidianVaultName: undefined
|
||||
})
|
||||
|
||||
|
||||
// Reset component state
|
||||
setVault(null)
|
||||
setSearchQuery('')
|
||||
setDebouncedSearchQuery('')
|
||||
|
|
@ -791,14 +746,8 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
setError(null)
|
||||
setHasLoadedOnce(false)
|
||||
setIsLoadingVault(false)
|
||||
}
|
||||
|
||||
// Clear vault data from Automerge without disconnecting (for privacy)
|
||||
const handleClearVaultFromSync = () => {
|
||||
if (vault) {
|
||||
clearVaultFromAutomerge(vault.name)
|
||||
alert(`Cleared "${vault.name}" from sync. Your vault data will no longer be visible to others.`)
|
||||
}
|
||||
|
||||
console.log('🔧 Vault disconnected successfully')
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
|
|
@ -892,19 +841,24 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
<h3>Load Obsidian Vault</h3>
|
||||
<p>Choose how you'd like to load your Obsidian vault:</p>
|
||||
<div className="vault-options">
|
||||
<button
|
||||
onClick={handleFolderPicker}
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('📁 Select Folder button clicked')
|
||||
handleFolderPicker()
|
||||
}}
|
||||
className="load-vault-button primary"
|
||||
>
|
||||
📁 Select Folder
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('📝 Enter Path button clicked')
|
||||
// Pre-populate with session vault path if available
|
||||
if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') {
|
||||
setVaultPath(session.obsidianVaultPath)
|
||||
}
|
||||
setShowVaultInput(true)
|
||||
}}
|
||||
}}
|
||||
className="load-vault-button secondary"
|
||||
>
|
||||
📝 Enter Path
|
||||
|
|
@ -1191,6 +1145,20 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
<h2>
|
||||
{vault ? `Obsidian Vault: ${vault.name}` : 'No Obsidian Vault Connected'}
|
||||
</h2>
|
||||
{!vault && (
|
||||
<div className="vault-connect-section">
|
||||
<p className="vault-connect-message">
|
||||
Connect your Obsidian vault to browse and add notes to the canvas.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleFolderPicker}
|
||||
className="connect-vault-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Connecting...' : 'Connect Vault'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{vault && (
|
||||
|
|
@ -1260,16 +1228,8 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
>
|
||||
🔌 Disconnect Vault
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearVaultFromSync}
|
||||
className="clear-sync-button"
|
||||
title="Clear vault data from sync (keeps local data)"
|
||||
style={{ marginLeft: '8px', fontSize: '0.85em', opacity: 0.8 }}
|
||||
>
|
||||
🗑️ Clear from Sync
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="selection-controls">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
|
|
@ -1523,16 +1483,8 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
|
|||
>
|
||||
🔌 Disconnect Vault
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearVaultFromSync}
|
||||
className="clear-sync-button"
|
||||
title="Clear vault data from sync (keeps local data)"
|
||||
style={{ marginLeft: '8px', fontSize: '0.85em', opacity: 0.8 }}
|
||||
>
|
||||
🗑️ Clear from Sync
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="selection-controls">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
|
|
|
|||
|
|
@ -1,632 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
interface ShareBoardButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type PermissionType = 'view' | 'edit' | 'admin';
|
||||
|
||||
const PERMISSION_LABELS: Record<PermissionType, { label: string; description: string; color: string }> = {
|
||||
view: { label: 'View', description: 'Can view but not edit', color: '#6b7280' },
|
||||
edit: { label: 'Edit', description: 'Can view and edit', color: '#3b82f6' },
|
||||
admin: { label: 'Admin', description: 'Full control', color: '#10b981' },
|
||||
};
|
||||
|
||||
const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
// Detect dark mode
|
||||
const [isDarkMode, setIsDarkMode] = useState(
|
||||
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
);
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
setIsDarkMode(document.documentElement.classList.contains('dark'));
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [permission, setPermission] = useState<PermissionType>('edit');
|
||||
const [nfcStatus, setNfcStatus] = useState<'idle' | 'writing' | 'success' | 'error' | 'unsupported'>('idle');
|
||||
const [nfcMessage, setNfcMessage] = useState('');
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [inviteInput, setInviteInput] = useState('');
|
||||
const [inviteStatus, setInviteStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownMenuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
|
||||
|
||||
const boardSlug = slug || 'mycofi33';
|
||||
const boardUrl = `${window.location.origin}/board/${boardSlug}`;
|
||||
|
||||
// Update dropdown position when it opens
|
||||
useEffect(() => {
|
||||
if (showDropdown && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + 8,
|
||||
right: window.innerWidth - rect.right,
|
||||
});
|
||||
}
|
||||
}, [showDropdown]);
|
||||
|
||||
// Generate URL with permission parameter
|
||||
const getShareUrl = () => {
|
||||
const url = new URL(boardUrl);
|
||||
url.searchParams.set('access', permission);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
// Check NFC support on mount
|
||||
useEffect(() => {
|
||||
if (!('NDEFReader' in window)) {
|
||||
setNfcStatus('unsupported');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Close dropdown when clicking outside or pressing ESC
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
// Check if click is inside trigger OR the portal dropdown menu
|
||||
const isInsideTrigger = dropdownRef.current && dropdownRef.current.contains(target);
|
||||
const isInsideMenu = dropdownMenuRef.current && dropdownMenuRef.current.contains(target);
|
||||
if (!isInsideTrigger && !isInsideMenu) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
if (showDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [showDropdown]);
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(getShareUrl());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy URL:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!inviteInput.trim()) return;
|
||||
|
||||
setInviteStatus('sending');
|
||||
try {
|
||||
// TODO: Implement actual invite API call
|
||||
// For now, simulate sending invite
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
setInviteStatus('sent');
|
||||
setInviteInput('');
|
||||
setTimeout(() => setInviteStatus('idle'), 3000);
|
||||
} catch (err) {
|
||||
console.error('Failed to send invite:', err);
|
||||
setInviteStatus('error');
|
||||
setTimeout(() => setInviteStatus('idle'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNfcWrite = async () => {
|
||||
if (!('NDEFReader' in window)) {
|
||||
setNfcStatus('unsupported');
|
||||
setNfcMessage('NFC is not supported on this device');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setNfcStatus('writing');
|
||||
setNfcMessage('Hold your NFC tag near the device...');
|
||||
|
||||
const ndef = new (window as any).NDEFReader();
|
||||
await ndef.write({
|
||||
records: [
|
||||
{ recordType: "url", data: getShareUrl() }
|
||||
]
|
||||
});
|
||||
|
||||
setNfcStatus('success');
|
||||
setNfcMessage('Board URL written to NFC tag!');
|
||||
setTimeout(() => {
|
||||
setNfcStatus('idle');
|
||||
setNfcMessage('');
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
console.error('NFC write error:', err);
|
||||
setNfcStatus('error');
|
||||
if (err.name === 'NotAllowedError') {
|
||||
setNfcMessage('NFC permission denied. Please allow NFC access.');
|
||||
} else if (err.name === 'NotSupportedError') {
|
||||
setNfcMessage('NFC is not supported on this device');
|
||||
} else {
|
||||
setNfcMessage(`Failed to write NFC tag: ${err.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Detect if we're in share-panel (compact) vs toolbar (full button)
|
||||
const isCompact = className.includes('share-panel-btn');
|
||||
|
||||
if (isCompact) {
|
||||
// Icon-only version for the top-right share panel with dropdown
|
||||
return (
|
||||
<div ref={dropdownRef} style={{ pointerEvents: 'all' }}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className={`share-board-button ${className}`}
|
||||
title="Invite others to this board"
|
||||
style={{
|
||||
background: showDropdown ? 'var(--color-muted-2)' : 'none',
|
||||
border: 'none',
|
||||
padding: '6px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--color-text-1)',
|
||||
opacity: showDropdown ? 1 : 0.7,
|
||||
transition: 'opacity 0.15s, background 0.15s',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!showDropdown) {
|
||||
e.currentTarget.style.opacity = '0.7';
|
||||
e.currentTarget.style.background = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* User with plus icon (invite/add person) */}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* User outline */}
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
{/* Plus sign */}
|
||||
<line x1="19" y1="8" x2="19" y2="14" />
|
||||
<line x1="16" y1="11" x2="22" y2="11" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown - rendered via portal to break out of parent container */}
|
||||
{showDropdown && dropdownPosition && createPortal(
|
||||
<div
|
||||
ref={dropdownMenuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: dropdownPosition.top,
|
||||
right: dropdownPosition.right,
|
||||
width: '340px',
|
||||
background: isDarkMode ? '#2d2d2d' : '#ffffff',
|
||||
backgroundColor: isDarkMode ? '#2d2d2d' : '#ffffff',
|
||||
backdropFilter: 'none',
|
||||
opacity: 1,
|
||||
border: `1px solid ${isDarkMode ? '#404040' : '#e5e5e5'}`,
|
||||
borderRadius: '12px',
|
||||
boxShadow: isDarkMode ? '0 8px 32px rgba(0,0,0,0.5)' : '0 8px 32px rgba(0,0,0,0.2)',
|
||||
zIndex: 100000,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'all',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
}}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Compact Header */}
|
||||
<div style={{
|
||||
padding: '12px 14px',
|
||||
borderBottom: '1px solid var(--color-panel-contrast)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '14px' }}>👥</span> Share Board
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowDropdown(false)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--color-muted-2)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
color: 'var(--color-text-3)',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 1,
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{/* Invite by username/email */}
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username or email..."
|
||||
value={inviteInput}
|
||||
onChange={(e) => setInviteInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') handleInvite();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'inherit',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--color-panel)',
|
||||
color: 'var(--color-text)',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleInvite}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!inviteInput.trim() || inviteStatus === 'sending'}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
backgroundColor: inviteStatus === 'sent' ? '#10b981' : '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: !inviteInput.trim() || inviteStatus === 'sending' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'inherit',
|
||||
opacity: !inviteInput.trim() ? 0.5 : 1,
|
||||
transition: 'all 0.15s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{inviteStatus === 'sending' ? '...' : inviteStatus === 'sent' ? '✓ Sent' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
{inviteStatus === 'error' && (
|
||||
<p style={{ fontSize: '11px', color: '#ef4444', marginTop: '4px' }}>
|
||||
Failed to send invite. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider with "or share link" */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
}}>
|
||||
<div style={{ flex: 1, height: '1px', background: 'var(--color-panel-contrast)' }} />
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-3)', fontWeight: 500 }}>or share link</span>
|
||||
<div style={{ flex: 1, height: '1px', background: 'var(--color-panel-contrast)' }} />
|
||||
</div>
|
||||
|
||||
{/* Permission selector - pill style */}
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{(['view', 'edit', 'admin'] as PermissionType[]).map((perm) => {
|
||||
const isActive = permission === perm;
|
||||
const { label, description } = PERMISSION_LABELS[perm];
|
||||
return (
|
||||
<button
|
||||
key={perm}
|
||||
onClick={() => setPermission(perm)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title={description}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 6px',
|
||||
border: 'none',
|
||||
background: isActive ? '#3b82f6' : 'var(--color-muted-2)',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'inherit',
|
||||
color: isActive ? 'white' : 'var(--color-text)',
|
||||
transition: 'all 0.15s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
fontWeight: 400,
|
||||
opacity: 0.8,
|
||||
color: isActive ? 'rgba(255,255,255,0.9)' : 'var(--color-text-3)',
|
||||
}}>
|
||||
{perm === 'view' ? 'Read only' : perm === 'edit' ? 'Can edit' : 'Full access'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* QR Code and URL - larger and side by side */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '14px',
|
||||
padding: '14px',
|
||||
backgroundColor: 'var(--color-muted-2)',
|
||||
borderRadius: '10px',
|
||||
}}>
|
||||
{/* QR Code - larger */}
|
||||
<div style={{
|
||||
padding: '10px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<QRCodeSVG
|
||||
value={getShareUrl()}
|
||||
size={100}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL and Copy - stacked */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '10px' }}>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
wordBreak: 'break-all',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace',
|
||||
color: 'var(--color-text)',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{getShareUrl()}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: copied ? '#10b981' : '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<>✓ Copied!</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
Copy Link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced options (collapsible) */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'inherit',
|
||||
color: 'var(--color-text-3)',
|
||||
padding: '6px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--color-muted-2)',
|
||||
}}>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
style={{ transform: showAdvanced ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
||||
>
|
||||
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</span>
|
||||
More options
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div style={{ marginTop: '8px', display: 'flex', gap: '8px' }}>
|
||||
{/* NFC Button */}
|
||||
<button
|
||||
onClick={handleNfcWrite}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={nfcStatus === 'unsupported' || nfcStatus === 'writing'}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: nfcStatus === 'unsupported' ? 'var(--color-muted-2)' :
|
||||
nfcStatus === 'success' ? '#d1fae5' :
|
||||
nfcStatus === 'error' ? '#fee2e2' :
|
||||
nfcStatus === 'writing' ? '#e0e7ff' : 'var(--color-panel)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '6px',
|
||||
cursor: nfcStatus === 'unsupported' || nfcStatus === 'writing' ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
opacity: nfcStatus === 'unsupported' ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
{nfcStatus === 'success' ? '✓' : nfcStatus === 'error' ? '!' : '📡'}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', color: 'var(--color-text)', fontWeight: 500 }}>
|
||||
{nfcStatus === 'writing' ? 'Writing...' :
|
||||
nfcStatus === 'success' ? 'Written!' :
|
||||
nfcStatus === 'unsupported' ? 'NFC N/A' :
|
||||
'NFC Tag'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Audio Button (coming soon) */}
|
||||
<button
|
||||
disabled
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: 'var(--color-muted-2)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '6px',
|
||||
cursor: 'not-allowed',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>🔊</span>
|
||||
<span style={{ fontSize: '10px', color: 'var(--color-text)', fontWeight: 500 }}>
|
||||
Audio (Soon)
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{nfcMessage && (
|
||||
<p style={{
|
||||
marginTop: '6px',
|
||||
fontSize: '10px',
|
||||
color: nfcStatus === 'error' ? '#ef4444' :
|
||||
nfcStatus === 'success' ? '#10b981' : 'var(--color-text-3)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{nfcMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full button version for other contexts (toolbar, etc.)
|
||||
return (
|
||||
<div ref={dropdownRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className={`share-board-button ${className}`}
|
||||
title="Invite others to this board"
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
background: "#3b82f6",
|
||||
color: "white",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
transition: "background 0.2s ease",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
whiteSpace: "nowrap",
|
||||
userSelect: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
height: "22px",
|
||||
minHeight: "22px",
|
||||
boxSizing: "border-box",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "#2563eb";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "#3b82f6";
|
||||
}}
|
||||
>
|
||||
{/* User with plus icon (invite/add person) */}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<line x1="19" y1="8" x2="19" y2="14" />
|
||||
<line x1="16" y1="11" x2="22" y2="11" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareBoardButton;
|
||||
|
|
@ -44,10 +44,6 @@ export interface StandardizedToolWrapperProps {
|
|||
onMinimize?: () => void
|
||||
/** Whether the tool is minimized */
|
||||
isMinimized?: boolean
|
||||
/** Callback when maximize button is clicked */
|
||||
onMaximize?: () => void
|
||||
/** Whether the tool is maximized (fullscreen) */
|
||||
isMaximized?: boolean
|
||||
/** Optional custom header content */
|
||||
headerContent?: ReactNode
|
||||
/** Editor instance for shape selection */
|
||||
|
|
@ -80,8 +76,6 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
onClose,
|
||||
onMinimize,
|
||||
isMinimized = false,
|
||||
onMaximize,
|
||||
isMaximized = false,
|
||||
headerContent,
|
||||
editor,
|
||||
shapeId,
|
||||
|
|
@ -97,22 +91,6 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
const tagInputRef = useRef<HTMLInputElement>(null)
|
||||
const isDarkMode = useIsDarkMode()
|
||||
|
||||
// Handle Esc key to exit maximize mode
|
||||
useEffect(() => {
|
||||
if (!isMaximized || !onMaximize) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onMaximize()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [isMaximized, onMaximize])
|
||||
|
||||
// Dark mode aware colors
|
||||
const colors = useMemo(() => isDarkMode ? {
|
||||
contentBg: '#1a1a1a',
|
||||
|
|
@ -188,7 +166,7 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
fontFamily: "Inter, sans-serif",
|
||||
position: 'relative',
|
||||
pointerEvents: 'auto',
|
||||
transition: isPinnedToView ? 'box-shadow 0.2s ease' : 'height 0.2s ease, box-shadow 0.2s ease',
|
||||
transition: 'height 0.2s ease, box-shadow 0.2s ease',
|
||||
boxSizing: 'border-box',
|
||||
}
|
||||
|
||||
|
|
@ -265,25 +243,16 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
color: isSelected ? 'white' : primaryColor,
|
||||
}
|
||||
|
||||
const maximizeButtonStyle: React.CSSProperties = {
|
||||
...buttonBaseStyle,
|
||||
backgroundColor: isMaximized
|
||||
? (isSelected ? 'rgba(255,255,255,0.4)' : primaryColor)
|
||||
: (isSelected ? 'rgba(255,255,255,0.2)' : `${primaryColor}20`),
|
||||
color: isMaximized
|
||||
? (isSelected ? 'white' : 'white')
|
||||
: (isSelected ? 'white' : primaryColor),
|
||||
}
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
minHeight: 0, // Allow flex shrinking
|
||||
height: isMinimized ? 0 : 'calc(100% - 40px)',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
pointerEvents: 'auto',
|
||||
transition: 'height 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1, // Take remaining space after header and tags
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const tagsContainerStyle: React.CSSProperties = {
|
||||
|
|
@ -519,20 +488,6 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
>
|
||||
_
|
||||
</button>
|
||||
{onMaximize && (
|
||||
<button
|
||||
style={maximizeButtonStyle}
|
||||
onClick={(e) => handleButtonClick(e, onMaximize)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => handleButtonTouch(e, onMaximize)}
|
||||
onTouchEnd={(e) => e.stopPropagation()}
|
||||
title={isMaximized ? "Exit fullscreen (Esc)" : "Maximize"}
|
||||
aria-label={isMaximized ? "Exit fullscreen" : "Maximize"}
|
||||
>
|
||||
{isMaximized ? '⊡' : '⤢'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={closeButtonStyle}
|
||||
onClick={(e) => handleButtonClick(e, onClose)}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
|
|||
|
||||
const handleStarToggle = async () => {
|
||||
if (!session.authed || !session.username || !slug) {
|
||||
showPopupMessage('Please log in to star boards', 'error');
|
||||
addNotification('Please log in to star boards', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -75,75 +75,9 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
|
|||
}
|
||||
};
|
||||
|
||||
// Detect if we're in share-panel (compact) vs toolbar (full button)
|
||||
const isCompact = className.includes('share-panel-btn');
|
||||
|
||||
if (isCompact) {
|
||||
// Icon-only version for the top-right share panel
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={handleStarToggle}
|
||||
disabled={isLoading}
|
||||
className={`star-board-button ${className} ${isStarred ? 'starred' : ''}`}
|
||||
title={!session.authed ? 'Log in to star boards' : isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '6px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: isStarred ? '#f59e0b' : 'var(--color-text-1)',
|
||||
opacity: isStarred ? 1 : 0.7,
|
||||
transition: 'opacity 0.15s, background 0.15s, color 0.15s',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = isStarred ? '1' : '0.7';
|
||||
e.currentTarget.style.background = 'none';
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" className="loading-spinner">
|
||||
<path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
{isStarred ? (
|
||||
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
|
||||
) : (
|
||||
<path d="M2.866 14.85c-.078.444.36.791.746.593l4.39-2.256 4.389 2.256c.386.198.824-.149.746-.592l-.83-4.73 3.522-3.356c.33-.314.16-.888-.282-.95l-4.898-.696L8.465.792a.513.513 0 0 0-.927 0L5.354 5.12l-4.898.696c-.441.062-.612.636-.283.95l3.523 3.356-.83 4.73zm4.905-2.767-3.686 1.894.694-3.957a.565.565 0 0 0-.163-.505L1.71 6.745l4.052-.576a.525.525 0 0 0 .393-.288L8 2.223l1.847 3.658a.525.525 0 0 0 .393.288l4.052.575-2.906 2.77a.565.565 0 0 0-.163.506l.694 3.957-3.686-1.894a.503.503 0 0 0-.461 0z"/>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Custom popup notification */}
|
||||
{showPopup && (
|
||||
<div
|
||||
className={`star-popup star-popup-${popupType}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 100001,
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{popupMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// Don't show the button if user is not authenticated
|
||||
if (!session.authed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -152,14 +86,14 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
|
|||
onClick={handleStarToggle}
|
||||
disabled={isLoading}
|
||||
className={`toolbar-btn star-board-button ${className} ${isStarred ? 'starred' : ''}`}
|
||||
title={!session.authed ? 'Log in to star boards' : isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
|
||||
title={isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="loading-spinner">
|
||||
<path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill={isStarred ? '#f59e0b' : 'currentColor'}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
{isStarred ? (
|
||||
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,521 +0,0 @@
|
|||
/**
|
||||
* WalletLinkPanel - UI for connecting and linking Web3 wallets to enCryptID
|
||||
*
|
||||
* Features:
|
||||
* - Connect wallet (MetaMask, WalletConnect, etc.)
|
||||
* - Link wallet to enCryptID account via signature
|
||||
* - View and manage linked wallets
|
||||
* - Set primary wallet
|
||||
* - Unlink wallets
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
useWalletConnection,
|
||||
useWalletLink,
|
||||
useLinkedWallets,
|
||||
formatAddress,
|
||||
LinkedWallet,
|
||||
} from '../hooks/useWallet';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
// =============================================================================
|
||||
// Styles (inline for simplicity - can be moved to CSS/Tailwind)
|
||||
// =============================================================================
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
padding: '16px',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
} as React.CSSProperties,
|
||||
section: {
|
||||
marginBottom: '24px',
|
||||
} as React.CSSProperties,
|
||||
sectionTitle: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
marginBottom: '12px',
|
||||
color: '#374151',
|
||||
} as React.CSSProperties,
|
||||
card: {
|
||||
background: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
marginBottom: '8px',
|
||||
} as React.CSSProperties,
|
||||
button: {
|
||||
background: '#4f46e5',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
} as React.CSSProperties,
|
||||
buttonSecondary: {
|
||||
background: '#e5e7eb',
|
||||
color: '#374151',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
} as React.CSSProperties,
|
||||
buttonDanger: {
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
} as React.CSSProperties,
|
||||
buttonSmall: {
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
} as React.CSSProperties,
|
||||
flexRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
} as React.CSSProperties,
|
||||
flexBetween: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
} as React.CSSProperties,
|
||||
address: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '13px',
|
||||
color: '#6b7280',
|
||||
} as React.CSSProperties,
|
||||
badge: {
|
||||
background: '#dbeafe',
|
||||
color: '#1d4ed8',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
} as React.CSSProperties,
|
||||
badgePrimary: {
|
||||
background: '#dcfce7',
|
||||
color: '#166534',
|
||||
} as React.CSSProperties,
|
||||
error: {
|
||||
color: '#ef4444',
|
||||
fontSize: '13px',
|
||||
marginTop: '8px',
|
||||
} as React.CSSProperties,
|
||||
success: {
|
||||
color: '#22c55e',
|
||||
fontSize: '13px',
|
||||
marginTop: '8px',
|
||||
} as React.CSSProperties,
|
||||
input: {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
marginBottom: '8px',
|
||||
} as React.CSSProperties,
|
||||
connectorButton: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '8px',
|
||||
transition: 'border-color 0.2s',
|
||||
} as React.CSSProperties,
|
||||
walletIcon: {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '6px',
|
||||
background: '#f3f4f6',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Sub-components
|
||||
// =============================================================================
|
||||
|
||||
interface ConnectWalletSectionProps {
|
||||
onConnect: (connectorId?: string) => void;
|
||||
connectors: Array<{ id: string; name: string; type: string }>;
|
||||
isConnecting: boolean;
|
||||
}
|
||||
|
||||
function ConnectWalletSection({ onConnect, connectors, isConnecting }: ConnectWalletSectionProps) {
|
||||
return (
|
||||
<div style={styles.section}>
|
||||
<div style={styles.sectionTitle}>Connect Wallet</div>
|
||||
{connectors.map((connector) => (
|
||||
<button
|
||||
key={connector.id}
|
||||
onClick={() => onConnect(connector.id)}
|
||||
disabled={isConnecting}
|
||||
style={styles.connectorButton}
|
||||
>
|
||||
<div style={styles.walletIcon}>
|
||||
{connector.name === 'MetaMask' ? '🦊' :
|
||||
connector.name === 'WalletConnect' ? '🔗' :
|
||||
connector.name === 'Coinbase Wallet' ? '🔵' : '👛'}
|
||||
</div>
|
||||
<span>{connector.name}</span>
|
||||
{isConnecting && <span style={{ marginLeft: 'auto', color: '#9ca3af' }}>Connecting...</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectedWalletSectionProps {
|
||||
address: string;
|
||||
ensName: string | null;
|
||||
chainId: number;
|
||||
connectorName: string | undefined;
|
||||
onDisconnect: () => void;
|
||||
isDisconnecting: boolean;
|
||||
}
|
||||
|
||||
function ConnectedWalletSection({
|
||||
address,
|
||||
ensName,
|
||||
chainId,
|
||||
connectorName,
|
||||
onDisconnect,
|
||||
isDisconnecting,
|
||||
}: ConnectedWalletSectionProps) {
|
||||
const chainNames: Record<number, string> = {
|
||||
1: 'Ethereum',
|
||||
10: 'Optimism',
|
||||
137: 'Polygon',
|
||||
42161: 'Arbitrum',
|
||||
8453: 'Base',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.section}>
|
||||
<div style={styles.sectionTitle}>Connected Wallet</div>
|
||||
<div style={styles.card}>
|
||||
<div style={styles.flexBetween}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: '4px' }}>
|
||||
{ensName || formatAddress(address)}
|
||||
</div>
|
||||
<div style={styles.address}>{formatAddress(address, 6)}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={styles.badge}>{chainNames[chainId] || `Chain ${chainId}`}</div>
|
||||
{connectorName && (
|
||||
<div style={{ fontSize: '11px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
via {connectorName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<button
|
||||
onClick={onDisconnect}
|
||||
disabled={isDisconnecting}
|
||||
style={styles.buttonSecondary}
|
||||
>
|
||||
{isDisconnecting ? 'Disconnecting...' : 'Disconnect'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LinkWalletSectionProps {
|
||||
address: string;
|
||||
isLinking: boolean;
|
||||
linkError: string | null;
|
||||
onLink: (label?: string) => Promise<{ success: boolean; error?: string }>;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
function LinkWalletSection({
|
||||
address: _address,
|
||||
isLinking,
|
||||
linkError,
|
||||
onLink,
|
||||
isAuthenticated,
|
||||
}: LinkWalletSectionProps) {
|
||||
const [label, setLabel] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleLink = async () => {
|
||||
setSuccess(false);
|
||||
const result = await onLink(label || undefined);
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
setLabel('');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div style={styles.section}>
|
||||
<div style={styles.sectionTitle}>Link to enCryptID</div>
|
||||
<div style={{ ...styles.card, color: '#6b7280' }}>
|
||||
Please sign in with enCryptID to link your wallet.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.section}>
|
||||
<div style={styles.sectionTitle}>Link to enCryptID</div>
|
||||
<div style={styles.card}>
|
||||
<p style={{ fontSize: '13px', color: '#6b7280', marginBottom: '12px' }}>
|
||||
Link this wallet to your enCryptID account. You'll be asked to sign a message
|
||||
to prove ownership.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Label (optional, e.g., 'Main Wallet')"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
style={styles.input}
|
||||
/>
|
||||
<button
|
||||
onClick={handleLink}
|
||||
disabled={isLinking}
|
||||
style={styles.button}
|
||||
>
|
||||
{isLinking ? 'Signing...' : 'Link Wallet'}
|
||||
</button>
|
||||
{linkError && <div style={styles.error}>{linkError}</div>}
|
||||
{success && <div style={styles.success}>Wallet linked successfully!</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LinkedWalletItemProps {
|
||||
wallet: LinkedWallet;
|
||||
onSetPrimary: () => Promise<boolean>;
|
||||
onUnlink: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
function LinkedWalletItem({ wallet, onSetPrimary, onUnlink }: LinkedWalletItemProps) {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const handleSetPrimary = async () => {
|
||||
setIsUpdating(true);
|
||||
await onSetPrimary();
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
const handleUnlink = async () => {
|
||||
if (!confirm('Are you sure you want to unlink this wallet?')) return;
|
||||
setIsUpdating(true);
|
||||
await onUnlink();
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.flexBetween}>
|
||||
<div>
|
||||
<div style={styles.flexRow}>
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{wallet.ensName || wallet.label || formatAddress(wallet.address)}
|
||||
</span>
|
||||
{wallet.isPrimary && (
|
||||
<span style={{ ...styles.badge, ...styles.badgePrimary }}>Primary</span>
|
||||
)}
|
||||
<span style={styles.badge}>{wallet.type.toUpperCase()}</span>
|
||||
</div>
|
||||
<div style={{ ...styles.address, marginTop: '4px' }}>
|
||||
{formatAddress(wallet.address, 8)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...styles.flexRow, gap: '4px' }}>
|
||||
{!wallet.isPrimary && (
|
||||
<button
|
||||
onClick={handleSetPrimary}
|
||||
disabled={isUpdating}
|
||||
style={{ ...styles.buttonSecondary, ...styles.buttonSmall }}
|
||||
>
|
||||
Set Primary
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleUnlink}
|
||||
disabled={isUpdating}
|
||||
style={{ ...styles.buttonDanger, ...styles.buttonSmall }}
|
||||
>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LinkedWalletsSectionProps {
|
||||
wallets: LinkedWallet[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onUpdateWallet: (address: string, updates: { isPrimary?: boolean }) => Promise<boolean>;
|
||||
onUnlinkWallet: (address: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function LinkedWalletsSection({
|
||||
wallets,
|
||||
isLoading,
|
||||
error,
|
||||
onUpdateWallet,
|
||||
onUnlinkWallet,
|
||||
}: LinkedWalletsSectionProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={styles.section}>
|
||||
<div style={styles.sectionTitle}>Linked Wallets</div>
|
||||
<div style={{ color: '#9ca3af', fontSize: '13px' }}>Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={styles.section}>
|
||||
<div style={styles.sectionTitle}>Linked Wallets</div>
|
||||
<div style={styles.error}>{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (wallets.length === 0) {
|
||||
return (
|
||||
<div style={styles.section}>
|
||||
<div style={styles.sectionTitle}>Linked Wallets</div>
|
||||
<div style={{ color: '#9ca3af', fontSize: '13px' }}>
|
||||
No wallets linked yet. Connect a wallet and link it above.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.section}>
|
||||
<div style={styles.sectionTitle}>Linked Wallets ({wallets.length})</div>
|
||||
{wallets.map((wallet) => (
|
||||
<LinkedWalletItem
|
||||
key={wallet.id}
|
||||
wallet={wallet}
|
||||
onSetPrimary={() => onUpdateWallet(wallet.address, { isPrimary: true })}
|
||||
onUnlink={() => onUnlinkWallet(wallet.address)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export function WalletLinkPanel() {
|
||||
const { session } = useAuth();
|
||||
const {
|
||||
address,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
chainId,
|
||||
connectorName,
|
||||
ensName,
|
||||
connect,
|
||||
disconnect,
|
||||
isDisconnecting,
|
||||
connectors,
|
||||
} = useWalletConnection();
|
||||
|
||||
const { isLinking, linkError, linkWallet, clearError } = useWalletLink();
|
||||
|
||||
const {
|
||||
wallets,
|
||||
isLoading: isLoadingWallets,
|
||||
error: walletsError,
|
||||
updateWallet,
|
||||
unlinkWallet,
|
||||
refetch: refetchWallets,
|
||||
} = useLinkedWallets();
|
||||
|
||||
// Check if the connected wallet is already linked
|
||||
const isCurrentWalletLinked = address
|
||||
? wallets.some(w => w.address.toLowerCase() === address.toLowerCase())
|
||||
: false;
|
||||
|
||||
const handleLink = async (label?: string) => {
|
||||
clearError();
|
||||
const result = await linkWallet(label);
|
||||
if (result.success) {
|
||||
await refetchWallets();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600 }}>
|
||||
Web3 Wallet
|
||||
</h3>
|
||||
|
||||
{!isConnected ? (
|
||||
<ConnectWalletSection
|
||||
onConnect={connect}
|
||||
connectors={connectors}
|
||||
isConnecting={isConnecting}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ConnectedWalletSection
|
||||
address={address!}
|
||||
ensName={ensName}
|
||||
chainId={chainId}
|
||||
connectorName={connectorName}
|
||||
onDisconnect={disconnect}
|
||||
isDisconnecting={isDisconnecting}
|
||||
/>
|
||||
|
||||
{!isCurrentWalletLinked && (
|
||||
<LinkWalletSection
|
||||
address={address!}
|
||||
isLinking={isLinking}
|
||||
linkError={linkError}
|
||||
onLink={handleLink}
|
||||
isAuthenticated={session.authed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<LinkedWalletsSection
|
||||
wallets={wallets}
|
||||
isLoading={isLoadingWallets}
|
||||
error={walletsError}
|
||||
onUpdateWallet={updateWallet}
|
||||
onUnlinkWallet={unlinkWallet}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WalletLinkPanel;
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
// Year View Panel - KalNext-style 12-month yearly overview
|
||||
// Shows all months in a 4x3 grid with event density indicators
|
||||
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { useCalendarEvents, type DecryptedCalendarEvent } from "@/hooks/useCalendarEvents"
|
||||
|
||||
interface YearViewPanelProps {
|
||||
onClose?: () => void
|
||||
onMonthSelect?: (year: number, month: number) => void
|
||||
shapeMode?: boolean
|
||||
initialYear?: number
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
const getDaysInMonth = (year: number, month: number) => {
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
const getFirstDayOfMonth = (year: number, month: number) => {
|
||||
const day = new Date(year, month, 1).getDay()
|
||||
return day === 0 ? 6 : day - 1 // Monday-first
|
||||
}
|
||||
|
||||
const isSameDay = (date1: Date, date2: Date) => {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January", "February", "March", "April",
|
||||
"May", "June", "July", "August",
|
||||
"September", "October", "November", "December"
|
||||
]
|
||||
|
||||
const SHORT_MONTH_NAMES = [
|
||||
"Jan", "Feb", "Mar", "Apr",
|
||||
"May", "Jun", "Jul", "Aug",
|
||||
"Sep", "Oct", "Nov", "Dec"
|
||||
]
|
||||
|
||||
export const YearViewPanel: React.FC<YearViewPanelProps> = ({
|
||||
onClose: _onClose,
|
||||
onMonthSelect,
|
||||
shapeMode: _shapeMode = false,
|
||||
initialYear,
|
||||
}) => {
|
||||
const [currentYear, setCurrentYear] = useState(initialYear || new Date().getFullYear())
|
||||
|
||||
// Detect dark mode
|
||||
const isDarkMode =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark")
|
||||
|
||||
// Fetch all events for the current year
|
||||
const yearStart = new Date(currentYear, 0, 1)
|
||||
const yearEnd = new Date(currentYear, 11, 31, 23, 59, 59)
|
||||
|
||||
const { events, loading, getEventsForDate } = useCalendarEvents({
|
||||
startDate: yearStart,
|
||||
endDate: yearEnd,
|
||||
})
|
||||
|
||||
// Colors
|
||||
const colors = isDarkMode
|
||||
? {
|
||||
bg: "#1f2937",
|
||||
text: "#e4e4e7",
|
||||
textMuted: "#a1a1aa",
|
||||
border: "#374151",
|
||||
monthBg: "#252525",
|
||||
todayBg: "#22c55e30",
|
||||
todayBorder: "#22c55e",
|
||||
eventDot1: "#3b82f620", // 1 event
|
||||
eventDot2: "#3b82f640", // 2 events
|
||||
eventDot3: "#3b82f680", // 3+ events
|
||||
eventDotMax: "#3b82f6", // 5+ events
|
||||
headerBg: "#22c55e",
|
||||
}
|
||||
: {
|
||||
bg: "#f9fafb",
|
||||
text: "#1f2937",
|
||||
textMuted: "#6b7280",
|
||||
border: "#e5e7eb",
|
||||
monthBg: "#ffffff",
|
||||
todayBg: "#22c55e20",
|
||||
todayBorder: "#22c55e",
|
||||
eventDot1: "#3b82f620",
|
||||
eventDot2: "#3b82f640",
|
||||
eventDot3: "#3b82f680",
|
||||
eventDotMax: "#3b82f6",
|
||||
headerBg: "#22c55e",
|
||||
}
|
||||
|
||||
// Get event count for a specific date
|
||||
const getEventCount = (date: Date) => {
|
||||
return getEventsForDate(date).length
|
||||
}
|
||||
|
||||
// Get background color based on event density
|
||||
const getEventDensityColor = (count: number) => {
|
||||
if (count === 0) return "transparent"
|
||||
if (count === 1) return colors.eventDot1
|
||||
if (count === 2) return colors.eventDot2
|
||||
if (count <= 4) return colors.eventDot3
|
||||
return colors.eventDotMax
|
||||
}
|
||||
|
||||
// Navigation
|
||||
const goToPrevYear = () => setCurrentYear((y) => y - 1)
|
||||
const goToNextYear = () => setCurrentYear((y) => y + 1)
|
||||
const goToCurrentYear = () => setCurrentYear(new Date().getFullYear())
|
||||
|
||||
const today = new Date()
|
||||
|
||||
// Generate mini calendar for a month
|
||||
const renderMiniMonth = (month: number) => {
|
||||
const daysInMonth = getDaysInMonth(currentYear, month)
|
||||
const firstDay = getFirstDayOfMonth(currentYear, month)
|
||||
|
||||
const days: { day: number | null; date: Date | null }[] = []
|
||||
|
||||
// Leading empty cells
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push({ day: null, date: null })
|
||||
}
|
||||
|
||||
// Days of month
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push({ day: i, date: new Date(currentYear, month, i) })
|
||||
}
|
||||
|
||||
// Trailing empty cells to complete grid (6 rows max)
|
||||
while (days.length < 42) {
|
||||
days.push({ day: null, date: null })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={month}
|
||||
style={{
|
||||
backgroundColor: colors.monthBg,
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
border: `1px solid ${colors.border}`,
|
||||
cursor: onMonthSelect ? "pointer" : "default",
|
||||
}}
|
||||
onClick={() => onMonthSelect?.(currentYear, month)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Month name */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
fontWeight: "600",
|
||||
color: colors.text,
|
||||
marginBottom: "6px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{SHORT_MONTH_NAMES[month]}
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "1px",
|
||||
marginBottom: "2px",
|
||||
}}
|
||||
>
|
||||
{["M", "T", "W", "T", "F", "S", "S"].map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "7px",
|
||||
fontWeight: "500",
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days grid */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "1px",
|
||||
}}
|
||||
>
|
||||
{days.slice(0, 42).map(({ day, date }, i) => {
|
||||
const isToday = date && isSameDay(date, today)
|
||||
const eventCount = date ? getEventCount(date) : 0
|
||||
const densityColor = getEventDensityColor(eventCount)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "8px",
|
||||
padding: "2px 0",
|
||||
borderRadius: "2px",
|
||||
backgroundColor: isToday ? colors.todayBg : densityColor,
|
||||
border: isToday ? `1px solid ${colors.todayBorder}` : "1px solid transparent",
|
||||
color: day ? colors.text : "transparent",
|
||||
fontWeight: isToday ? "700" : "400",
|
||||
minHeight: "14px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: colors.bg,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header with year navigation */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
backgroundColor: colors.headerBg,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={goToPrevYear}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
fontWeight: "600",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<
|
||||
</button>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: "700",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{currentYear}
|
||||
</span>
|
||||
{currentYear !== new Date().getFullYear() && (
|
||||
<button
|
||||
onClick={goToCurrentYear}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "11px",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={goToNextYear}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
fontWeight: "600",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 12-month grid (4x3 layout) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: colors.textMuted,
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
Loading calendar data...
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gridTemplateRows: "repeat(3, 1fr)",
|
||||
gap: "10px",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, month) => renderMiniMonth(month))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
borderTop: `1px solid ${colors.border}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "2px",
|
||||
backgroundColor: colors.todayBg,
|
||||
border: `1px solid ${colors.todayBorder}`,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "10px", color: colors.textMuted }}>Today</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "2px",
|
||||
backgroundColor: colors.eventDot1,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "10px", color: colors.textMuted }}>1 event</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "2px",
|
||||
backgroundColor: colors.eventDot3,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "10px", color: colors.textMuted }}>3+ events</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "2px",
|
||||
backgroundColor: colors.eventDotMax,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "10px", color: colors.textMuted }}>5+ events</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import CryptID from './CryptID';
|
||||
import '../../css/anonymous-banner.css';
|
||||
|
||||
|
|
@ -13,27 +13,28 @@ interface AnonymousViewerBannerProps {
|
|||
/**
|
||||
* Banner shown to anonymous (unauthenticated) users viewing a board.
|
||||
* Explains CryptID and provides a smooth sign-up flow.
|
||||
*
|
||||
* Note: This component should only be rendered when user is NOT authenticated.
|
||||
* The parent component (Board.tsx) handles the auth check via:
|
||||
* {(!session.authed || showEditPrompt) && <AnonymousViewerBanner ... />}
|
||||
*/
|
||||
const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
|
||||
onAuthenticated,
|
||||
triggeredByEdit = false
|
||||
}) => {
|
||||
const { session } = useAuth();
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const [showSignUp, setShowSignUp] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(triggeredByEdit);
|
||||
|
||||
// Note: We intentionally do NOT persist banner dismissal across page loads.
|
||||
// The banner should appear on each new page load for anonymous users
|
||||
// to remind them about CryptID. Only dismiss within the current component lifecycle.
|
||||
//
|
||||
// Previous implementation used sessionStorage to remember dismissal, but this caused
|
||||
// issues where users who dismissed once would never see it again until they closed
|
||||
// their browser entirely - even if they logged out or their session expired.
|
||||
//
|
||||
// If triggeredByEdit is true, always show regardless of dismiss state.
|
||||
// Check if banner was previously dismissed this session
|
||||
useEffect(() => {
|
||||
const dismissed = sessionStorage.getItem('anonymousBannerDismissed');
|
||||
if (dismissed && !triggeredByEdit) {
|
||||
setIsDismissed(true);
|
||||
}
|
||||
}, [triggeredByEdit]);
|
||||
|
||||
// If user is authenticated, don't show banner
|
||||
if (session.authed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If dismissed and not triggered by edit, don't show
|
||||
if (isDismissed && !triggeredByEdit) {
|
||||
|
|
@ -41,8 +42,7 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
|
|||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
// Just set local state - don't persist to sessionStorage
|
||||
// This allows the banner to show again on page refresh
|
||||
sessionStorage.setItem('anonymousBannerDismissed', 'true');
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
|
|
@ -52,9 +52,6 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
|
|||
|
||||
const handleSignUpSuccess = () => {
|
||||
setShowSignUp(false);
|
||||
// Dismiss the banner when user signs in successfully
|
||||
// No need to persist - the parent condition (!session.authed) will hide us
|
||||
setIsDismissed(true);
|
||||
if (onAuthenticated) {
|
||||
onAuthenticated();
|
||||
}
|
||||
|
|
@ -64,134 +61,107 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
|
|||
setShowSignUp(false);
|
||||
};
|
||||
|
||||
// Show CryptID modal when sign up is clicked
|
||||
if (showSignUp) {
|
||||
return (
|
||||
<div className="anonymous-banner-modal-overlay">
|
||||
<div className="anonymous-banner-modal">
|
||||
<CryptID
|
||||
onSuccess={handleSignUpSuccess}
|
||||
onCancel={handleSignUpCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`anonymous-viewer-banner ${triggeredByEdit ? 'edit-triggered' : ''}`}>
|
||||
{/* Dismiss button in top-right corner */}
|
||||
{!triggeredByEdit && (
|
||||
<button
|
||||
className="banner-dismiss-btn"
|
||||
onClick={handleDismiss}
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={`anonymous-viewer-banner ${triggeredByEdit ? 'edit-triggered' : ''} ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div className="banner-content">
|
||||
<div className="banner-header">
|
||||
<div className="banner-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="banner-text">
|
||||
{triggeredByEdit ? (
|
||||
<p className="banner-headline">
|
||||
<strong>Sign in to edit</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="banner-headline">
|
||||
<strong>Viewing anonymously</strong>
|
||||
</p>
|
||||
)}
|
||||
<p className="banner-summary">
|
||||
Sign in with enCryptID to edit
|
||||
</p>
|
||||
</div>
|
||||
<div className="banner-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="banner-text">
|
||||
{triggeredByEdit ? (
|
||||
<p className="banner-headline">
|
||||
<strong>Want to edit this board?</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="banner-headline">
|
||||
<strong>You're viewing this board anonymously</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isExpanded ? (
|
||||
<div className="banner-details">
|
||||
<p>
|
||||
Sign in by creating a username as your <strong>CryptID</strong> — no password required!
|
||||
</p>
|
||||
<ul className="cryptid-benefits">
|
||||
<li>
|
||||
<span className="benefit-icon">🔒</span>
|
||||
<span>Secured with encrypted keys, right in your browser, by a <a href="https://www.w3.org/TR/WebCryptoAPI/" target="_blank" rel="noopener noreferrer">W3C standard</a> algorithm</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="benefit-icon">💾</span>
|
||||
<span>Your session is stored for offline access, encrypted in browser storage by the same key</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="benefit-icon">📦</span>
|
||||
<span>Full data portability — use your canvas securely any time you like</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="banner-summary">
|
||||
Create a free CryptID to edit this board — no password needed!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<div className="banner-actions">
|
||||
<button
|
||||
className="banner-signup-btn"
|
||||
onClick={handleSignUpClick}
|
||||
>
|
||||
Sign in
|
||||
Create CryptID
|
||||
</button>
|
||||
|
||||
{!triggeredByEdit && (
|
||||
<button
|
||||
className="banner-dismiss-btn"
|
||||
onClick={handleDismiss}
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isExpanded && (
|
||||
<button
|
||||
className="banner-expand-btn"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
title="Learn more"
|
||||
>
|
||||
Learn more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{triggeredByEdit && (
|
||||
<div className="banner-edit-notice">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span>Read-only for anonymous viewers</span>
|
||||
<span>This board is in read-only mode for anonymous viewers</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CryptID Sign In Modal - same as CryptIDDropdown */}
|
||||
{showSignUp && createPortal(
|
||||
<div
|
||||
className="cryptid-modal-overlay"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 999999,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleSignUpCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="cryptid-modal"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-panel, #ffffff)',
|
||||
borderRadius: '16px',
|
||||
padding: '0',
|
||||
maxWidth: '580px',
|
||||
width: '95vw',
|
||||
maxHeight: '90vh',
|
||||
boxShadow: '0 25px 80px rgba(0, 0, 0, 0.4)',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleSignUpCancel}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
right: '12px',
|
||||
background: 'var(--color-muted-2, #f3f4f6)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--color-text-2, #6b7280)',
|
||||
fontSize: '16px',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<CryptID
|
||||
onSuccess={handleSignUpSuccess}
|
||||
onCancel={handleSignUpCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -32,6 +32,7 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout, onOpenVaultBrowser }
|
|||
obsidianVaultName: undefined
|
||||
});
|
||||
setIsEditingVault(false);
|
||||
console.log('🔧 Vault disconnected from profile');
|
||||
};
|
||||
|
||||
const handleChangeVault = () => {
|
||||
|
|
@ -62,7 +63,7 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout, onOpenVaultBrowser }
|
|||
return (
|
||||
<div className="profile-container">
|
||||
<div className="profile-header">
|
||||
<h3>enCryptID: {session.username}</h3>
|
||||
<h3>CryptID: {session.username}</h3>
|
||||
</div>
|
||||
|
||||
<div className="profile-settings">
|
||||
|
|
|
|||
|
|
@ -1,631 +0,0 @@
|
|||
/**
|
||||
* VersionHistoryPanel Component
|
||||
*
|
||||
* Displays version history timeline with diff visualization.
|
||||
* - Shows timeline of changes
|
||||
* - Highlights additions (green) and deletions (red)
|
||||
* - Allows reverting to previous versions
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { WORKER_URL } from '../../constants/workerUrl';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface HistoryEntry {
|
||||
hash: string;
|
||||
timestamp: string | null;
|
||||
message: string | null;
|
||||
actor: string;
|
||||
}
|
||||
|
||||
interface SnapshotDiff {
|
||||
added: Record<string, any>;
|
||||
removed: Record<string, any>;
|
||||
modified: Record<string, { before: any; after: any }>;
|
||||
}
|
||||
|
||||
interface VersionHistoryPanelProps {
|
||||
roomId: string;
|
||||
onClose: () => void;
|
||||
onRevert?: (hash: string) => void;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
function formatTimestamp(timestamp: string | null): string {
|
||||
if (!timestamp) return 'Unknown time';
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
// Less than 1 minute ago
|
||||
if (diff < 60000) return 'Just now';
|
||||
|
||||
// Less than 1 hour ago
|
||||
if (diff < 3600000) {
|
||||
const mins = Math.floor(diff / 60000);
|
||||
return `${mins} minute${mins !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
// Less than 24 hours ago
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
// Less than 7 days ago
|
||||
if (diff < 604800000) {
|
||||
const days = Math.floor(diff / 86400000);
|
||||
return `${days} day${days !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
// Older - show full date
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getShapeLabel(record: any): string {
|
||||
if (record?.typeName === 'shape') {
|
||||
const type = record.type || 'shape';
|
||||
const name = record.props?.name || record.props?.text?.slice?.(0, 20) || '';
|
||||
if (name) return `${type}: "${name}"`;
|
||||
return type;
|
||||
}
|
||||
if (record?.typeName === 'page') {
|
||||
return `Page: ${record.name || 'Untitled'}`;
|
||||
}
|
||||
return record?.typeName || 'Record';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function VersionHistoryPanel({
|
||||
roomId,
|
||||
onClose,
|
||||
onRevert,
|
||||
isDarkMode = false,
|
||||
}: VersionHistoryPanelProps) {
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedEntry, setSelectedEntry] = useState<HistoryEntry | null>(null);
|
||||
const [diff, setDiff] = useState<SnapshotDiff | null>(null);
|
||||
const [isLoadingDiff, setIsLoadingDiff] = useState(false);
|
||||
const [isReverting, setIsReverting] = useState(false);
|
||||
const [showConfirmRevert, setShowConfirmRevert] = useState(false);
|
||||
|
||||
// Fetch history on mount
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
}, [roomId]);
|
||||
|
||||
const fetchHistory = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/room/${roomId}/history`);
|
||||
if (!response.ok) throw new Error('Failed to fetch history');
|
||||
const data = await response.json() as { history?: HistoryEntry[] };
|
||||
setHistory(data.history || []);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDiff = async (entry: HistoryEntry, prevEntry: HistoryEntry | null) => {
|
||||
setIsLoadingDiff(true);
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/room/${roomId}/diff`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fromHash: prevEntry?.hash || null,
|
||||
toHash: entry.hash,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch diff');
|
||||
const data = await response.json() as { diff?: SnapshotDiff };
|
||||
setDiff(data.diff || null);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch diff:', err);
|
||||
setDiff(null);
|
||||
} finally {
|
||||
setIsLoadingDiff(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEntryClick = (entry: HistoryEntry, index: number) => {
|
||||
setSelectedEntry(entry);
|
||||
const prevEntry = index < history.length - 1 ? history[index + 1] : null;
|
||||
fetchDiff(entry, prevEntry);
|
||||
};
|
||||
|
||||
const handleRevert = async () => {
|
||||
if (!selectedEntry) return;
|
||||
|
||||
setIsReverting(true);
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/room/${roomId}/revert`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hash: selectedEntry.hash }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to revert');
|
||||
|
||||
// Notify parent
|
||||
onRevert?.(selectedEntry.hash);
|
||||
setShowConfirmRevert(false);
|
||||
|
||||
// Refresh history
|
||||
await fetchHistory();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setIsReverting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Styles
|
||||
const theme = {
|
||||
bg: isDarkMode ? '#1e1e1e' : '#ffffff',
|
||||
bgSecondary: isDarkMode ? '#2d2d2d' : '#f5f5f5',
|
||||
text: isDarkMode ? '#e0e0e0' : '#333333',
|
||||
textMuted: isDarkMode ? '#888888' : '#666666',
|
||||
border: isDarkMode ? '#404040' : '#e0e0e0',
|
||||
accent: '#8b5cf6',
|
||||
green: isDarkMode ? '#4ade80' : '#16a34a',
|
||||
red: isDarkMode ? '#f87171' : '#dc2626',
|
||||
greenBg: isDarkMode ? 'rgba(74, 222, 128, 0.15)' : 'rgba(22, 163, 74, 0.1)',
|
||||
redBg: isDarkMode ? 'rgba(248, 113, 113, 0.15)' : 'rgba(220, 38, 38, 0.1)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '400px',
|
||||
height: '100vh',
|
||||
backgroundColor: theme.bg,
|
||||
borderLeft: `1px solid ${theme.border}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 2000,
|
||||
boxShadow: '-4px 0 24px rgba(0, 0, 0, 0.15)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: `1px solid ${theme.border}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={theme.accent}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span style={{ fontWeight: 600, color: theme.text, fontSize: '16px' }}>
|
||||
Version History
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
color: theme.textMuted,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{isLoading ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
color: theme.textMuted,
|
||||
}}
|
||||
>
|
||||
Loading history...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: theme.red,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
<button
|
||||
onClick={fetchHistory}
|
||||
style={{
|
||||
display: 'block',
|
||||
margin: '10px auto 0',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: theme.accent,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
color: theme.textMuted,
|
||||
}}
|
||||
>
|
||||
No version history available
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Timeline */}
|
||||
<div style={{ flex: '0 0 auto', maxHeight: '40%', overflow: 'auto', padding: '12px 0' }}>
|
||||
{history.map((entry, index) => (
|
||||
<div
|
||||
key={entry.hash}
|
||||
onClick={() => handleEntryClick(entry, index)}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
borderLeft: `3px solid ${
|
||||
selectedEntry?.hash === entry.hash ? theme.accent : 'transparent'
|
||||
}`,
|
||||
backgroundColor:
|
||||
selectedEntry?.hash === entry.hash ? theme.bgSecondary : 'transparent',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedEntry?.hash !== entry.hash) {
|
||||
e.currentTarget.style.backgroundColor = theme.bgSecondary;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedEntry?.hash !== entry.hash) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: theme.text,
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{entry.message || `Change ${entry.hash.slice(0, 8)}`}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: theme.textMuted,
|
||||
}}
|
||||
>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Diff View */}
|
||||
{selectedEntry && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
borderTop: `1px solid ${theme.border}`,
|
||||
overflow: 'auto',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: theme.textMuted,
|
||||
marginBottom: '12px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Changes in this version
|
||||
</div>
|
||||
|
||||
{isLoadingDiff ? (
|
||||
<div style={{ color: theme.textMuted, fontSize: '13px' }}>
|
||||
Loading diff...
|
||||
</div>
|
||||
) : diff ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* Added */}
|
||||
{Object.entries(diff.added).length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: theme.green,
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
+ Added ({Object.entries(diff.added).filter(([id]) => id.startsWith('shape:')).length} shapes)
|
||||
</div>
|
||||
{Object.entries(diff.added)
|
||||
.filter(([id]) => id.startsWith('shape:'))
|
||||
.slice(0, 10)
|
||||
.map(([id, record]) => (
|
||||
<div
|
||||
key={id}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: theme.greenBg,
|
||||
borderLeft: `3px solid ${theme.green}`,
|
||||
borderRadius: '4px',
|
||||
marginBottom: '4px',
|
||||
fontSize: '12px',
|
||||
color: theme.text,
|
||||
}}
|
||||
>
|
||||
{getShapeLabel(record)}
|
||||
</div>
|
||||
))}
|
||||
{Object.entries(diff.added).filter(([id]) => id.startsWith('shape:')).length > 10 && (
|
||||
<div style={{ fontSize: '11px', color: theme.textMuted, marginLeft: '12px' }}>
|
||||
...and {Object.entries(diff.added).filter(([id]) => id.startsWith('shape:')).length - 10} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Removed */}
|
||||
{Object.entries(diff.removed).length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: theme.red,
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
- Removed ({Object.entries(diff.removed).filter(([id]) => id.startsWith('shape:')).length} shapes)
|
||||
</div>
|
||||
{Object.entries(diff.removed)
|
||||
.filter(([id]) => id.startsWith('shape:'))
|
||||
.slice(0, 10)
|
||||
.map(([id, record]) => (
|
||||
<div
|
||||
key={id}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: theme.redBg,
|
||||
borderLeft: `3px solid ${theme.red}`,
|
||||
borderRadius: '4px',
|
||||
marginBottom: '4px',
|
||||
fontSize: '12px',
|
||||
color: theme.text,
|
||||
}}
|
||||
>
|
||||
{getShapeLabel(record)}
|
||||
</div>
|
||||
))}
|
||||
{Object.entries(diff.removed).filter(([id]) => id.startsWith('shape:')).length > 10 && (
|
||||
<div style={{ fontSize: '11px', color: theme.textMuted, marginLeft: '12px' }}>
|
||||
...and {Object.entries(diff.removed).filter(([id]) => id.startsWith('shape:')).length - 10} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modified */}
|
||||
{Object.entries(diff.modified).length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: theme.accent,
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
~ Modified ({Object.entries(diff.modified).filter(([id]) => id.startsWith('shape:')).length} shapes)
|
||||
</div>
|
||||
{Object.entries(diff.modified)
|
||||
.filter(([id]) => id.startsWith('shape:'))
|
||||
.slice(0, 5)
|
||||
.map(([id, { after }]) => (
|
||||
<div
|
||||
key={id}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: theme.bgSecondary,
|
||||
borderLeft: `3px solid ${theme.accent}`,
|
||||
borderRadius: '4px',
|
||||
marginBottom: '4px',
|
||||
fontSize: '12px',
|
||||
color: theme.text,
|
||||
}}
|
||||
>
|
||||
{getShapeLabel(after)}
|
||||
</div>
|
||||
))}
|
||||
{Object.entries(diff.modified).filter(([id]) => id.startsWith('shape:')).length > 5 && (
|
||||
<div style={{ fontSize: '11px', color: theme.textMuted, marginLeft: '12px' }}>
|
||||
...and {Object.entries(diff.modified).filter(([id]) => id.startsWith('shape:')).length - 5} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No visible changes */}
|
||||
{Object.entries(diff.added).filter(([id]) => id.startsWith('shape:')).length === 0 &&
|
||||
Object.entries(diff.removed).filter(([id]) => id.startsWith('shape:')).length === 0 &&
|
||||
Object.entries(diff.modified).filter(([id]) => id.startsWith('shape:')).length === 0 && (
|
||||
<div style={{ color: theme.textMuted, fontSize: '13px' }}>
|
||||
No visible shape changes in this version
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: theme.textMuted, fontSize: '13px' }}>
|
||||
Select a version to see changes
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revert Button */}
|
||||
{selectedEntry && history.indexOf(selectedEntry) !== 0 && (
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
{showConfirmRevert ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
backgroundColor: theme.redBg,
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${theme.red}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '13px', color: theme.text, marginBottom: '12px' }}>
|
||||
Are you sure you want to revert to this version? This will restore the board to this point in time.
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleRevert}
|
||||
disabled={isReverting}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 16px',
|
||||
backgroundColor: theme.red,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: isReverting ? 'not-allowed' : 'pointer',
|
||||
opacity: isReverting ? 0.7 : 1,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{isReverting ? 'Reverting...' : 'Yes, Revert'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowConfirmRevert(false)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 16px',
|
||||
backgroundColor: theme.bgSecondary,
|
||||
color: theme.text,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowConfirmRevert(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: theme.accent,
|
||||
border: `1px solid ${theme.accent}`,
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = theme.accent;
|
||||
e.currentTarget.style.color = 'white';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = theme.accent;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
Revert to this version
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VersionHistoryPanel;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { VersionHistoryPanel } from './VersionHistoryPanel';
|
||||
export { useVersionHistory } from './useVersionHistory';
|
||||
export type { HistoryEntry, SnapshotDiff, UseVersionHistoryReturn } from './useVersionHistory';
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
/**
|
||||
* useVersionHistory Hook
|
||||
*
|
||||
* Provides version history functionality for a board.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { WORKER_URL } from '../../constants/workerUrl';
|
||||
|
||||
export interface HistoryEntry {
|
||||
hash: string;
|
||||
timestamp: string | null;
|
||||
message: string | null;
|
||||
actor: string;
|
||||
}
|
||||
|
||||
export interface SnapshotDiff {
|
||||
added: Record<string, any>;
|
||||
removed: Record<string, any>;
|
||||
modified: Record<string, { before: any; after: any }>;
|
||||
}
|
||||
|
||||
export interface UseVersionHistoryReturn {
|
||||
history: HistoryEntry[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
fetchHistory: () => Promise<void>;
|
||||
fetchDiff: (fromHash: string | null, toHash: string | null) => Promise<SnapshotDiff | null>;
|
||||
revert: (hash: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useVersionHistory(roomId: string): UseVersionHistoryReturn {
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchHistory = useCallback(async () => {
|
||||
if (!roomId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/room/${roomId}/history`);
|
||||
if (!response.ok) throw new Error('Failed to fetch history');
|
||||
const data = await response.json() as { history?: HistoryEntry[] };
|
||||
setHistory(data.history || []);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [roomId]);
|
||||
|
||||
const fetchDiff = useCallback(
|
||||
async (fromHash: string | null, toHash: string | null): Promise<SnapshotDiff | null> => {
|
||||
if (!roomId) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/room/${roomId}/diff`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fromHash, toHash }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch diff');
|
||||
const data = await response.json() as { diff?: SnapshotDiff };
|
||||
return data.diff || null;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch diff:', err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[roomId]
|
||||
);
|
||||
|
||||
const revert = useCallback(
|
||||
async (hash: string): Promise<boolean> => {
|
||||
if (!roomId) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/room/${roomId}/revert`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hash }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to revert');
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[roomId]
|
||||
);
|
||||
|
||||
return {
|
||||
history,
|
||||
isLoading,
|
||||
error,
|
||||
fetchHistory,
|
||||
fetchDiff,
|
||||
revert,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -5,146 +5,13 @@
|
|||
* Extracts room participants from the editor and provides connection actions.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useEditor, useValue } from 'tldraw';
|
||||
import { NetworkGraphMinimap } from './NetworkGraphMinimap';
|
||||
import { useNetworkGraph } from './useNetworkGraph';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import type { GraphEdge, TrustLevel } from '../../lib/networking';
|
||||
|
||||
// =============================================================================
|
||||
// Broadcast Mode Indicator Component
|
||||
// =============================================================================
|
||||
|
||||
interface BroadcastIndicatorProps {
|
||||
followingUser: { id: string; username: string; color?: string } | null;
|
||||
onStop: () => void;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
function BroadcastIndicator({ followingUser, onStop, isDarkMode }: BroadcastIndicatorProps) {
|
||||
if (!followingUser) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 10000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '10px 16px',
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, rgba(168, 85, 247, 0.95), rgba(139, 92, 246, 0.95))'
|
||||
: 'linear-gradient(135deg, rgba(168, 85, 247, 0.95), rgba(139, 92, 246, 0.95))',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 20px rgba(168, 85, 247, 0.4)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
animation: 'pulse-glow 2s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
<style>
|
||||
{`
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 4px 20px rgba(168, 85, 247, 0.4); }
|
||||
50% { box-shadow: 0 4px 30px rgba(168, 85, 247, 0.6); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
{/* Live indicator */}
|
||||
<div
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
background: '#ef4444',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.6; transform: scale(0.9); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
{/* User avatar */}
|
||||
<div
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
background: followingUser.color || '#6366f1',
|
||||
border: '2px solid rgba(255, 255, 255, 0.5)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Text */}
|
||||
<div style={{ color: '#fff', fontSize: '13px', fontWeight: 500 }}>
|
||||
<span style={{ opacity: 0.8 }}>Viewing as</span>{' '}
|
||||
<strong>{followingUser.username}</strong>
|
||||
</div>
|
||||
|
||||
{/* Exit hint */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
marginLeft: '8px',
|
||||
padding: '4px 8px',
|
||||
background: 'rgba(0, 0, 0, 0.2)',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
<kbd
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
color: '#fff',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
ESC
|
||||
</kbd>
|
||||
<span style={{ color: 'rgba(255, 255, 255, 0.7)', fontSize: '11px' }}>to exit</span>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onStop}
|
||||
style={{
|
||||
marginLeft: '4px',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
|
@ -160,83 +27,9 @@ interface NetworkGraphPanelProps {
|
|||
export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
||||
const editor = useEditor();
|
||||
const { session } = useAuth();
|
||||
|
||||
// Start collapsed on mobile for less cluttered UI
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||
const [isCollapsed, setIsCollapsed] = useState(isMobile);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [selectedEdge, setSelectedEdge] = useState<GraphEdge | null>(null);
|
||||
|
||||
// Broadcast mode state - tracks who we're following
|
||||
const [followingUser, setFollowingUser] = useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
color?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Detect dark mode
|
||||
const [isDarkMode, setIsDarkMode] = useState(
|
||||
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Listen for theme changes
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
setIsDarkMode(document.documentElement.classList.contains('dark'));
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Stop following user - cleanup function
|
||||
const stopFollowingUser = useCallback(() => {
|
||||
if (!editor) return;
|
||||
|
||||
editor.stopFollowingUser();
|
||||
setFollowingUser(null);
|
||||
|
||||
// Remove followId from URL if present
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has('followId')) {
|
||||
url.searchParams.delete('followId');
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
|
||||
}, [editor]);
|
||||
|
||||
// Keyboard handler for ESC and X to exit broadcast mode
|
||||
useEffect(() => {
|
||||
if (!followingUser) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// ESC or X (lowercase or uppercase) stops following
|
||||
if (e.key === 'Escape' || e.key === 'x' || e.key === 'X') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stopFollowingUser();
|
||||
}
|
||||
};
|
||||
|
||||
// Use capture phase to intercept before tldraw
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [followingUser, stopFollowingUser]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (followingUser && editor) {
|
||||
editor.stopFollowingUser();
|
||||
}
|
||||
};
|
||||
}, [followingUser, editor]);
|
||||
|
||||
// Get collaborators from tldraw
|
||||
const collaborators = useValue(
|
||||
'collaborators',
|
||||
|
|
@ -258,10 +51,10 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
|||
},
|
||||
];
|
||||
|
||||
// Add collaborators - TLInstancePresence has userId and userName
|
||||
// Add collaborators
|
||||
collaborators.forEach((c: any) => {
|
||||
participants.push({
|
||||
id: c.userId || c.id,
|
||||
id: c.id || c.userId || c.instanceId,
|
||||
username: c.userName || 'Anonymous',
|
||||
color: c.color,
|
||||
});
|
||||
|
|
@ -285,9 +78,9 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
|||
useCache: true,
|
||||
});
|
||||
|
||||
// Handle connect with optional trust level
|
||||
const handleConnect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => {
|
||||
await connect(userId, trustLevel);
|
||||
// Handle connect with default trust level
|
||||
const handleConnect = useCallback(async (userId: string) => {
|
||||
await connect(userId);
|
||||
}, [connect]);
|
||||
|
||||
// Handle disconnect
|
||||
|
|
@ -296,76 +89,16 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
|||
}, [disconnect]);
|
||||
|
||||
// Handle node click
|
||||
const handleNodeClick = useCallback((_node: any) => {
|
||||
const handleNodeClick = useCallback((node: any) => {
|
||||
// Could open a profile modal or navigate to user
|
||||
}, []);
|
||||
|
||||
// Handle going to a user's cursor on canvas (navigate/pan to their location)
|
||||
const handleGoToUser = useCallback((node: any) => {
|
||||
if (!editor) return;
|
||||
|
||||
// Find the collaborator's cursor position
|
||||
// TLInstancePresence has userId and userName properties
|
||||
const targetCollaborator = collaborators.find((c: any) =>
|
||||
c.id === node.id ||
|
||||
c.userId === node.id ||
|
||||
c.userName === node.username
|
||||
);
|
||||
|
||||
if (targetCollaborator && targetCollaborator.cursor) {
|
||||
// Pan to the user's cursor position
|
||||
const { x, y } = targetCollaborator.cursor;
|
||||
editor.centerOnPoint({ x, y });
|
||||
} else {
|
||||
// If no cursor position, try to find any presence data
|
||||
}
|
||||
}, [editor, collaborators]);
|
||||
|
||||
// Handle screen following a user (camera follows their view)
|
||||
const handleFollowUser = useCallback((node: any) => {
|
||||
if (!editor) return;
|
||||
|
||||
// Find the collaborator to follow
|
||||
// TLInstancePresence has userId and userName properties
|
||||
const targetCollaborator = collaborators.find((c: any) =>
|
||||
c.id === node.id ||
|
||||
c.userId === node.id ||
|
||||
c.userName === node.username
|
||||
);
|
||||
|
||||
if (targetCollaborator) {
|
||||
// Use tldraw's built-in follow functionality - needs userId
|
||||
const userId = targetCollaborator.userId || targetCollaborator.id;
|
||||
editor.startFollowingUser(userId);
|
||||
|
||||
// Set state to show broadcast indicator and enable keyboard exit
|
||||
setFollowingUser({
|
||||
id: userId,
|
||||
username: node.username || node.displayName || 'User',
|
||||
color: targetCollaborator.color || node.avatarColor || node.roomPresenceColor,
|
||||
});
|
||||
|
||||
// Optionally add followId to URL for deep linking
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('followId', userId);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
|
||||
} else {
|
||||
}
|
||||
}, [editor, collaborators]);
|
||||
|
||||
// Handle opening a user's profile
|
||||
const handleOpenProfile = useCallback((node: any) => {
|
||||
// Open user profile in a new tab or modal
|
||||
const username = node.username || node.id;
|
||||
// Navigate to user profile page
|
||||
window.open(`/profile/${username}`, '_blank');
|
||||
console.log('Node clicked:', node);
|
||||
}, []);
|
||||
|
||||
// Handle edge click
|
||||
const handleEdgeClick = useCallback((edge: GraphEdge) => {
|
||||
setSelectedEdge(edge);
|
||||
// Could open an edge metadata editor modal
|
||||
console.log('Edge clicked:', edge);
|
||||
}, []);
|
||||
|
||||
// Handle expand to full 3D view
|
||||
|
|
@ -378,6 +111,11 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
|||
}
|
||||
}, [onExpand]);
|
||||
|
||||
// Don't render if not authenticated
|
||||
if (!session.authed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show loading state briefly
|
||||
if (isLoading && nodes.length === 0) {
|
||||
return (
|
||||
|
|
@ -397,33 +135,19 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Broadcast mode indicator - shows when following a user */}
|
||||
<BroadcastIndicator
|
||||
followingUser={followingUser}
|
||||
onStop={stopFollowingUser}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
|
||||
{/* Network graph minimap */}
|
||||
<NetworkGraphMinimap
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
myConnections={myConnections}
|
||||
currentUserId={session.username}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
onGoToUser={handleGoToUser}
|
||||
onFollowUser={handleFollowUser}
|
||||
onOpenProfile={handleOpenProfile}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onExpandClick={handleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</>
|
||||
<NetworkGraphMinimap
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
myConnections={myConnections}
|
||||
currentUserId={session.username}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onExpandClick={handleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -256,24 +256,19 @@ export function UserSearchModal({
|
|||
}
|
||||
}, [onConnect, onDisconnect]);
|
||||
|
||||
// Handle escape key - use a ref to avoid stale closure issues
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCloseRef.current();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true); // Use capture phase
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [isOpen]);
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
*/
|
||||
|
||||
export { NetworkGraphMinimap } from './NetworkGraphMinimap';
|
||||
export { NetworkGraph3D } from './NetworkGraph3D';
|
||||
export { NetworkGraphPanel } from './NetworkGraphPanel';
|
||||
export { UserSearchModal } from './UserSearchModal';
|
||||
export { useNetworkGraph, useRoomParticipantsFromEditor } from './useNetworkGraph';
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import {
|
|||
type NetworkGraph,
|
||||
type GraphNode,
|
||||
type GraphEdge,
|
||||
type TrustLevel,
|
||||
} from '../../lib/networking';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -54,8 +53,8 @@ export interface UseNetworkGraphOptions {
|
|||
export interface UseNetworkGraphReturn extends NetworkGraphState {
|
||||
// Refresh the graph from the server
|
||||
refresh: () => Promise<void>;
|
||||
// Connect to a user with optional trust level
|
||||
connect: (userId: string, trustLevel?: TrustLevel) => Promise<void>;
|
||||
// Connect to a user
|
||||
connect: (userId: string) => Promise<void>;
|
||||
// Disconnect from a user
|
||||
disconnect: (connectionId: string) => Promise<void>;
|
||||
// Check if connected to a user
|
||||
|
|
@ -104,29 +103,12 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
|
|||
|
||||
// Fetch the network graph
|
||||
const fetchGraph = useCallback(async (skipCache = false) => {
|
||||
// For unauthenticated users, just show room participants without network connections
|
||||
if (!session.authed || !session.username) {
|
||||
// Create nodes from room participants for anonymous users
|
||||
const anonymousNodes: GraphNode[] = roomParticipants.map(participant => ({
|
||||
id: participant.id,
|
||||
username: participant.username,
|
||||
displayName: participant.username,
|
||||
avatarColor: participant.color,
|
||||
isInRoom: true,
|
||||
roomPresenceColor: participant.color,
|
||||
isCurrentUser: participant.id === roomParticipants[0]?.id, // First participant is current user
|
||||
isAnonymous: true,
|
||||
trustLevelTo: undefined,
|
||||
trustLevelFrom: undefined,
|
||||
}));
|
||||
|
||||
setState({
|
||||
nodes: anonymousNodes,
|
||||
edges: [],
|
||||
myConnections: [],
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
error: 'Not authenticated',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -155,80 +137,12 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
|
|||
try {
|
||||
setState(prev => ({ ...prev, isLoading: !prev.nodes.length }));
|
||||
|
||||
// Double-check authentication before making API calls
|
||||
// This handles race conditions where session state might not be updated yet
|
||||
const currentUserId = (() => {
|
||||
try {
|
||||
// Session is stored as 'canvas_auth_session' by sessionPersistence.ts
|
||||
const sessionStr = localStorage.getItem('canvas_auth_session');
|
||||
if (sessionStr) {
|
||||
const s = JSON.parse(sessionStr);
|
||||
if (s.authed && s.username) return s.username;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (!currentUserId) {
|
||||
// Not authenticated - use room participants only
|
||||
const anonymousNodes: GraphNode[] = roomParticipants.map(participant => ({
|
||||
id: participant.id,
|
||||
username: participant.username,
|
||||
displayName: participant.username,
|
||||
avatarColor: participant.color,
|
||||
isInRoom: true,
|
||||
roomPresenceColor: participant.color,
|
||||
isCurrentUser: participant.id === roomParticipants[0]?.id,
|
||||
isAnonymous: true,
|
||||
trustLevelTo: undefined,
|
||||
trustLevelFrom: undefined,
|
||||
}));
|
||||
|
||||
setState({
|
||||
nodes: anonymousNodes,
|
||||
edges: [],
|
||||
myConnections: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch graph, optionally scoped to room
|
||||
let graph: NetworkGraph;
|
||||
try {
|
||||
if (participantIds.length > 0) {
|
||||
graph = await getRoomNetworkGraph(participantIds);
|
||||
} else {
|
||||
graph = await getMyNetworkGraph();
|
||||
}
|
||||
} catch (apiError: any) {
|
||||
// If API call fails (e.g., 401 Unauthorized), fall back to showing room participants
|
||||
// Only log if it's not a 401 (which is expected for auth issues)
|
||||
if (!apiError.message?.includes('401')) {
|
||||
console.warn('Network graph API failed, falling back to room participants:', apiError.message);
|
||||
}
|
||||
const fallbackNodes: GraphNode[] = roomParticipants.map(participant => ({
|
||||
id: participant.id,
|
||||
username: participant.username,
|
||||
displayName: participant.username,
|
||||
avatarColor: participant.color,
|
||||
isInRoom: true,
|
||||
roomPresenceColor: participant.color,
|
||||
isCurrentUser: participant.id === session.username || participant.id === roomParticipants[0]?.id,
|
||||
isAnonymous: false,
|
||||
trustLevelTo: undefined,
|
||||
trustLevelFrom: undefined,
|
||||
}));
|
||||
|
||||
setState({
|
||||
nodes: fallbackNodes,
|
||||
edges: [],
|
||||
myConnections: [],
|
||||
isLoading: false,
|
||||
error: null, // Don't show error to user - graceful degradation
|
||||
});
|
||||
return;
|
||||
if (participantIds.length > 0) {
|
||||
graph = await getRoomNetworkGraph(participantIds);
|
||||
} else {
|
||||
graph = await getMyNetworkGraph();
|
||||
}
|
||||
|
||||
// Enrich nodes with room status, current user flag, and anonymous status
|
||||
|
|
@ -242,27 +156,6 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
|
|||
isAnonymous: false, // Nodes from the graph are authenticated
|
||||
}));
|
||||
|
||||
// Always ensure the current user is in the graph, even if they have no connections
|
||||
const currentUserInGraph = enrichedNodes.some(n => n.isCurrentUser);
|
||||
if (!currentUserInGraph) {
|
||||
// Find current user in room participants
|
||||
const currentUserParticipant = roomParticipants.find(p => p.id === session.username);
|
||||
if (currentUserParticipant) {
|
||||
enrichedNodes.push({
|
||||
id: currentUserParticipant.id,
|
||||
username: currentUserParticipant.username,
|
||||
displayName: currentUserParticipant.username,
|
||||
avatarColor: currentUserParticipant.color,
|
||||
isInRoom: true,
|
||||
roomPresenceColor: currentUserParticipant.color,
|
||||
isCurrentUser: true,
|
||||
isAnonymous: false,
|
||||
trustLevelTo: undefined,
|
||||
trustLevelFrom: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add room participants who are not in the network graph as anonymous nodes
|
||||
roomParticipants.forEach(participant => {
|
||||
if (!graphNodeIds.has(participant.id) && participant.id !== session.username) {
|
||||
|
|
@ -303,30 +196,13 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
|
|||
error: (error as Error).message,
|
||||
}));
|
||||
}
|
||||
}, [session.authed, session.username, participantIds, participantColorMap, useCache, roomParticipants]);
|
||||
}, [session.authed, session.username, participantIds, participantColorMap, useCache]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchGraph();
|
||||
}, [fetchGraph]);
|
||||
|
||||
// Listen for session-cleared event to immediately clear graph state
|
||||
useEffect(() => {
|
||||
const handleSessionCleared = () => {
|
||||
clearGraphCache();
|
||||
setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
myConnections: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('session-cleared', handleSessionCleared);
|
||||
return () => window.removeEventListener('session-cleared', handleSessionCleared);
|
||||
}, []);
|
||||
|
||||
// Refresh interval
|
||||
useEffect(() => {
|
||||
if (refreshInterval > 0) {
|
||||
|
|
@ -335,57 +211,22 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
|
|||
}
|
||||
}, [refreshInterval, fetchGraph]);
|
||||
|
||||
// Update room status when participants change AND add new participants immediately
|
||||
// Update room status when participants change
|
||||
useEffect(() => {
|
||||
setState(prev => {
|
||||
const existingNodeIds = new Set(prev.nodes.map(n => n.id));
|
||||
|
||||
// Update existing nodes with room status
|
||||
const updatedNodes = prev.nodes.map(node => ({
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
nodes: prev.nodes.map(node => ({
|
||||
...node,
|
||||
isInRoom: participantIds.includes(node.id),
|
||||
roomPresenceColor: participantColorMap.get(node.id),
|
||||
}));
|
||||
|
||||
// Add any new room participants that aren't in the graph yet
|
||||
roomParticipants.forEach(participant => {
|
||||
if (!existingNodeIds.has(participant.id)) {
|
||||
// Check if this is the current user
|
||||
const isCurrentUser = participant.id === session.username;
|
||||
|
||||
// Check if this looks like an anonymous/guest ID
|
||||
const isAnonymous = !isCurrentUser && (
|
||||
participant.username.startsWith('Guest') ||
|
||||
participant.username === 'Anonymous' ||
|
||||
!participant.id.match(/^[a-zA-Z0-9_-]+$/)
|
||||
);
|
||||
|
||||
updatedNodes.push({
|
||||
id: participant.id,
|
||||
username: participant.username,
|
||||
displayName: participant.username,
|
||||
avatarColor: participant.color,
|
||||
isInRoom: true,
|
||||
roomPresenceColor: participant.color,
|
||||
isCurrentUser,
|
||||
isAnonymous,
|
||||
trustLevelTo: undefined,
|
||||
trustLevelFrom: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...prev,
|
||||
nodes: updatedNodes,
|
||||
};
|
||||
});
|
||||
}, [participantIds, participantColorMap, roomParticipants, session.username]);
|
||||
})),
|
||||
}));
|
||||
}, [participantIds, participantColorMap]);
|
||||
|
||||
// Connect to a user
|
||||
const connect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => {
|
||||
const connect = useCallback(async (userId: string) => {
|
||||
try {
|
||||
await createConnection(userId, trustLevel);
|
||||
await createConnection(userId);
|
||||
// Refresh the graph to get updated state
|
||||
await fetchGraph(true);
|
||||
clearGraphCache();
|
||||
|
|
|
|||
|
|
@ -1,273 +0,0 @@
|
|||
/**
|
||||
* WorkflowPalette
|
||||
*
|
||||
* Sidebar palette showing available workflow blocks organized by category.
|
||||
* Supports click-to-place and displays block descriptions.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react'
|
||||
import { Editor } from 'tldraw'
|
||||
import {
|
||||
getAllBlockDefinitions,
|
||||
getBlocksByCategory,
|
||||
} from '@/lib/workflow/blockRegistry'
|
||||
import {
|
||||
BlockCategory,
|
||||
BlockDefinition,
|
||||
CATEGORY_INFO,
|
||||
} from '@/lib/workflow/types'
|
||||
import {
|
||||
setWorkflowBlockType,
|
||||
} from '@/tools/WorkflowBlockTool'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface WorkflowPaletteProps {
|
||||
editor: Editor
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Category Section Component
|
||||
// =============================================================================
|
||||
|
||||
interface CategorySectionProps {
|
||||
category: BlockCategory
|
||||
blocks: BlockDefinition[]
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onBlockClick: (blockType: string) => void
|
||||
}
|
||||
|
||||
const CategorySection: React.FC<CategorySectionProps> = ({
|
||||
category,
|
||||
blocks,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onBlockClick,
|
||||
}) => {
|
||||
const info = CATEGORY_INFO[category]
|
||||
|
||||
return (
|
||||
<div className="workflow-palette-category">
|
||||
<button
|
||||
className="workflow-palette-category-header"
|
||||
onClick={onToggle}
|
||||
style={{ borderLeftColor: info.color }}
|
||||
>
|
||||
<span className="workflow-palette-category-icon">{info.icon}</span>
|
||||
<span className="workflow-palette-category-label">{info.label}</span>
|
||||
<span className="workflow-palette-category-count">{blocks.length}</span>
|
||||
<span className={`workflow-palette-chevron ${isExpanded ? 'expanded' : ''}`}>
|
||||
▶
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="workflow-palette-blocks">
|
||||
{blocks.map((block) => (
|
||||
<BlockCard
|
||||
key={block.type}
|
||||
block={block}
|
||||
categoryColor={info.color}
|
||||
onClick={() => onBlockClick(block.type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Block Card Component
|
||||
// =============================================================================
|
||||
|
||||
interface BlockCardProps {
|
||||
block: BlockDefinition
|
||||
categoryColor: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const BlockCard: React.FC<BlockCardProps> = ({ block, categoryColor, onClick }) => {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<button
|
||||
className="workflow-palette-block"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
borderLeftColor: isHovered ? categoryColor : 'transparent',
|
||||
}}
|
||||
>
|
||||
<span className="workflow-palette-block-icon">{block.icon}</span>
|
||||
<div className="workflow-palette-block-content">
|
||||
<span className="workflow-palette-block-name">{block.name}</span>
|
||||
<span className="workflow-palette-block-description">
|
||||
{block.description}
|
||||
</span>
|
||||
</div>
|
||||
<div className="workflow-palette-block-ports">
|
||||
<span className="workflow-palette-port-count" title="Inputs">
|
||||
← {block.inputs.length}
|
||||
</span>
|
||||
<span className="workflow-palette-port-count" title="Outputs">
|
||||
{block.outputs.length} →
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Search Bar Component
|
||||
// =============================================================================
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const SearchBar: React.FC<SearchBarProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="workflow-palette-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search blocks..."
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="workflow-palette-search-input"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
className="workflow-palette-search-clear"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Palette Component
|
||||
// =============================================================================
|
||||
|
||||
const WorkflowPalette: React.FC<WorkflowPaletteProps> = ({
|
||||
editor,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<BlockCategory>>(
|
||||
new Set(['trigger', 'action'])
|
||||
)
|
||||
|
||||
const allBlocks = useMemo(() => getAllBlockDefinitions(), [])
|
||||
|
||||
const categories: BlockCategory[] = [
|
||||
'trigger',
|
||||
'action',
|
||||
'condition',
|
||||
'transformer',
|
||||
'ai',
|
||||
'output',
|
||||
]
|
||||
|
||||
const filteredBlocksByCategory = useMemo(() => {
|
||||
const result: Record<BlockCategory, BlockDefinition[]> = {
|
||||
trigger: [],
|
||||
action: [],
|
||||
condition: [],
|
||||
transformer: [],
|
||||
ai: [],
|
||||
output: [],
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
|
||||
for (const block of allBlocks) {
|
||||
const matches =
|
||||
!query ||
|
||||
block.name.toLowerCase().includes(query) ||
|
||||
block.description.toLowerCase().includes(query) ||
|
||||
block.type.toLowerCase().includes(query)
|
||||
|
||||
if (matches) {
|
||||
result[block.category].push(block)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}, [allBlocks, searchQuery])
|
||||
|
||||
const toggleCategory = useCallback((category: BlockCategory) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(category)) {
|
||||
next.delete(category)
|
||||
} else {
|
||||
next.add(category)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleBlockClick = useCallback(
|
||||
(blockType: string) => {
|
||||
// Set the block type for the tool
|
||||
setWorkflowBlockType(blockType)
|
||||
|
||||
// Switch to the WorkflowBlock tool
|
||||
editor.setCurrentTool('WorkflowBlock')
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="workflow-palette">
|
||||
<div className="workflow-palette-header">
|
||||
<h3 className="workflow-palette-title">Workflow Blocks</h3>
|
||||
<button className="workflow-palette-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SearchBar value={searchQuery} onChange={setSearchQuery} />
|
||||
|
||||
<div className="workflow-palette-content">
|
||||
{categories.map((category) => {
|
||||
const blocks = filteredBlocksByCategory[category]
|
||||
if (blocks.length === 0 && searchQuery) return null
|
||||
|
||||
return (
|
||||
<CategorySection
|
||||
key={category}
|
||||
category={category}
|
||||
blocks={blocks}
|
||||
isExpanded={expandedCategories.has(category) || !!searchQuery}
|
||||
onToggle={() => toggleCategory(category)}
|
||||
onBlockClick={handleBlockClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="workflow-palette-footer">
|
||||
<div className="workflow-palette-hint">
|
||||
Click a block to place it on the canvas
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowPalette
|
||||
|
|
@ -134,6 +134,7 @@ export function saveQuartzSyncSettings(settings: Partial<QuartzSyncSettings>): v
|
|||
const currentSettings = getQuartzSyncSettings()
|
||||
const newSettings = { ...currentSettings, ...settings }
|
||||
localStorage.setItem('quartz_sync_settings', JSON.stringify(newSettings))
|
||||
console.log('✅ Quartz sync settings saved')
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to save Quartz sync settings:', error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,15 @@
|
|||
// You can easily switch between environments by changing the WORKER_ENV variable
|
||||
|
||||
// Available environments:
|
||||
// - 'local': Use local worker running on port 5172 (for local development)
|
||||
// - 'dev': Use Cloudflare dev worker (jeffemmett-canvas-automerge-dev)
|
||||
// - 'staging': Use Cloudflare dev worker (same as dev, for Netcup staging)
|
||||
// - 'production': Use production worker (jeffemmett-canvas)
|
||||
// - 'local': Use local worker running on port 5172
|
||||
// - 'dev': Use Cloudflare dev environment (jeffemmett-canvas-automerge-dev)
|
||||
// - 'production': Use production environment (jeffemmett-canvas)
|
||||
|
||||
const WORKER_ENV = import.meta.env.VITE_WORKER_ENV || 'production' // Default to production
|
||||
|
||||
const WORKER_URLS = {
|
||||
local: `http://${window.location.hostname}:5172`,
|
||||
dev: "https://jeffemmett-canvas-automerge-dev.jeffemmett.workers.dev",
|
||||
staging: "https://jeffemmett-canvas-automerge-dev.jeffemmett.workers.dev",
|
||||
dev: `http://${window.location.hostname}:5172`,
|
||||
production: "https://jeffemmett-canvas.jeffemmett.workers.dev"
|
||||
}
|
||||
|
||||
|
|
@ -28,8 +26,11 @@ export const getWorkerInfo = () => ({
|
|||
url: WORKER_URL,
|
||||
isLocal: WORKER_ENV === 'local',
|
||||
isDev: WORKER_ENV === 'dev',
|
||||
isStaging: WORKER_ENV === 'staging',
|
||||
isProduction: WORKER_ENV === 'production'
|
||||
})
|
||||
|
||||
// Log current environment on import (for debugging)
|
||||
console.log(`🔧 Worker Environment: ${WORKER_ENV}`)
|
||||
console.log(`🔧 Worker URL: ${WORKER_URL}`)
|
||||
console.log(`🔧 Available environments: local, dev, production`)
|
||||
console.log(`🔧 To switch: Set VITE_WORKER_ENV environment variable or change WORKER_ENV in this file`)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode, useRef } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react';
|
||||
import { Session, SessionError, PermissionLevel } from '../lib/auth/types';
|
||||
import { AuthService } from '../lib/auth/authService';
|
||||
import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence';
|
||||
|
|
@ -20,10 +20,6 @@ interface AuthContextType {
|
|||
canEdit: () => boolean;
|
||||
/** Check if user is admin for the current board */
|
||||
isAdmin: () => boolean;
|
||||
/** Current access token from URL (if any) */
|
||||
accessToken: string | null;
|
||||
/** Set access token (from URL parameter) */
|
||||
setAccessToken: (token: string | null) => void;
|
||||
}
|
||||
|
||||
const initialSession: Session = {
|
||||
|
|
@ -39,25 +35,6 @@ export const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
|||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [session, setSessionState] = useState<Session>(initialSession);
|
||||
const [accessToken, setAccessTokenState] = useState<string | null>(null);
|
||||
|
||||
// Track when auth state changes to bypass cache for a short period
|
||||
// This prevents stale callbacks from using old cached permissions
|
||||
const authChangedAtRef = useRef<number>(0);
|
||||
|
||||
// Extract access token from URL on mount
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const token = urlParams.get('token');
|
||||
if (token) {
|
||||
setAccessTokenState(token);
|
||||
// Optionally remove from URL to clean it up (but keep the token in state)
|
||||
// This prevents the token from being shared if someone copies the URL
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('token');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update session with partial data
|
||||
const setSession = useCallback((updatedSession: Partial<Session>) => {
|
||||
|
|
@ -108,16 +85,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
const result = await AuthService.login(username);
|
||||
|
||||
if (result.success && result.session) {
|
||||
// IMPORTANT: Mark auth as just changed - prevents stale callbacks from using cache
|
||||
authChangedAtRef.current = Date.now();
|
||||
|
||||
// IMPORTANT: Clear permission cache when auth state changes
|
||||
// This forces a fresh permission fetch with the new credentials
|
||||
setSessionState({
|
||||
...result.session,
|
||||
boardPermissions: {},
|
||||
currentBoardPermission: undefined,
|
||||
});
|
||||
setSessionState(result.session);
|
||||
|
||||
// Save session to localStorage if authenticated
|
||||
if (result.session.authed && result.session.username) {
|
||||
|
|
@ -153,16 +121,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
const result = await AuthService.register(username);
|
||||
|
||||
if (result.success && result.session) {
|
||||
// IMPORTANT: Mark auth as just changed - prevents stale callbacks from using cache
|
||||
authChangedAtRef.current = Date.now();
|
||||
|
||||
// IMPORTANT: Clear permission cache when auth state changes
|
||||
// This forces a fresh permission fetch with the new credentials
|
||||
setSessionState({
|
||||
...result.session,
|
||||
boardPermissions: {},
|
||||
currentBoardPermission: undefined,
|
||||
});
|
||||
setSessionState(result.session);
|
||||
|
||||
// Save session to localStorage if authenticated
|
||||
if (result.session.authed && result.session.username) {
|
||||
|
|
@ -178,7 +137,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
console.error('Register error:', error);
|
||||
setSessionState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
|
|
@ -192,9 +151,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
* Clear the current session
|
||||
*/
|
||||
const clearSession = useCallback((): void => {
|
||||
// IMPORTANT: Mark auth as just changed - prevents stale callbacks from using cache
|
||||
authChangedAtRef.current = Date.now();
|
||||
|
||||
clearStoredSession();
|
||||
setSessionState({
|
||||
username: '',
|
||||
|
|
@ -202,10 +158,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
loading: false,
|
||||
backupCreated: null,
|
||||
obsidianVaultPath: undefined,
|
||||
obsidianVaultName: undefined,
|
||||
// IMPORTANT: Clear permission cache on logout to force fresh fetch on next login
|
||||
boardPermissions: {},
|
||||
currentBoardPermission: undefined,
|
||||
obsidianVaultName: undefined
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
|
@ -222,30 +175,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
}
|
||||
}, [clearSession]);
|
||||
|
||||
// Setter for access token
|
||||
const setAccessToken = useCallback((token: string | null) => {
|
||||
setAccessTokenState(token);
|
||||
// Clear cached permissions when token changes (they may be different)
|
||||
if (token) {
|
||||
setSessionState(prev => ({
|
||||
...prev,
|
||||
boardPermissions: {},
|
||||
currentBoardPermission: undefined,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch and cache the user's permission level for a specific board
|
||||
* Includes access token if available (from share link)
|
||||
*/
|
||||
const fetchBoardPermission = useCallback(async (boardId: string): Promise<PermissionLevel> => {
|
||||
// IMPORTANT: Check if auth state changed recently (within last 5 seconds)
|
||||
// If so, bypass cache entirely to prevent stale callbacks from returning old cached values
|
||||
const authChangedRecently = Date.now() - authChangedAtRef.current < 5000;
|
||||
|
||||
// Check cache first (but only if no access token and auth didn't just change)
|
||||
if (!accessToken && !authChangedRecently && session.boardPermissions?.[boardId]) {
|
||||
// Check cache first
|
||||
if (session.boardPermissions?.[boardId]) {
|
||||
return session.boardPermissions[boardId];
|
||||
}
|
||||
|
||||
|
|
@ -255,77 +190,59 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
let publicKeyUsed: string | null = null;
|
||||
if (session.authed && session.username) {
|
||||
const publicKey = crypto.getPublicKey(session.username);
|
||||
if (publicKey) {
|
||||
headers['X-CryptID-PublicKey'] = publicKey;
|
||||
publicKeyUsed = publicKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Build URL with optional access token
|
||||
let url = `${WORKER_URL}/boards/${boardId}/permission`;
|
||||
if (accessToken) {
|
||||
url += `?token=${encodeURIComponent(accessToken)}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Default to 'edit' for everyone (open by default) if API fails
|
||||
return 'edit';
|
||||
console.error('Failed to fetch board permission:', response.status);
|
||||
// Default to 'view' for unauthenticated, 'edit' for authenticated
|
||||
return session.authed ? 'edit' : 'view';
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
permission: PermissionLevel;
|
||||
isOwner: boolean;
|
||||
boardExists: boolean;
|
||||
grantedByToken?: boolean;
|
||||
isExplicitPermission?: boolean; // Whether this permission was explicitly set
|
||||
isProtected?: boolean; // Whether board is in protected mode
|
||||
isGlobalAdmin?: boolean; // Whether user is global admin
|
||||
};
|
||||
|
||||
// NEW PERMISSION MODEL (Dec 2024):
|
||||
// - Everyone (including anonymous) can EDIT by default
|
||||
// - Only protected boards restrict editing to listed editors
|
||||
// The backend now returns the correct permission, so we just use it directly
|
||||
let effectivePermission = data.permission;
|
||||
|
||||
// Cache the permission
|
||||
setSessionState(prev => ({
|
||||
...prev,
|
||||
currentBoardPermission: effectivePermission,
|
||||
currentBoardPermission: data.permission,
|
||||
boardPermissions: {
|
||||
...prev.boardPermissions,
|
||||
[boardId]: effectivePermission,
|
||||
[boardId]: data.permission,
|
||||
},
|
||||
}));
|
||||
|
||||
return effectivePermission;
|
||||
return data.permission;
|
||||
} catch (error) {
|
||||
console.error('Error fetching board permission:', error);
|
||||
// Default to 'edit' for everyone (open by default)
|
||||
return 'edit';
|
||||
// Default to 'view' for unauthenticated, 'edit' for authenticated
|
||||
return session.authed ? 'edit' : 'view';
|
||||
}
|
||||
}, [session.authed, session.username, session.boardPermissions, accessToken]);
|
||||
}, [session.authed, session.username, session.boardPermissions]);
|
||||
|
||||
/**
|
||||
* Check if user can edit the current board
|
||||
* NEW: Returns true by default (open permission model)
|
||||
*/
|
||||
const canEdit = useCallback((): boolean => {
|
||||
const permission = session.currentBoardPermission;
|
||||
if (!permission) {
|
||||
// NEW: If no permission set, default to edit (open by default)
|
||||
return true;
|
||||
// If no permission set, default based on auth status
|
||||
return session.authed;
|
||||
}
|
||||
return permission === 'edit' || permission === 'admin';
|
||||
}, [session.currentBoardPermission]);
|
||||
}, [session.currentBoardPermission, session.authed]);
|
||||
|
||||
/**
|
||||
* Check if user is admin for the current board
|
||||
|
|
@ -361,9 +278,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
fetchBoardPermission,
|
||||
canEdit,
|
||||
isAdmin,
|
||||
accessToken,
|
||||
setAccessToken,
|
||||
}), [session, setSession, clearSession, initialize, login, register, logout, fetchBoardPermission, canEdit, isAdmin, accessToken, setAccessToken]);
|
||||
}), [session, setSession, clearSession, initialize, login, register, logout, fetchBoardPermission, canEdit, isAdmin]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import React, { createContext, useContext, ReactNode } from 'react'
|
||||
import { ConnectionState } from '@/automerge/CloudflareAdapter'
|
||||
|
||||
interface ConnectionContextValue {
|
||||
connectionState: ConnectionState
|
||||
isNetworkOnline: boolean
|
||||
}
|
||||
|
||||
const ConnectionContext = createContext<ConnectionContextValue | null>(null)
|
||||
|
||||
interface ConnectionProviderProps {
|
||||
connectionState: ConnectionState
|
||||
isNetworkOnline: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ConnectionProvider({
|
||||
connectionState,
|
||||
isNetworkOnline,
|
||||
children,
|
||||
}: ConnectionProviderProps) {
|
||||
return (
|
||||
<ConnectionContext.Provider value={{ connectionState, isNetworkOnline }}>
|
||||
{children}
|
||||
</ConnectionContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useConnectionStatus() {
|
||||
const context = useContext(ConnectionContext)
|
||||
if (!context) {
|
||||
// Return default values when not in provider (e.g., during SSR or outside Board)
|
||||
return {
|
||||
connectionState: 'connected' as ConnectionState,
|
||||
isNetworkOnline: true,
|
||||
}
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
|
@ -1,39 +1,29 @@
|
|||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import * as webnative from 'webnative';
|
||||
import type FileSystem from 'webnative/fs/index';
|
||||
|
||||
/**
|
||||
* FileSystemContext - PLACEHOLDER
|
||||
*
|
||||
* Previously used webnative for Fission WNFS integration.
|
||||
* Now a stub - file system functionality is handled via local storage
|
||||
* or server-side APIs when needed.
|
||||
* File system context interface
|
||||
*/
|
||||
|
||||
// Placeholder FileSystem interface matching previous API
|
||||
interface FileSystem {
|
||||
exists: (path: any) => Promise<boolean>;
|
||||
mkdir: (path: any) => Promise<void>;
|
||||
write: (path: any, content: any) => Promise<void>;
|
||||
read: (path: any) => Promise<any>;
|
||||
ls: (path: any) => Promise<Record<string, any>>;
|
||||
publish: () => Promise<void>;
|
||||
}
|
||||
|
||||
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 - Stub implementation
|
||||
* 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 never ready in stub mode
|
||||
const isReady = false;
|
||||
// File system is ready when it's not null
|
||||
const isReady = fs !== null;
|
||||
|
||||
return (
|
||||
<FileSystemContext.Provider value={{ fs, setFs, isReady }}>
|
||||
|
|
@ -44,6 +34,9 @@ export const FileSystemProvider: React.FC<{ children: ReactNode }> = ({ children
|
|||
|
||||
/**
|
||||
* 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);
|
||||
|
|
@ -71,30 +64,120 @@ export const DIRECTORIES = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Stub filesystem utilities - returns no-op functions
|
||||
* Common filesystem operations
|
||||
*
|
||||
* @param fs The filesystem instance
|
||||
* @returns An object with filesystem utility functions
|
||||
*/
|
||||
export const createFileSystemUtils = (_fs: FileSystem) => {
|
||||
console.warn('⚠️ FileSystemUtils is a stub - webnative has been removed');
|
||||
export const createFileSystemUtils = (fs: FileSystem) => {
|
||||
return {
|
||||
ensureDirectory: async (_path: string[]): Promise<void> => {},
|
||||
writeFile: async (_path: string[], _fileName: string, _content: Blob | string): Promise<void> => {},
|
||||
readFile: async (_path: string[], _fileName: string): Promise<any> => {
|
||||
throw new Error('FileSystem not available');
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
},
|
||||
fileExists: async (_path: string[], _fileName: string): Promise<boolean> => false,
|
||||
listDirectory: async (_path: string[]): Promise<Record<string, any>> => ({})
|
||||
|
||||
/**
|
||||
* 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 - always returns null in stub mode
|
||||
* 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);
|
||||
};
|
||||
};
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
/* Activity Panel Styles */
|
||||
|
||||
.activity-panel {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 12px;
|
||||
width: 280px;
|
||||
max-height: calc(100vh - 80px);
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.activity-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.activity-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.activity-panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
color: #6c757d;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.activity-panel-close:hover {
|
||||
background: #e9ecef;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.activity-panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.activity-loading {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.activity-empty {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.activity-empty-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.activity-empty p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.activity-empty-hint {
|
||||
margin-top: 4px !important;
|
||||
font-size: 0.75rem !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.activity-group {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.activity-group-header {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 8px 16px 4px;
|
||||
background: #f8f9fa;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 8px 16px;
|
||||
gap: 10px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.activity-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-action-created {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.activity-action-deleted {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.activity-action-updated {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.activity-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.activity-text {
|
||||
font-size: 0.8rem;
|
||||
color: #212529;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.activity-user {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.activity-shape {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 0.7rem;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
/* Toggle Button */
|
||||
.activity-toggle-btn {
|
||||
background: var(--tool-bg, #f8f9fa);
|
||||
border: 1px solid var(--tool-border, #dee2e6);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.activity-toggle-btn:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.activity-toggle-btn.active {
|
||||
background: #007bff;
|
||||
border-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.activity-toggle-icon {
|
||||
font-family: monospace;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Dark Mode */
|
||||
html.dark .activity-panel {
|
||||
background: #2d2d2d;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
html.dark .activity-panel-header {
|
||||
background: #3a3a3a;
|
||||
border-bottom-color: #495057;
|
||||
}
|
||||
|
||||
html.dark .activity-panel-header h3 {
|
||||
color: #e9ecef;
|
||||
}
|
||||
|
||||
html.dark .activity-panel-close {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
html.dark .activity-panel-close:hover {
|
||||
background: #495057;
|
||||
color: #e9ecef;
|
||||
}
|
||||
|
||||
html.dark .activity-group-header {
|
||||
background: #3a3a3a;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
html.dark .activity-item:hover {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
html.dark .activity-text {
|
||||
color: #e9ecef;
|
||||
}
|
||||
|
||||
html.dark .activity-shape {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
html.dark .activity-time {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
html.dark .activity-empty {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
html.dark .activity-action-created {
|
||||
background: #1e4d2b;
|
||||
color: #d4edda;
|
||||
}
|
||||
|
||||
html.dark .activity-action-deleted {
|
||||
background: #4a1e1e;
|
||||
color: #f8d7da;
|
||||
}
|
||||
|
||||
html.dark .activity-action-updated {
|
||||
background: #1e4a4a;
|
||||
color: #d1ecf1;
|
||||
}
|
||||
|
||||
html.dark .activity-toggle-btn {
|
||||
background: #3a3a3a;
|
||||
border-color: #495057;
|
||||
color: #e9ecef;
|
||||
}
|
||||
|
||||
html.dark .activity-toggle-btn:hover {
|
||||
background: #495057;
|
||||
}
|
||||
|
||||
html.dark .activity-toggle-btn.active {
|
||||
background: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.activity-panel {
|
||||
width: calc(100vw - 24px);
|
||||
right: 12px;
|
||||
left: 12px;
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,33 @@
|
|||
/* Anonymous Viewer Banner Styles - Compact unified sign-in box (~33% smaller) */
|
||||
/* Anonymous Viewer Banner Styles */
|
||||
|
||||
.anonymous-viewer-banner {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
right: 10px;
|
||||
z-index: 100000;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10000;
|
||||
|
||||
max-width: 200px;
|
||||
width: auto;
|
||||
max-width: 600px;
|
||||
width: calc(100% - 40px);
|
||||
|
||||
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: 8px;
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.2),
|
||||
0 0 10px rgba(139, 92, 246, 0.08);
|
||||
0 20px 60px rgba(0, 0, 0, 0.3),
|
||||
0 0 40px rgba(139, 92, 246, 0.15);
|
||||
|
||||
animation: slideDown 0.25s ease-out;
|
||||
animation: slideUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -34,29 +35,22 @@
|
|||
background: linear-gradient(135deg, #2d1f3d 0%, #3d2d54 100%);
|
||||
border-color: rgba(236, 72, 153, 0.4);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.25),
|
||||
0 0 20px rgba(236, 72, 153, 0.15);
|
||||
0 20px 60px rgba(0, 0, 0, 0.3),
|
||||
0 0 40px rgba(236, 72, 153, 0.2);
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.banner-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -64,11 +58,6 @@
|
|||
color: white;
|
||||
}
|
||||
|
||||
.banner-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.edit-triggered .banner-icon {
|
||||
background: linear-gradient(135deg, #ec4899 0%, #a855f7 100%);
|
||||
}
|
||||
|
|
@ -79,10 +68,10 @@
|
|||
}
|
||||
|
||||
.banner-headline {
|
||||
margin: 0 0 2px 0;
|
||||
font-size: 11px;
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
color: #f0f0f0;
|
||||
line-height: 1.3;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.banner-headline strong {
|
||||
|
|
@ -91,27 +80,75 @@
|
|||
|
||||
.banner-summary {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
font-size: 14px;
|
||||
color: #a0a0b0;
|
||||
line-height: 1.3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.banner-details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.banner-details p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: #c0c0d0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.banner-details strong {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.cryptid-benefits {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.cryptid-benefits li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #a0a0b0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cryptid-benefits li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.benefit-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cryptid-benefits a {
|
||||
color: #8b5cf6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cryptid-benefits a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.banner-signup-btn {
|
||||
flex: 1;
|
||||
padding: 5px 10px;
|
||||
font-size: 10px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
|
@ -119,7 +156,7 @@
|
|||
|
||||
.banner-signup-btn:hover {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%);
|
||||
box-shadow: 0 2px 12px rgba(139, 92, 246, 0.35);
|
||||
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
|
@ -129,47 +166,55 @@
|
|||
|
||||
.edit-triggered .banner-signup-btn:hover {
|
||||
background: linear-gradient(135deg, #db2777 0%, #9333ea 100%);
|
||||
box-shadow: 0 2px 12px rgba(236, 72, 153, 0.35);
|
||||
box-shadow: 0 4px 20px rgba(236, 72, 153, 0.4);
|
||||
}
|
||||
|
||||
.banner-dismiss-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: #808090;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.banner-dismiss-btn svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.banner-dismiss-btn:hover {
|
||||
color: #f0f0f0;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.banner-expand-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: #8b5cf6;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-expand-btn:hover {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
}
|
||||
|
||||
.banner-edit-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: rgba(236, 72, 153, 0.1);
|
||||
border-top: 1px solid rgba(236, 72, 153, 0.2);
|
||||
border-radius: 0 0 8px 8px;
|
||||
font-size: 9px;
|
||||
border-radius: 0 0 16px 16px;
|
||||
font-size: 13px;
|
||||
color: #f472b6;
|
||||
}
|
||||
|
||||
|
|
@ -219,8 +264,8 @@
|
|||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-color: rgba(139, 92, 246, 0.2);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.08),
|
||||
0 0 16px rgba(139, 92, 246, 0.08);
|
||||
0 20px 60px rgba(0, 0, 0, 0.1),
|
||||
0 0 40px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.banner-headline {
|
||||
|
|
@ -231,48 +276,48 @@
|
|||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
.banner-summary {
|
||||
.banner-summary,
|
||||
.banner-details p,
|
||||
.cryptid-benefits li {
|
||||
color: #606080;
|
||||
}
|
||||
|
||||
.banner-dismiss-btn {
|
||||
color: #606080;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.banner-dismiss-btn:hover {
|
||||
color: #2d2d44;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.anonymous-viewer-banner {
|
||||
top: 56px;
|
||||
right: 8px;
|
||||
max-width: 180px;
|
||||
bottom: 10px;
|
||||
max-width: none;
|
||||
width: calc(100% - 20px);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
padding: 8px;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.banner-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
.banner-actions {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.banner-headline {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.banner-summary {
|
||||
font-size: 9px;
|
||||
.banner-signup-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,122 +136,6 @@
|
|||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Recent Boards Section - Horizontal Scroll */
|
||||
.recent-boards-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.recent-boards-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 12px;
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for recent boards */
|
||||
.recent-boards-row::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.recent-boards-row::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.recent-boards-row::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.recent-boards-row::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
.recent-board-card {
|
||||
flex: 0 0 200px;
|
||||
scroll-snap-align: start;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.recent-board-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
border-color: #dee2e6;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.recent-board-screenshot {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
background: #e9ecef;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.recent-board-screenshot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.recent-board-screenshot .placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 2rem;
|
||||
color: #adb5bd;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
}
|
||||
|
||||
.recent-board-info {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.recent-board-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
margin: 0 0 4px 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recent-board-time {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recent-boards-empty {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.recent-boards-empty-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -554,7 +438,6 @@ html.dark .dashboard-container {
|
|||
|
||||
.dashboard-header,
|
||||
.starred-boards-section,
|
||||
.recent-boards-section,
|
||||
.quick-actions-section,
|
||||
html.dark .auth-required {
|
||||
background: #2d2d2d;
|
||||
|
|
@ -577,46 +460,15 @@ html.dark .action-card p {
|
|||
}
|
||||
|
||||
.board-card,
|
||||
.recent-board-card,
|
||||
html.dark .action-card {
|
||||
background: #3a3a3a;
|
||||
border-color: #495057;
|
||||
}
|
||||
|
||||
|
||||
.board-card:hover,
|
||||
.recent-board-card:hover,
|
||||
html.dark .action-card:hover {
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
html.dark .recent-board-screenshot {
|
||||
background: #495057;
|
||||
}
|
||||
|
||||
html.dark .recent-board-screenshot .placeholder {
|
||||
background: linear-gradient(135deg, #3a3a3a 0%, #495057 100%);
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
html.dark .recent-board-title {
|
||||
color: #e9ecef;
|
||||
}
|
||||
|
||||
html.dark .recent-board-time {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
html.dark .recent-boards-row::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
html.dark .recent-boards-row::-webkit-scrollbar-thumb {
|
||||
background: #495057;
|
||||
}
|
||||
|
||||
html.dark .recent-boards-row::-webkit-scrollbar-thumb:hover {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
html.dark .board-slug {
|
||||
background: #495057;
|
||||
|
|
|
|||
|
|
@ -1950,5 +1950,4 @@ html.dark button:not([class*="primary"]):not([style*="background"]) {
|
|||
|
||||
html.dark button:not([class*="primary"]):not([style*="background"]):hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,357 +0,0 @@
|
|||
/**
|
||||
* Workflow Palette Styles
|
||||
*
|
||||
* Styles for the workflow block palette sidebar component.
|
||||
*/
|
||||
|
||||
/* =============================================================================
|
||||
Palette Container
|
||||
============================================================================= */
|
||||
|
||||
.workflow-palette {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: white;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
box-shadow: 4px 0 16px rgba(0, 0, 0, 0.08);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Header
|
||||
============================================================================= */
|
||||
|
||||
.workflow-palette-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.workflow-palette-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.workflow-palette-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workflow-palette-close:hover {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Search Bar
|
||||
============================================================================= */
|
||||
|
||||
.workflow-palette-search {
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.workflow-palette-search-input {
|
||||
width: 100%;
|
||||
padding: 8px 32px 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.workflow-palette-search-input:focus {
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.workflow-palette-search-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.workflow-palette-search-clear {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workflow-palette-search-clear:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Content Area
|
||||
============================================================================= */
|
||||
|
||||
.workflow-palette-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Category Section
|
||||
============================================================================= */
|
||||
|
||||
.workflow-palette-category {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workflow-palette-category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.workflow-palette-category-header:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.workflow-palette-category-icon {
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.workflow-palette-category-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workflow-palette-category-count {
|
||||
font-weight: 400;
|
||||
color: #9ca3af;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.workflow-palette-chevron {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.workflow-palette-chevron.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Block Cards
|
||||
============================================================================= */
|
||||
|
||||
.workflow-palette-blocks {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.workflow-palette-block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.workflow-palette-block:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.workflow-palette-block-icon {
|
||||
font-size: 18px;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.workflow-palette-block-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workflow-palette-block-name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.workflow-palette-block-description {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-palette-block-ports {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-palette-port-count {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Footer
|
||||
============================================================================= */
|
||||
|
||||
.workflow-palette-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.workflow-palette-hint {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Scrollbar Styling
|
||||
============================================================================= */
|
||||
|
||||
.workflow-palette-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.workflow-palette-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workflow-palette-content::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.workflow-palette-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Dark Mode Support (optional)
|
||||
============================================================================= */
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.workflow-palette {
|
||||
background: #1f2937;
|
||||
border-right-color: #374151;
|
||||
}
|
||||
|
||||
.workflow-palette-header {
|
||||
background: #111827;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.workflow-palette-title {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.workflow-palette-close {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.workflow-palette-close:hover {
|
||||
background: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.workflow-palette-search {
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.workflow-palette-search-input {
|
||||
background: #111827;
|
||||
border-color: #4b5563;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.workflow-palette-search-input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.workflow-palette-category-header {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.workflow-palette-category-header:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.workflow-palette-block:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.workflow-palette-block-name {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.workflow-palette-block-description {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.workflow-palette-footer {
|
||||
background: #111827;
|
||||
border-top-color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Responsive Adjustments
|
||||
============================================================================= */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.workflow-palette {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
|
|
@ -145,6 +145,7 @@ export const useAdvancedSpeakerDiarization = ({
|
|||
source.connect(processor)
|
||||
processor.connect(audioContext.destination)
|
||||
|
||||
console.log('🎤 Advanced speaker diarization started')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error starting speaker diarization:', error)
|
||||
|
|
@ -171,6 +172,7 @@ export const useAdvancedSpeakerDiarization = ({
|
|||
}
|
||||
|
||||
setIsProcessing(false)
|
||||
console.log('🛑 Advanced speaker diarization stopped')
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
|
|
|
|||
|
|
@ -1,341 +0,0 @@
|
|||
// Hook for accessing decrypted calendar events from local encrypted storage
|
||||
// Uses the existing Google Data Sovereignty infrastructure
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { calendarStore } from '@/lib/google/database'
|
||||
import { deriveServiceKey, decryptDataToString, importMasterKey } from '@/lib/google/encryption'
|
||||
import { getGoogleDataService } from '@/lib/google'
|
||||
import type { EncryptedCalendarEvent } from '@/lib/google/types'
|
||||
|
||||
// Decrypted event type for display
|
||||
export interface DecryptedCalendarEvent {
|
||||
id: string
|
||||
calendarId: string
|
||||
summary: string
|
||||
description: string | null
|
||||
location: string | null
|
||||
startTime: Date
|
||||
endTime: Date
|
||||
isAllDay: boolean
|
||||
timezone: string
|
||||
isRecurring: boolean
|
||||
meetingLink: string | null
|
||||
reminders: { method: string; minutes: number }[]
|
||||
syncedAt: number
|
||||
}
|
||||
|
||||
// Hook options
|
||||
export interface UseCalendarEventsOptions {
|
||||
startDate?: Date
|
||||
endDate?: Date
|
||||
limit?: number
|
||||
calendarId?: string
|
||||
autoRefresh?: boolean
|
||||
refreshInterval?: number // in milliseconds
|
||||
}
|
||||
|
||||
// Hook return type
|
||||
export interface UseCalendarEventsResult {
|
||||
events: DecryptedCalendarEvent[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
initialized: boolean
|
||||
refresh: () => Promise<void>
|
||||
getEventsForDate: (date: Date) => DecryptedCalendarEvent[]
|
||||
getEventsForMonth: (year: number, month: number) => DecryptedCalendarEvent[]
|
||||
getEventsForWeek: (date: Date) => DecryptedCalendarEvent[]
|
||||
getUpcoming: (limit?: number) => DecryptedCalendarEvent[]
|
||||
eventCount: number
|
||||
}
|
||||
|
||||
// Helper to get start of day
|
||||
function startOfDay(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
// Helper to get end of day
|
||||
function endOfDay(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
d.setHours(23, 59, 59, 999)
|
||||
return d
|
||||
}
|
||||
|
||||
// Helper to get start of week (Monday)
|
||||
function startOfWeek(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
const day = d.getDay()
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1) // Monday as first day
|
||||
d.setDate(diff)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
// Helper to get end of week (Sunday)
|
||||
function endOfWeek(date: Date): Date {
|
||||
const start = startOfWeek(date)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + 6)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
return end
|
||||
}
|
||||
|
||||
// Helper to get start of month
|
||||
function startOfMonth(year: number, month: number): Date {
|
||||
return new Date(year, month, 1, 0, 0, 0, 0)
|
||||
}
|
||||
|
||||
// Helper to get end of month
|
||||
function endOfMonth(year: number, month: number): Date {
|
||||
return new Date(year, month + 1, 0, 23, 59, 59, 999)
|
||||
}
|
||||
|
||||
// Decrypt a single event
|
||||
async function decryptEvent(
|
||||
event: EncryptedCalendarEvent,
|
||||
calendarKey: CryptoKey
|
||||
): Promise<DecryptedCalendarEvent> {
|
||||
const [summary, description, location, meetingLink] = await Promise.all([
|
||||
decryptDataToString(event.encryptedSummary, calendarKey),
|
||||
event.encryptedDescription
|
||||
? decryptDataToString(event.encryptedDescription, calendarKey)
|
||||
: Promise.resolve(null),
|
||||
event.encryptedLocation
|
||||
? decryptDataToString(event.encryptedLocation, calendarKey)
|
||||
: Promise.resolve(null),
|
||||
event.encryptedMeetingLink
|
||||
? decryptDataToString(event.encryptedMeetingLink, calendarKey)
|
||||
: Promise.resolve(null),
|
||||
])
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
calendarId: event.calendarId,
|
||||
summary,
|
||||
description,
|
||||
location,
|
||||
startTime: new Date(event.startTime),
|
||||
endTime: new Date(event.endTime),
|
||||
isAllDay: event.isAllDay,
|
||||
timezone: event.timezone,
|
||||
isRecurring: event.isRecurring,
|
||||
meetingLink,
|
||||
reminders: event.reminders || [],
|
||||
syncedAt: event.syncedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function useCalendarEvents(
|
||||
options: UseCalendarEventsOptions = {}
|
||||
): UseCalendarEventsResult {
|
||||
const {
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
calendarId,
|
||||
autoRefresh = false,
|
||||
refreshInterval = 60000, // 1 minute default
|
||||
} = options
|
||||
|
||||
const [events, setEvents] = useState<DecryptedCalendarEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
// Fetch and decrypt events
|
||||
const fetchEvents = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const service = getGoogleDataService()
|
||||
|
||||
// Check if service is initialized
|
||||
if (!service.isInitialized()) {
|
||||
// Try to initialize
|
||||
const success = await service.initialize()
|
||||
if (!success) {
|
||||
setEvents([])
|
||||
setInitialized(false)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get the master key
|
||||
const masterKeyData = await service.exportKey()
|
||||
if (!masterKeyData) {
|
||||
setError('No encryption key available')
|
||||
setEvents([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Derive calendar-specific key
|
||||
const masterKey = await importMasterKey(masterKeyData)
|
||||
const calendarKey = await deriveServiceKey(masterKey, 'calendar')
|
||||
|
||||
// Determine query range
|
||||
let encryptedEvents: EncryptedCalendarEvent[]
|
||||
|
||||
if (startDate && endDate) {
|
||||
// Query by date range
|
||||
encryptedEvents = await calendarStore.getByDateRange(
|
||||
startDate.getTime(),
|
||||
endDate.getTime()
|
||||
)
|
||||
} else if (calendarId) {
|
||||
// Query by calendar ID
|
||||
encryptedEvents = await calendarStore.getByCalendar(calendarId)
|
||||
} else {
|
||||
// Get upcoming events (default: next 90 days)
|
||||
const now = Date.now()
|
||||
const ninetyDaysLater = now + 90 * 24 * 60 * 60 * 1000
|
||||
encryptedEvents = await calendarStore.getByDateRange(now, ninetyDaysLater)
|
||||
}
|
||||
|
||||
// Apply limit if specified
|
||||
if (limit && encryptedEvents.length > limit) {
|
||||
encryptedEvents = encryptedEvents.slice(0, limit)
|
||||
}
|
||||
|
||||
// Decrypt all events in parallel
|
||||
const decryptedEvents = await Promise.all(
|
||||
encryptedEvents.map(event => decryptEvent(event, calendarKey))
|
||||
)
|
||||
|
||||
// Sort by start time
|
||||
decryptedEvents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
|
||||
|
||||
setEvents(decryptedEvents)
|
||||
setInitialized(true)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch calendar events:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to load calendar events')
|
||||
setEvents([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [startDate, endDate, limit, calendarId])
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchEvents()
|
||||
}, [fetchEvents])
|
||||
|
||||
// Auto-refresh if enabled
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || !initialized) return
|
||||
|
||||
const intervalId = setInterval(fetchEvents, refreshInterval)
|
||||
return () => clearInterval(intervalId)
|
||||
}, [autoRefresh, refreshInterval, fetchEvents, initialized])
|
||||
|
||||
// Get events for a specific date
|
||||
const getEventsForDate = useCallback(
|
||||
(date: Date): DecryptedCalendarEvent[] => {
|
||||
const dayStart = startOfDay(date).getTime()
|
||||
const dayEnd = endOfDay(date).getTime()
|
||||
|
||||
return events.filter(event => {
|
||||
const eventStart = event.startTime.getTime()
|
||||
const eventEnd = event.endTime.getTime()
|
||||
|
||||
// Event overlaps with this day
|
||||
return eventStart <= dayEnd && eventEnd >= dayStart
|
||||
})
|
||||
},
|
||||
[events]
|
||||
)
|
||||
|
||||
// Get events for a specific month
|
||||
const getEventsForMonth = useCallback(
|
||||
(year: number, month: number): DecryptedCalendarEvent[] => {
|
||||
const monthStart = startOfMonth(year, month).getTime()
|
||||
const monthEnd = endOfMonth(year, month).getTime()
|
||||
|
||||
return events.filter(event => {
|
||||
const eventStart = event.startTime.getTime()
|
||||
const eventEnd = event.endTime.getTime()
|
||||
|
||||
// Event overlaps with this month
|
||||
return eventStart <= monthEnd && eventEnd >= monthStart
|
||||
})
|
||||
},
|
||||
[events]
|
||||
)
|
||||
|
||||
// Get events for a specific week
|
||||
const getEventsForWeek = useCallback(
|
||||
(date: Date): DecryptedCalendarEvent[] => {
|
||||
const weekStart = startOfWeek(date).getTime()
|
||||
const weekEnd = endOfWeek(date).getTime()
|
||||
|
||||
return events.filter(event => {
|
||||
const eventStart = event.startTime.getTime()
|
||||
const eventEnd = event.endTime.getTime()
|
||||
|
||||
// Event overlaps with this week
|
||||
return eventStart <= weekEnd && eventEnd >= weekStart
|
||||
})
|
||||
},
|
||||
[events]
|
||||
)
|
||||
|
||||
// Get upcoming events from now
|
||||
const getUpcoming = useCallback(
|
||||
(upcomingLimit: number = 10): DecryptedCalendarEvent[] => {
|
||||
const now = Date.now()
|
||||
return events
|
||||
.filter(event => event.startTime.getTime() >= now)
|
||||
.slice(0, upcomingLimit)
|
||||
},
|
||||
[events]
|
||||
)
|
||||
|
||||
// Memoized event count
|
||||
const eventCount = useMemo(() => events.length, [events])
|
||||
|
||||
return {
|
||||
events,
|
||||
loading,
|
||||
error,
|
||||
initialized,
|
||||
refresh: fetchEvents,
|
||||
getEventsForDate,
|
||||
getEventsForMonth,
|
||||
getEventsForWeek,
|
||||
getUpcoming,
|
||||
eventCount,
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for getting events for a specific year (useful for YearView)
|
||||
export function useCalendarEventsForYear(year: number) {
|
||||
const startDate = useMemo(() => new Date(year, 0, 1), [year])
|
||||
const endDate = useMemo(() => new Date(year, 11, 31, 23, 59, 59, 999), [year])
|
||||
|
||||
return useCalendarEvents({ startDate, endDate })
|
||||
}
|
||||
|
||||
// Hook for getting events for current month
|
||||
export function useCurrentMonthEvents() {
|
||||
const now = new Date()
|
||||
const startDate = useMemo(() => startOfMonth(now.getFullYear(), now.getMonth()), [])
|
||||
const endDate = useMemo(() => endOfMonth(now.getFullYear(), now.getMonth()), [])
|
||||
|
||||
return useCalendarEvents({ startDate, endDate })
|
||||
}
|
||||
|
||||
// Hook for getting upcoming events only
|
||||
export function useUpcomingEvents(limit: number = 10) {
|
||||
const startDate = useMemo(() => new Date(), [])
|
||||
const endDate = useMemo(() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 90) // Next 90 days
|
||||
return d
|
||||
}, [])
|
||||
|
||||
return useCalendarEvents({ startDate, endDate, limit })
|
||||
}
|
||||
|
|
@ -1,357 +0,0 @@
|
|||
/**
|
||||
* useLiveImage Hook
|
||||
* Captures drawings within a frame shape and sends them to Fal.ai for AI enhancement
|
||||
* Based on draw-fast implementation, adapted for canvas-website with Automerge sync
|
||||
*
|
||||
* SECURITY: All fal.ai API calls go through the Cloudflare Worker proxy
|
||||
* API keys are stored server-side, never exposed to the browser
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { Editor, TLShapeId, Box, exportToBlob } from 'tldraw'
|
||||
import { getFalProxyConfig } from '@/lib/clientConfig'
|
||||
|
||||
// Fal.ai model endpoints
|
||||
const FAL_MODEL_LCM = 'fal-ai/lcm-sd15-i2i' // Fast, real-time (~150ms)
|
||||
const FAL_MODEL_FLUX_CANNY = 'fal-ai/flux-control-lora-canny/image-to-image' // Higher quality
|
||||
|
||||
interface LiveImageContextValue {
|
||||
isConnected: boolean
|
||||
// Note: apiKey is no longer exposed to the browser
|
||||
setApiKey: (key: string) => void
|
||||
}
|
||||
|
||||
const LiveImageContext = createContext<LiveImageContextValue | null>(null)
|
||||
|
||||
interface LiveImageProviderProps {
|
||||
children: React.ReactNode
|
||||
apiKey?: string // Deprecated - API keys are now server-side
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component that manages Fal.ai connection
|
||||
* API keys are now stored server-side and proxied through Cloudflare Worker
|
||||
*/
|
||||
export function LiveImageProvider({ children }: LiveImageProviderProps) {
|
||||
// Fal.ai is always "connected" via the proxy - actual auth happens server-side
|
||||
const [isConnected, setIsConnected] = useState(true)
|
||||
|
||||
// Log that we're using the proxy
|
||||
useEffect(() => {
|
||||
const { proxyUrl } = getFalProxyConfig()
|
||||
console.log('LiveImage: Using fal.ai proxy at', proxyUrl || '(same origin)')
|
||||
}, [])
|
||||
|
||||
// setApiKey is now a no-op since keys are server-side
|
||||
// Kept for backward compatibility with any code that tries to set a key
|
||||
const setApiKey = useCallback((_key: string) => {
|
||||
console.warn('LiveImage: setApiKey is deprecated. API keys are now stored server-side.')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<LiveImageContext.Provider value={{ isConnected, setApiKey }}>
|
||||
{children}
|
||||
</LiveImageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLiveImageContext() {
|
||||
const context = useContext(LiveImageContext)
|
||||
if (!context) {
|
||||
throw new Error('useLiveImageContext must be used within a LiveImageProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface UseLiveImageOptions {
|
||||
editor: Editor
|
||||
shapeId: TLShapeId
|
||||
prompt: string
|
||||
enabled?: boolean
|
||||
throttleMs?: number
|
||||
model?: 'lcm' | 'flux-canny'
|
||||
strength?: number
|
||||
onResult?: (imageUrl: string) => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
interface LiveImageState {
|
||||
isGenerating: boolean
|
||||
lastGeneratedUrl: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that watches for drawing changes within a frame and generates AI images
|
||||
*/
|
||||
export function useLiveImage({
|
||||
editor,
|
||||
shapeId,
|
||||
prompt,
|
||||
enabled = true,
|
||||
throttleMs = 500,
|
||||
model = 'lcm',
|
||||
strength = 0.65,
|
||||
onResult,
|
||||
onError,
|
||||
}: UseLiveImageOptions): LiveImageState {
|
||||
const [state, setState] = useState<LiveImageState>({
|
||||
isGenerating: false,
|
||||
lastGeneratedUrl: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const requestVersionRef = useRef(0)
|
||||
const lastRequestTimeRef = useRef(0)
|
||||
const pendingRequestRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const context = useContext(LiveImageContext)
|
||||
|
||||
// Get shapes that intersect with this frame
|
||||
const getChildShapes = useCallback(() => {
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) return []
|
||||
|
||||
const bounds = editor.getShapePageBounds(shapeId)
|
||||
if (!bounds) return []
|
||||
|
||||
// Find all shapes that intersect with this frame
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
return allShapes.filter(s => {
|
||||
if (s.id === shapeId) return false // Exclude the frame itself
|
||||
const shapeBounds = editor.getShapePageBounds(s.id)
|
||||
if (!shapeBounds) return false
|
||||
return bounds.contains(shapeBounds) || bounds.collides(shapeBounds)
|
||||
})
|
||||
}, [editor, shapeId])
|
||||
|
||||
// Capture the drawing as a base64 image
|
||||
const captureDrawing = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
const childShapes = getChildShapes()
|
||||
if (childShapes.length === 0) return null
|
||||
|
||||
const shapeIds = childShapes.map(s => s.id)
|
||||
|
||||
// Export shapes to blob
|
||||
const blob = await exportToBlob({
|
||||
editor,
|
||||
ids: shapeIds,
|
||||
format: 'jpeg',
|
||||
opts: {
|
||||
background: true,
|
||||
padding: 0,
|
||||
scale: 1,
|
||||
},
|
||||
})
|
||||
|
||||
// Convert blob to data URL
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('LiveImage: Failed to capture drawing:', error)
|
||||
return null
|
||||
}
|
||||
}, [editor, getChildShapes])
|
||||
|
||||
// Generate AI image from the sketch via proxy
|
||||
const generateImage = useCallback(async () => {
|
||||
if (!context?.isConnected || !enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentVersion = ++requestVersionRef.current
|
||||
|
||||
setState(prev => ({ ...prev, isGenerating: true, error: null }))
|
||||
|
||||
try {
|
||||
const imageDataUrl = await captureDrawing()
|
||||
if (!imageDataUrl) {
|
||||
setState(prev => ({ ...prev, isGenerating: false }))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this request is still valid (not superseded by newer request)
|
||||
if (currentVersion !== requestVersionRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const modelEndpoint = model === 'flux-canny' ? FAL_MODEL_FLUX_CANNY : FAL_MODEL_LCM
|
||||
|
||||
// Build the full prompt
|
||||
const fullPrompt = prompt
|
||||
? `${prompt}, hd, award-winning, impressive, detailed`
|
||||
: 'hd, award-winning, impressive, detailed illustration'
|
||||
|
||||
// Use the proxy endpoint instead of calling fal.ai directly
|
||||
const { proxyUrl } = getFalProxyConfig()
|
||||
|
||||
const response = await fetch(`${proxyUrl}/subscribe/${modelEndpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: fullPrompt,
|
||||
image_url: imageDataUrl,
|
||||
strength: strength,
|
||||
sync_mode: true,
|
||||
seed: 42,
|
||||
num_inference_steps: model === 'lcm' ? 4 : 20,
|
||||
guidance_scale: model === 'lcm' ? 1 : 7.5,
|
||||
enable_safety_checks: false,
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: response.statusText })) as { error?: string }
|
||||
throw new Error(errorData.error || `Proxy error: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
images?: Array<{ url?: string } | string>
|
||||
image?: { url?: string } | string
|
||||
output?: { url?: string } | string
|
||||
}
|
||||
|
||||
// Check if this result is still relevant
|
||||
if (currentVersion !== requestVersionRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract image URL from result
|
||||
let imageUrl: string | null = null
|
||||
|
||||
if (data.images && Array.isArray(data.images) && data.images.length > 0) {
|
||||
const firstImage = data.images[0]
|
||||
imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null
|
||||
} else if (data.image) {
|
||||
imageUrl = typeof data.image === 'string' ? data.image : data.image?.url || null
|
||||
} else if (data.output) {
|
||||
imageUrl = typeof data.output === 'string' ? data.output : data.output?.url || null
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isGenerating: false,
|
||||
lastGeneratedUrl: imageUrl,
|
||||
error: null,
|
||||
}))
|
||||
onResult?.(imageUrl)
|
||||
} else {
|
||||
throw new Error('No image URL in response')
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error('LiveImage: Generation failed:', errorMessage)
|
||||
|
||||
if (currentVersion === requestVersionRef.current) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isGenerating: false,
|
||||
error: errorMessage,
|
||||
}))
|
||||
onError?.(error instanceof Error ? error : new Error(errorMessage))
|
||||
}
|
||||
}
|
||||
}, [context?.isConnected, enabled, captureDrawing, model, prompt, strength, onResult, onError])
|
||||
|
||||
// Throttled generation trigger
|
||||
const triggerGeneration = useCallback(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const now = Date.now()
|
||||
const timeSinceLastRequest = now - lastRequestTimeRef.current
|
||||
|
||||
// Clear any pending request
|
||||
if (pendingRequestRef.current) {
|
||||
clearTimeout(pendingRequestRef.current)
|
||||
}
|
||||
|
||||
if (timeSinceLastRequest >= throttleMs) {
|
||||
// Enough time has passed, generate immediately
|
||||
lastRequestTimeRef.current = now
|
||||
generateImage()
|
||||
} else {
|
||||
// Schedule generation after throttle period
|
||||
const delay = throttleMs - timeSinceLastRequest
|
||||
pendingRequestRef.current = setTimeout(() => {
|
||||
lastRequestTimeRef.current = Date.now()
|
||||
generateImage()
|
||||
}, delay)
|
||||
}
|
||||
}, [enabled, throttleMs, generateImage])
|
||||
|
||||
// Watch for changes to shapes within the frame
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handleChange = () => {
|
||||
triggerGeneration()
|
||||
}
|
||||
|
||||
// Subscribe to store changes
|
||||
const unsubscribe = editor.store.listen(handleChange, {
|
||||
source: 'user',
|
||||
scope: 'document',
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
if (pendingRequestRef.current) {
|
||||
clearTimeout(pendingRequestRef.current)
|
||||
}
|
||||
}
|
||||
}, [editor, enabled, triggerGeneration])
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SVG string to JPEG data URL (fast method)
|
||||
*/
|
||||
async function svgToJpegDataUrl(
|
||||
svgString: string,
|
||||
width: number,
|
||||
height: number,
|
||||
quality: number = 0.3
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
const svgBlob = new Blob([svgString], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(svgBlob)
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get canvas context'))
|
||||
return
|
||||
}
|
||||
|
||||
// Fill with white background
|
||||
ctx.fillStyle = 'white'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
|
||||
// Draw the SVG
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Convert to JPEG
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', quality)
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(dataUrl)
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('Failed to load SVG'))
|
||||
}
|
||||
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { Editor, TLShapeId } from 'tldraw'
|
||||
|
||||
interface OriginalDimensions {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
interface UseMaximizeOptions {
|
||||
/** Editor instance */
|
||||
editor: Editor
|
||||
/** Shape ID to maximize */
|
||||
shapeId: TLShapeId
|
||||
/** Current width of the shape */
|
||||
currentW: number
|
||||
/** Current height of the shape */
|
||||
currentH: number
|
||||
/** Shape type for updateShape call */
|
||||
shapeType: string
|
||||
/** Padding from viewport edges in pixels */
|
||||
padding?: number
|
||||
}
|
||||
|
||||
interface UseMaximizeReturn {
|
||||
/** Whether the shape is currently maximized */
|
||||
isMaximized: boolean
|
||||
/** Toggle maximize state */
|
||||
toggleMaximize: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to enable maximize/fullscreen functionality for shapes.
|
||||
* When maximized, the shape fills the viewport.
|
||||
* Press Esc or click maximize again to restore original size.
|
||||
*/
|
||||
export function useMaximize({
|
||||
editor,
|
||||
shapeId,
|
||||
currentW,
|
||||
currentH,
|
||||
shapeType,
|
||||
padding = 40,
|
||||
}: UseMaximizeOptions): UseMaximizeReturn {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
const originalDimensionsRef = useRef<OriginalDimensions | null>(null)
|
||||
|
||||
const toggleMaximize = useCallback(() => {
|
||||
if (!editor || !shapeId) return
|
||||
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) return
|
||||
|
||||
if (isMaximized) {
|
||||
// Restore original dimensions
|
||||
const original = originalDimensionsRef.current
|
||||
if (original) {
|
||||
editor.updateShape({
|
||||
id: shapeId,
|
||||
type: shapeType,
|
||||
x: original.x,
|
||||
y: original.y,
|
||||
props: {
|
||||
w: original.w,
|
||||
h: original.h,
|
||||
},
|
||||
})
|
||||
}
|
||||
originalDimensionsRef.current = null
|
||||
setIsMaximized(false)
|
||||
} else {
|
||||
// Store current dimensions before maximizing
|
||||
originalDimensionsRef.current = {
|
||||
x: shape.x,
|
||||
y: shape.y,
|
||||
w: currentW,
|
||||
h: currentH,
|
||||
}
|
||||
|
||||
// Get viewport bounds in page coordinates
|
||||
const viewportBounds = editor.getViewportPageBounds()
|
||||
|
||||
// Calculate new dimensions to fill viewport with padding
|
||||
const newX = viewportBounds.x + padding
|
||||
const newY = viewportBounds.y + padding
|
||||
const newW = viewportBounds.width - (padding * 2)
|
||||
const newH = viewportBounds.height - (padding * 2)
|
||||
|
||||
editor.updateShape({
|
||||
id: shapeId,
|
||||
type: shapeType,
|
||||
x: newX,
|
||||
y: newY,
|
||||
props: {
|
||||
w: newW,
|
||||
h: newH,
|
||||
},
|
||||
})
|
||||
|
||||
// Center the view on the maximized shape
|
||||
editor.centerOnPoint({ x: newX + newW / 2, y: newY + newH / 2 })
|
||||
|
||||
setIsMaximized(true)
|
||||
}
|
||||
}, [editor, shapeId, shapeType, currentW, currentH, padding, isMaximized])
|
||||
|
||||
// Clean up when shape is deleted or unmounted
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
originalDimensionsRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Reset maximize state if shape dimensions change externally while maximized
|
||||
useEffect(() => {
|
||||
if (isMaximized && originalDimensionsRef.current) {
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) {
|
||||
setIsMaximized(false)
|
||||
originalDimensionsRef.current = null
|
||||
}
|
||||
}
|
||||
}, [editor, shapeId, isMaximized])
|
||||
|
||||
return {
|
||||
isMaximized,
|
||||
toggleMaximize,
|
||||
}
|
||||
}
|
||||
|
|
@ -19,11 +19,7 @@ export interface PinnedViewOptions {
|
|||
|
||||
/**
|
||||
* Hook to manage shapes pinned to the viewport.
|
||||
* When a shape is pinned, it stays in the same screen position as the camera
|
||||
* moves and zooms. The shape scales normally with zoom.
|
||||
*
|
||||
* Uses store.listen for immediate synchronous updates when the camera changes,
|
||||
* ensuring zero visual lag between camera movement and shape repositioning.
|
||||
* When a shape is pinned, it stays in the same screen position as the camera moves.
|
||||
*/
|
||||
export function usePinnedToView(
|
||||
editor: Editor | null,
|
||||
|
|
@ -34,8 +30,14 @@ export function usePinnedToView(
|
|||
const { position = 'current', offsetY = 0, offsetX = 0 } = options
|
||||
const pinnedScreenPositionRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const originalCoordinatesRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const originalSizeRef = useRef<{ w: number; h: number } | null>(null)
|
||||
const originalZoomRef = useRef<number | null>(null)
|
||||
const wasPinnedRef = useRef<boolean>(false)
|
||||
const isUpdatingRef = useRef<boolean>(false)
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
const lastCameraRef = useRef<{ x: number; y: number; z: number } | null>(null)
|
||||
const pendingUpdateRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const lastUpdateTimeRef = useRef<number>(0)
|
||||
const driftAnimationRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -46,96 +48,65 @@ export function usePinnedToView(
|
|||
const shape = editor.getShape(shapeId as TLShapeId)
|
||||
if (!shape) return
|
||||
|
||||
// Helper to clear all pin-related state
|
||||
const clearPinState = () => {
|
||||
pinnedScreenPositionRef.current = null
|
||||
originalCoordinatesRef.current = null
|
||||
isUpdatingRef.current = false
|
||||
if (driftAnimationRef.current) {
|
||||
cancelAnimationFrame(driftAnimationRef.current)
|
||||
driftAnimationRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to clean shape meta of any pin-related properties
|
||||
const cleanShapeMeta = (currentShape: any) => {
|
||||
const meta = currentShape.meta || {}
|
||||
// Remove all pin-related meta properties
|
||||
const { originalX, originalY, pinnedAtZoom, ...cleanMeta } = meta as any
|
||||
// Only update if there were pin properties to remove
|
||||
if ('originalX' in meta || 'originalY' in meta || 'pinnedAtZoom' in meta) {
|
||||
try {
|
||||
editor.updateShape({
|
||||
id: shapeId as TLShapeId,
|
||||
type: currentShape.type,
|
||||
meta: cleanMeta,
|
||||
})
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
return cleanMeta
|
||||
}
|
||||
|
||||
// If just became pinned (transition from false to true)
|
||||
// If just became pinned (transition from false to true), capture the current screen position
|
||||
if (isPinned && !wasPinnedRef.current) {
|
||||
// Clear any leftover state from previous pin sessions
|
||||
clearPinState()
|
||||
|
||||
// Store the original coordinates - these will be restored when unpinned
|
||||
originalCoordinatesRef.current = { x: shape.x, y: shape.y }
|
||||
|
||||
// Store original position in meta for unpinning (clean any old meta first)
|
||||
const cleanMeta = cleanShapeMeta(shape)
|
||||
editor.updateShape({
|
||||
id: shapeId as TLShapeId,
|
||||
type: shape.type,
|
||||
meta: {
|
||||
...cleanMeta,
|
||||
originalX: shape.x,
|
||||
originalY: shape.y,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
// Store the original size and zoom - needed to maintain constant visual size
|
||||
const currentCamera = editor.getCamera()
|
||||
originalSizeRef.current = {
|
||||
w: (shape.props as any).w || 0,
|
||||
h: (shape.props as any).h || 0
|
||||
}
|
||||
originalZoomRef.current = currentCamera.z
|
||||
|
||||
// Calculate screen position based on position option
|
||||
let screenPoint: { x: number; y: number }
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
const currentCamera = editor.getCamera()
|
||||
const shapeWidth = (shape.props as any).w || 0
|
||||
const shapeHeight = (shape.props as any).h || 0
|
||||
|
||||
if (position === 'top-center') {
|
||||
// Center horizontally at the top of the viewport
|
||||
screenPoint = {
|
||||
x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX,
|
||||
y: viewport.y + offsetY,
|
||||
}
|
||||
} else if (position === 'bottom-center') {
|
||||
// Center horizontally at the bottom of the viewport
|
||||
screenPoint = {
|
||||
x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX,
|
||||
y: viewport.y + viewport.h - (shapeHeight * currentCamera.z) - offsetY,
|
||||
}
|
||||
} else if (position === 'center') {
|
||||
// Center in the viewport
|
||||
screenPoint = {
|
||||
x: viewport.x + (viewport.w / 2) - (shapeWidth * currentCamera.z / 2) + offsetX,
|
||||
y: viewport.y + (viewport.h / 2) - (shapeHeight * currentCamera.z / 2) + offsetY,
|
||||
}
|
||||
} else {
|
||||
// Default: use current position - shape stays exactly where it is
|
||||
// Default: use current position
|
||||
const pagePoint = { x: shape.x, y: shape.y }
|
||||
screenPoint = editor.pageToScreen(pagePoint)
|
||||
}
|
||||
|
||||
pinnedScreenPositionRef.current = { x: screenPoint.x, y: screenPoint.y }
|
||||
|
||||
// Bring the shape to the front
|
||||
lastCameraRef.current = { ...currentCamera }
|
||||
|
||||
// Bring the shape to the front using tldraw's proper index functions
|
||||
try {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
|
||||
// Find the highest index among all shapes
|
||||
let highestIndex = shape.index
|
||||
for (const s of allShapes) {
|
||||
if (s.id !== shape.id && s.index > highestIndex) {
|
||||
highestIndex = s.index
|
||||
}
|
||||
}
|
||||
|
||||
// Only update if we need to move higher
|
||||
if (highestIndex > shape.index) {
|
||||
const newIndex = getIndexAbove(highestIndex)
|
||||
editor.updateShape({
|
||||
|
|
@ -151,89 +122,139 @@ export function usePinnedToView(
|
|||
|
||||
// If just became unpinned, animate back to original coordinates
|
||||
if (!isPinned && wasPinnedRef.current) {
|
||||
// Cancel any ongoing animations
|
||||
if (driftAnimationRef.current) {
|
||||
cancelAnimationFrame(driftAnimationRef.current)
|
||||
driftAnimationRef.current = null
|
||||
// Cancel any ongoing pinned position updates
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
|
||||
// Get original coordinates from meta
|
||||
const currentShape = editor.getShape(shapeId as TLShapeId)
|
||||
if (currentShape) {
|
||||
const originalX = (currentShape.meta as any)?.originalX ?? originalCoordinatesRef.current?.x ?? currentShape.x
|
||||
const originalY = (currentShape.meta as any)?.originalY ?? originalCoordinatesRef.current?.y ?? currentShape.y
|
||||
|
||||
const startX = currentShape.x
|
||||
const startY = currentShape.y
|
||||
const targetX = originalX
|
||||
const targetY = originalY
|
||||
|
||||
// Calculate distance
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(targetX - startX, 2) + Math.pow(targetY - startY, 2)
|
||||
)
|
||||
|
||||
// Immediately clear refs so next pin session starts fresh
|
||||
pinnedScreenPositionRef.current = null
|
||||
originalCoordinatesRef.current = null
|
||||
|
||||
if (distance > 1) {
|
||||
// Animation parameters
|
||||
const duration = 600 // 600ms for a calm drift
|
||||
const startTime = performance.now()
|
||||
|
||||
const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3)
|
||||
|
||||
const animateDrift = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const easedProgress = easeOutCubic(progress)
|
||||
|
||||
const currentX = startX + (targetX - startX) * easedProgress
|
||||
const currentY = startY + (targetY - startY) * easedProgress
|
||||
|
||||
|
||||
// Animate back to original coordinates and size with a calm drift
|
||||
if (originalCoordinatesRef.current && originalSizeRef.current && originalZoomRef.current !== null) {
|
||||
const currentShape = editor.getShape(shapeId as TLShapeId)
|
||||
if (currentShape) {
|
||||
const startX = currentShape.x
|
||||
const startY = currentShape.y
|
||||
const targetX = originalCoordinatesRef.current.x
|
||||
const targetY = originalCoordinatesRef.current.y
|
||||
|
||||
// Return to the exact original size (not calculated based on current zoom)
|
||||
const originalW = originalSizeRef.current.w
|
||||
const originalH = originalSizeRef.current.h
|
||||
|
||||
// Use the original size directly
|
||||
const targetW = originalW
|
||||
const targetH = originalH
|
||||
|
||||
const currentW = (currentShape.props as any).w || originalW
|
||||
const currentH = (currentShape.props as any).h || originalH
|
||||
|
||||
const startW = currentW
|
||||
const startH = currentH
|
||||
|
||||
// Only animate if there's a meaningful distance to travel or size change
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(targetX - startX, 2) + Math.pow(targetY - startY, 2)
|
||||
)
|
||||
const sizeChange = Math.abs(targetW - startW) > 0.1 || Math.abs(targetH - startH) > 0.1
|
||||
|
||||
if (distance > 1 || sizeChange) {
|
||||
// Animation parameters
|
||||
const duration = 600 // 600ms for a calm drift
|
||||
const startTime = performance.now()
|
||||
|
||||
// Easing function: ease-out for a calm deceleration
|
||||
const easeOutCubic = (t: number): number => {
|
||||
return 1 - Math.pow(1 - t, 3)
|
||||
}
|
||||
|
||||
const animateDrift = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1) // Clamp to 0-1
|
||||
const easedProgress = easeOutCubic(progress)
|
||||
|
||||
// Interpolate position
|
||||
const currentX = startX + (targetX - startX) * easedProgress
|
||||
const currentY = startY + (targetY - startY) * easedProgress
|
||||
|
||||
// Interpolate size
|
||||
const currentW = startW + (targetW - startW) * easedProgress
|
||||
const currentH = startH + (targetH - startH) * easedProgress
|
||||
|
||||
try {
|
||||
editor.updateShape({
|
||||
id: shapeId as TLShapeId,
|
||||
type: currentShape.type,
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
props: {
|
||||
...currentShape.props,
|
||||
w: currentW,
|
||||
h: currentH,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error during drift animation:', error)
|
||||
driftAnimationRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// Continue animation if not complete
|
||||
if (progress < 1) {
|
||||
driftAnimationRef.current = requestAnimationFrame(animateDrift)
|
||||
} else {
|
||||
// Animation complete - ensure we're exactly at target
|
||||
try {
|
||||
editor.updateShape({
|
||||
id: shapeId as TLShapeId,
|
||||
type: currentShape.type,
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
props: {
|
||||
...currentShape.props,
|
||||
w: targetW,
|
||||
h: targetH,
|
||||
},
|
||||
})
|
||||
console.log(`📍 Drifted back to original coordinates: (${targetX}, ${targetY}) and size: (${targetW}, ${targetH})`)
|
||||
} catch (error) {
|
||||
console.error('Error setting final position/size:', error)
|
||||
}
|
||||
driftAnimationRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// Start the animation
|
||||
driftAnimationRef.current = requestAnimationFrame(animateDrift)
|
||||
} else {
|
||||
// Distance is too small, just set directly
|
||||
try {
|
||||
editor.updateShape({
|
||||
id: shapeId as TLShapeId,
|
||||
type: currentShape.type,
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
props: {
|
||||
...currentShape.props,
|
||||
w: targetW,
|
||||
h: targetH,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error during drift animation:', error)
|
||||
driftAnimationRef.current = null
|
||||
return
|
||||
console.error('Error restoring original coordinates/size:', error)
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
driftAnimationRef.current = requestAnimationFrame(animateDrift)
|
||||
} else {
|
||||
// Animation complete - clear pinned meta data
|
||||
cleanShapeMeta(currentShape)
|
||||
driftAnimationRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
driftAnimationRef.current = requestAnimationFrame(animateDrift)
|
||||
} else {
|
||||
// Distance is too small, just set directly and clear meta
|
||||
try {
|
||||
editor.updateShape({
|
||||
id: shapeId as TLShapeId,
|
||||
type: currentShape.type,
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
})
|
||||
cleanShapeMeta(currentShape)
|
||||
} catch (error) {
|
||||
console.error('Error restoring original coordinates:', error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Shape doesn't exist, just clear refs
|
||||
}
|
||||
|
||||
// Clear refs after a short delay to allow animation to start
|
||||
setTimeout(() => {
|
||||
pinnedScreenPositionRef.current = null
|
||||
originalCoordinatesRef.current = null
|
||||
}
|
||||
originalSizeRef.current = null
|
||||
originalZoomRef.current = null
|
||||
lastCameraRef.current = null
|
||||
pendingUpdateRef.current = null
|
||||
}, 50)
|
||||
}
|
||||
|
||||
wasPinnedRef.current = isPinned
|
||||
|
|
@ -242,23 +263,33 @@ export function usePinnedToView(
|
|||
return
|
||||
}
|
||||
|
||||
// Function to update pinned position - called synchronously on camera changes
|
||||
const updatePinnedPosition = () => {
|
||||
if (isUpdatingRef.current || !editor || !shapeId) {
|
||||
// Use requestAnimationFrame for smooth, continuous updates
|
||||
// Throttle updates to reduce jitter
|
||||
const updatePinnedPosition = (timestamp: number) => {
|
||||
if (isUpdatingRef.current) {
|
||||
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!editor || !shapeId || !isPinned) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentShape = editor.getShape(shapeId as TLShapeId)
|
||||
if (!currentShape) {
|
||||
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the target screen position
|
||||
const currentCamera = editor.getCamera()
|
||||
const lastCamera = lastCameraRef.current
|
||||
|
||||
// For preset positions (top-center, etc.), always recalculate based on viewport
|
||||
// For 'current' position, use the stored screen position
|
||||
let pinnedScreenPos: { x: number; y: number }
|
||||
|
||||
if (position !== 'current') {
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
const currentCamera = editor.getCamera()
|
||||
const shapeWidth = (currentShape.props as any).w || 0
|
||||
const shapeHeight = (currentShape.props as any).h || 0
|
||||
|
||||
|
|
@ -282,57 +313,143 @@ export function usePinnedToView(
|
|||
}
|
||||
} else {
|
||||
if (!pinnedScreenPositionRef.current) {
|
||||
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
|
||||
return
|
||||
}
|
||||
pinnedScreenPos = pinnedScreenPositionRef.current
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert screen position back to page coordinates
|
||||
const newPagePoint = editor.screenToPage(pinnedScreenPos)
|
||||
// Check if camera has changed significantly
|
||||
const cameraChanged = !lastCamera || (
|
||||
Math.abs(currentCamera.x - lastCamera.x) > 0.1 ||
|
||||
Math.abs(currentCamera.y - lastCamera.y) > 0.1 ||
|
||||
Math.abs(currentCamera.z - lastCamera.z) > 0.001
|
||||
)
|
||||
|
||||
// Always update - no threshold, for maximum responsiveness
|
||||
isUpdatingRef.current = true
|
||||
// For preset positions, always check for updates (viewport might have changed)
|
||||
const shouldUpdate = cameraChanged || position !== 'current'
|
||||
|
||||
editor.updateShape({
|
||||
id: shapeId as TLShapeId,
|
||||
type: currentShape.type,
|
||||
x: newPagePoint.x,
|
||||
y: newPagePoint.y,
|
||||
})
|
||||
if (shouldUpdate) {
|
||||
// Throttle updates to max 60fps (every ~16ms)
|
||||
const timeSinceLastUpdate = timestamp - lastUpdateTimeRef.current
|
||||
const minUpdateInterval = 16 // ~60fps
|
||||
|
||||
isUpdatingRef.current = false
|
||||
} catch (error) {
|
||||
console.error('Error updating pinned shape position:', error)
|
||||
isUpdatingRef.current = false
|
||||
if (timeSinceLastUpdate >= minUpdateInterval) {
|
||||
try {
|
||||
// Convert the pinned screen position back to page coordinates
|
||||
const newPagePoint = editor.screenToPage(pinnedScreenPos)
|
||||
|
||||
// Calculate delta
|
||||
const deltaX = Math.abs(currentShape.x - newPagePoint.x)
|
||||
const deltaY = Math.abs(currentShape.y - newPagePoint.y)
|
||||
|
||||
// Check if zoom changed - if so, adjust size to maintain constant visual size
|
||||
const zoomChanged = lastCamera && Math.abs(currentCamera.z - lastCamera.z) > 0.001
|
||||
let needsSizeUpdate = false
|
||||
let newW = (currentShape.props as any).w
|
||||
let newH = (currentShape.props as any).h
|
||||
|
||||
if (zoomChanged && originalSizeRef.current && originalZoomRef.current !== null) {
|
||||
// Calculate the size needed to maintain constant visual size
|
||||
// Visual size = page size * zoom
|
||||
// To keep visual size constant: new_page_size = (original_page_size * original_zoom) / new_zoom
|
||||
const originalW = originalSizeRef.current.w
|
||||
const originalH = originalSizeRef.current.h
|
||||
const originalZoom = originalZoomRef.current
|
||||
const currentZoom = currentCamera.z
|
||||
|
||||
newW = (originalW * originalZoom) / currentZoom
|
||||
newH = (originalH * originalZoom) / currentZoom
|
||||
|
||||
const currentW = (currentShape.props as any).w || originalW
|
||||
const currentH = (currentShape.props as any).h || originalH
|
||||
|
||||
// Check if size needs updating
|
||||
needsSizeUpdate = Math.abs(newW - currentW) > 0.1 || Math.abs(newH - currentH) > 0.1
|
||||
}
|
||||
|
||||
// Only update if the position would actually change significantly or size needs updating
|
||||
if (deltaX > 0.5 || deltaY > 0.5 || needsSizeUpdate) {
|
||||
isUpdatingRef.current = true
|
||||
|
||||
// Batch the update using editor.batch for smoother updates
|
||||
editor.batch(() => {
|
||||
const updateData: any = {
|
||||
id: shapeId,
|
||||
type: currentShape.type,
|
||||
x: newPagePoint.x,
|
||||
y: newPagePoint.y,
|
||||
}
|
||||
|
||||
// Only update size if it changed
|
||||
if (needsSizeUpdate) {
|
||||
updateData.props = {
|
||||
...currentShape.props,
|
||||
w: newW,
|
||||
h: newH,
|
||||
}
|
||||
}
|
||||
|
||||
editor.updateShape(updateData)
|
||||
})
|
||||
|
||||
lastUpdateTimeRef.current = timestamp
|
||||
isUpdatingRef.current = false
|
||||
}
|
||||
|
||||
lastCameraRef.current = { ...currentCamera }
|
||||
} catch (error) {
|
||||
console.error('Error updating pinned shape position/size:', error)
|
||||
isUpdatingRef.current = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continue monitoring
|
||||
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
|
||||
}
|
||||
|
||||
// Use store.listen to react immediately to camera changes
|
||||
// This is more immediate than 'tick' as it fires synchronously when the store changes
|
||||
const unsubscribe = editor.store.listen(
|
||||
(entry) => {
|
||||
// Only react to camera changes
|
||||
const hasCamera = Object.entries(entry.changes.updated).some(
|
||||
([, [, record]]) => record.typeName === 'camera'
|
||||
)
|
||||
if (hasCamera && !isUpdatingRef.current) {
|
||||
updatePinnedPosition()
|
||||
}
|
||||
},
|
||||
{ source: 'all', scope: 'document' }
|
||||
)
|
||||
// Start the animation loop
|
||||
lastUpdateTimeRef.current = performance.now()
|
||||
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
|
||||
|
||||
// Don't call updatePinnedPosition immediately - the shape is already at
|
||||
// the correct position since we just captured its current screen position
|
||||
// Only start listening for camera changes
|
||||
// Also listen for shape changes (in case user drags the shape while pinned)
|
||||
// This updates the pinned position to the new location
|
||||
const handleShapeChange = (event: any) => {
|
||||
if (isUpdatingRef.current) return // Don't update if we're programmatically moving it
|
||||
|
||||
if (!editor || !shapeId || !isPinned) return
|
||||
|
||||
// Only respond to changes that affect this specific shape
|
||||
const changedShapes = event?.changedShapes || event?.shapes || []
|
||||
const shapeChanged = changedShapes.some((s: any) => s?.id === (shapeId as TLShapeId))
|
||||
|
||||
if (!shapeChanged) return
|
||||
|
||||
const currentShape = editor.getShape(shapeId as TLShapeId)
|
||||
if (!currentShape) return
|
||||
|
||||
// Update the pinned screen position to the shape's current screen position
|
||||
const pagePoint = { x: currentShape.x, y: currentShape.y }
|
||||
const screenPoint = editor.pageToScreen(pagePoint)
|
||||
pinnedScreenPositionRef.current = { x: screenPoint.x, y: screenPoint.y }
|
||||
lastCameraRef.current = { ...editor.getCamera() }
|
||||
}
|
||||
|
||||
// Listen for shape updates (when user drags the shape)
|
||||
editor.on('change' as any, handleShapeChange)
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
if (driftAnimationRef.current) {
|
||||
cancelAnimationFrame(driftAnimationRef.current)
|
||||
driftAnimationRef.current = null
|
||||
}
|
||||
unsubscribe()
|
||||
editor.off('change' as any, handleShapeChange)
|
||||
}
|
||||
}, [editor, shapeId, isPinned, position, offsetX, offsetY])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,346 +0,0 @@
|
|||
/**
|
||||
* useWallet - Hooks for Web3 wallet integration
|
||||
*
|
||||
* Provides functionality for:
|
||||
* - Connecting/disconnecting wallets
|
||||
* - Linking wallets to enCryptID accounts
|
||||
* - Managing linked wallets
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
useAccount,
|
||||
useConnect,
|
||||
useDisconnect,
|
||||
useSignMessage,
|
||||
useEnsName,
|
||||
useEnsAvatar,
|
||||
useChainId,
|
||||
} from 'wagmi';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { WORKER_URL } from '../constants/workerUrl';
|
||||
import * as crypto from '../lib/auth/crypto';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type WalletType = 'eoa' | 'safe' | 'hardware' | 'contract';
|
||||
|
||||
export interface LinkedWallet {
|
||||
id: string;
|
||||
address: string;
|
||||
type: WalletType;
|
||||
chainId: number;
|
||||
label: string | null;
|
||||
ensName: string | null;
|
||||
ensAvatar: string | null;
|
||||
isPrimary: boolean;
|
||||
linkedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
interface LinkWalletResult {
|
||||
success: boolean;
|
||||
wallet?: LinkedWallet;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Message Generation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generate the message that must be signed to link a wallet
|
||||
*/
|
||||
function generateLinkMessage(
|
||||
username: string,
|
||||
address: string,
|
||||
timestamp: string,
|
||||
nonce: string
|
||||
): string {
|
||||
return `Link wallet to enCryptID
|
||||
|
||||
Account: ${username}
|
||||
Wallet: ${address}
|
||||
Timestamp: ${timestamp}
|
||||
Nonce: ${nonce}
|
||||
|
||||
This signature proves you own this wallet.`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// useWalletConnection - Basic wallet connection
|
||||
// =============================================================================
|
||||
|
||||
export function useWalletConnection() {
|
||||
const { address, isConnected, isConnecting, connector } = useAccount();
|
||||
const { connect, connectors, isPending: isConnectPending } = useConnect();
|
||||
const { disconnect, isPending: isDisconnectPending } = useDisconnect();
|
||||
const chainId = useChainId();
|
||||
|
||||
// ENS data for connected wallet
|
||||
const { data: ensName } = useEnsName({ address });
|
||||
const { data: ensAvatar } = useEnsAvatar({ name: ensName || undefined });
|
||||
|
||||
const connectWallet = useCallback((connectorId?: string) => {
|
||||
const targetConnector = connectorId
|
||||
? connectors.find(c => c.id === connectorId)
|
||||
: connectors[0]; // Default to first connector (usually injected)
|
||||
|
||||
if (targetConnector) {
|
||||
connect({ connector: targetConnector });
|
||||
}
|
||||
}, [connect, connectors]);
|
||||
|
||||
return {
|
||||
// Connection state
|
||||
address,
|
||||
isConnected,
|
||||
isConnecting: isConnecting || isConnectPending,
|
||||
chainId,
|
||||
connectorName: connector?.name,
|
||||
|
||||
// ENS data
|
||||
ensName: ensName || null,
|
||||
ensAvatar: ensAvatar || null,
|
||||
|
||||
// Actions
|
||||
connect: connectWallet,
|
||||
disconnect,
|
||||
isDisconnecting: isDisconnectPending,
|
||||
|
||||
// Available connectors
|
||||
connectors: connectors.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// useWalletLink - Link wallet to enCryptID
|
||||
// =============================================================================
|
||||
|
||||
export function useWalletLink() {
|
||||
const { address, isConnected } = useAccount();
|
||||
const { signMessageAsync } = useSignMessage();
|
||||
const { session } = useAuth();
|
||||
const [isLinking, setIsLinking] = useState(false);
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
|
||||
const linkWallet = useCallback(async (label?: string): Promise<LinkWalletResult> => {
|
||||
if (!address) {
|
||||
return { success: false, error: 'No wallet connected' };
|
||||
}
|
||||
|
||||
if (!session.authed || !session.username) {
|
||||
return { success: false, error: 'Not authenticated with enCryptID' };
|
||||
}
|
||||
|
||||
setIsLinking(true);
|
||||
setLinkError(null);
|
||||
|
||||
try {
|
||||
// Generate the message to sign
|
||||
const timestamp = new Date().toISOString();
|
||||
const nonce = globalThis.crypto.randomUUID();
|
||||
const message = generateLinkMessage(
|
||||
session.username,
|
||||
address,
|
||||
timestamp,
|
||||
nonce
|
||||
);
|
||||
|
||||
// Request signature from wallet
|
||||
const signature = await signMessageAsync({ message });
|
||||
|
||||
// Get public key for auth header
|
||||
const publicKey = crypto.getPublicKey(session.username);
|
||||
if (!publicKey) {
|
||||
throw new Error('Could not get enCryptID public key');
|
||||
}
|
||||
|
||||
// Send to backend for verification
|
||||
const response = await fetch(`${WORKER_URL}/api/wallet/link`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CryptID-PublicKey': publicKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
walletAddress: address,
|
||||
signature,
|
||||
message,
|
||||
label,
|
||||
walletType: 'eoa',
|
||||
chainId: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json() as { error?: string; wallet?: LinkedWallet };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to link wallet');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
wallet: data.wallet,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
setLinkError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setIsLinking(false);
|
||||
}
|
||||
}, [address, session.authed, session.username, signMessageAsync]);
|
||||
|
||||
return {
|
||||
address,
|
||||
isConnected,
|
||||
isLinking,
|
||||
linkError,
|
||||
linkWallet,
|
||||
clearError: () => setLinkError(null),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// useLinkedWallets - Manage linked wallets
|
||||
// =============================================================================
|
||||
|
||||
export function useLinkedWallets() {
|
||||
const { session } = useAuth();
|
||||
const [wallets, setWallets] = useState<LinkedWallet[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch linked wallets
|
||||
const fetchWallets = useCallback(async () => {
|
||||
if (!session.authed || !session.username) {
|
||||
setWallets([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKey = crypto.getPublicKey(session.username);
|
||||
if (!publicKey) {
|
||||
setError('Could not get enCryptID public key');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/api/wallet/list`, {
|
||||
headers: {
|
||||
'X-CryptID-PublicKey': publicKey,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json() as { error?: string; wallets?: LinkedWallet[] };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch wallets');
|
||||
}
|
||||
|
||||
setWallets(data.wallets || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [session.authed, session.username]);
|
||||
|
||||
// Fetch on mount and when session changes
|
||||
useEffect(() => {
|
||||
fetchWallets();
|
||||
}, [fetchWallets]);
|
||||
|
||||
// Update a wallet
|
||||
const updateWallet = useCallback(async (
|
||||
address: string,
|
||||
updates: { label?: string; isPrimary?: boolean }
|
||||
): Promise<boolean> => {
|
||||
if (!session.username) return false;
|
||||
|
||||
const publicKey = crypto.getPublicKey(session.username);
|
||||
if (!publicKey) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/api/wallet/${address}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CryptID-PublicKey': publicKey,
|
||||
},
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await fetchWallets(); // Refresh list
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [session.username, fetchWallets]);
|
||||
|
||||
// Unlink a wallet
|
||||
const unlinkWallet = useCallback(async (address: string): Promise<boolean> => {
|
||||
if (!session.username) return false;
|
||||
|
||||
const publicKey = crypto.getPublicKey(session.username);
|
||||
if (!publicKey) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/api/wallet/${address}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CryptID-PublicKey': publicKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await fetchWallets(); // Refresh list
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [session.username, fetchWallets]);
|
||||
|
||||
return {
|
||||
wallets,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: fetchWallets,
|
||||
updateWallet,
|
||||
unlinkWallet,
|
||||
primaryWallet: wallets.find(w => w.isPrimary) || wallets[0] || null,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utility functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Format an address for display (0x1234...5678)
|
||||
*/
|
||||
export function formatAddress(address: string, chars = 4): string {
|
||||
if (!address) return '';
|
||||
return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an address is valid
|
||||
*/
|
||||
export function isValidAddress(address: string): boolean {
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||
}
|
||||
|
|
@ -163,6 +163,7 @@ export const useWebSpeechTranscription = ({
|
|||
// Reduced debug logging
|
||||
} else {
|
||||
setIsSupported(false)
|
||||
console.log('❌ Web Speech API is not supported')
|
||||
onError?.(new Error('Web Speech API is not supported in this browser'))
|
||||
}
|
||||
}, [onError])
|
||||
|
|
@ -180,6 +181,7 @@ export const useWebSpeechTranscription = ({
|
|||
recognition.maxAlternatives = 1
|
||||
|
||||
recognition.onstart = () => {
|
||||
console.log('🎤 Web Speech API started')
|
||||
setIsRecording(true)
|
||||
setIsTranscribing(true)
|
||||
}
|
||||
|
|
@ -219,6 +221,7 @@ export const useWebSpeechTranscription = ({
|
|||
finalTranscriptRef.current += newText
|
||||
setTranscript(finalTranscriptRef.current)
|
||||
onTranscriptUpdate?.(newText) // Only send the new text portion
|
||||
console.log(`✅ Final transcript: "${processedFinal}" (confidence: ${confidence.toFixed(2)})`)
|
||||
|
||||
// Trigger pause detection
|
||||
handlePauseDetection()
|
||||
|
|
@ -229,6 +232,7 @@ export const useWebSpeechTranscription = ({
|
|||
const processedInterim = processTranscript(interimTranscript, false)
|
||||
interimTranscriptRef.current = processedInterim
|
||||
setInterimTranscript(processedInterim)
|
||||
console.log(`🔄 Interim transcript: "${processedInterim}"`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +244,7 @@ export const useWebSpeechTranscription = ({
|
|||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
console.log('🛑 Web Speech API ended')
|
||||
setIsRecording(false)
|
||||
setIsTranscribing(false)
|
||||
}
|
||||
|
|
@ -255,6 +260,7 @@ export const useWebSpeechTranscription = ({
|
|||
}
|
||||
|
||||
try {
|
||||
console.log('🎤 Starting Web Speech API recording...')
|
||||
|
||||
// Don't reset transcripts for continuous transcription - keep existing content
|
||||
// finalTranscriptRef.current = ''
|
||||
|
|
@ -285,6 +291,7 @@ export const useWebSpeechTranscription = ({
|
|||
// Stop recording
|
||||
const stopRecording = useCallback(() => {
|
||||
if (recognitionRef.current) {
|
||||
console.log('🛑 Stopping Web Speech API recording...')
|
||||
recognitionRef.current.stop()
|
||||
recognitionRef.current = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ export const useWhisperTranscription = ({
|
|||
const initializeTranscriber = useCallback(async () => {
|
||||
// Skip model loading if using RunPod
|
||||
if (shouldUseRunPod) {
|
||||
console.log('🚀 Using RunPod WhisperX endpoint - skipping local model loading')
|
||||
setModelLoaded(true) // Mark as "loaded" since we don't need a local model
|
||||
return null
|
||||
}
|
||||
|
|
@ -214,6 +215,7 @@ export const useWhisperTranscription = ({
|
|||
if (transcriberRef.current) return transcriberRef.current
|
||||
|
||||
try {
|
||||
console.log('🤖 Loading Whisper model...')
|
||||
|
||||
// Check if we're running in a CORS-restricted environment
|
||||
if (typeof window !== 'undefined' && window.location.protocol === 'file:') {
|
||||
|
|
@ -228,13 +230,16 @@ export const useWhisperTranscription = ({
|
|||
|
||||
for (const modelOption of modelOptions) {
|
||||
try {
|
||||
console.log(`🔄 Trying model: ${modelOption.name}`)
|
||||
transcriber = await pipeline('automatic-speech-recognition', modelOption.name, {
|
||||
...modelOption.options,
|
||||
progress_callback: (progress: any) => {
|
||||
if (progress.status === 'downloading') {
|
||||
console.log(`📦 Downloading model: ${progress.file} (${Math.round(progress.progress * 100)}%)`)
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log(`✅ Successfully loaded model: ${modelOption.name}`)
|
||||
break
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Failed to load model ${modelOption.name}:`, error)
|
||||
|
|
@ -268,7 +273,9 @@ export const useWhisperTranscription = ({
|
|||
quantized: true,
|
||||
progress_callback: (progress: any) => {
|
||||
if (progress.status === 'downloading') {
|
||||
console.log(`📦 Downloading model: ${progress.file} (${Math.round(progress.progress * 100)}%)`)
|
||||
} else if (progress.status === 'loading') {
|
||||
console.log(`🔄 Loading model: ${progress.file}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -281,6 +288,7 @@ export const useWhisperTranscription = ({
|
|||
|
||||
transcriberRef.current = transcriber
|
||||
setModelLoaded(true)
|
||||
console.log(`✅ Whisper model loaded: ${modelName}`)
|
||||
|
||||
return transcriber
|
||||
} catch (error) {
|
||||
|
|
@ -348,6 +356,8 @@ export const useWhisperTranscription = ({
|
|||
previousTranscriptLengthRef.current = processedTranscript.length
|
||||
}
|
||||
|
||||
console.log(`📝 Real-time transcript updated: "${newTextTrimmed}" -> Total: "${processedTranscript}"`)
|
||||
console.log(`🔄 Streaming transcript state updated, calling onTranscriptUpdate with: "${processedTranscript}"`)
|
||||
}
|
||||
}, [onTranscriptUpdate, processTranscript])
|
||||
|
||||
|
|
@ -362,6 +372,7 @@ export const useWhisperTranscription = ({
|
|||
|
||||
const chunks = audioChunksRef.current || []
|
||||
if (chunks.length === 0 || chunks.length < 2) {
|
||||
console.log(`⚠️ Not enough chunks for real-time processing: ${chunks.length}`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -370,11 +381,13 @@ export const useWhisperTranscription = ({
|
|||
const validChunks = recentChunks.filter(chunk => chunk && chunk.size > 2000) // Filter out small chunks
|
||||
|
||||
if (validChunks.length < 2) {
|
||||
console.log(`⚠️ Not enough valid chunks for real-time processing: ${validChunks.length}`)
|
||||
return
|
||||
}
|
||||
|
||||
const totalSize = validChunks.reduce((sum, chunk) => sum + chunk.size, 0)
|
||||
if (totalSize < 20000) { // Increased to 20KB for reliable decoding
|
||||
console.log(`⚠️ Not enough audio data for real-time processing: ${totalSize} bytes`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -384,12 +397,16 @@ export const useWhisperTranscription = ({
|
|||
mimeType = mediaRecorderRef.current.mimeType
|
||||
}
|
||||
|
||||
console.log(`🔄 Real-time processing ${validChunks.length} chunks, total size: ${totalSize} bytes, type: ${mimeType}`)
|
||||
console.log(`🔄 Chunk sizes:`, validChunks.map(c => c.size))
|
||||
console.log(`🔄 Chunk types:`, validChunks.map(c => c.type))
|
||||
|
||||
// Create a more robust blob with proper headers
|
||||
const tempBlob = new Blob(validChunks, { type: mimeType })
|
||||
|
||||
// Validate blob size
|
||||
if (tempBlob.size < 10000) {
|
||||
console.log(`⚠️ Blob too small for processing: ${tempBlob.size} bytes`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -397,6 +414,7 @@ export const useWhisperTranscription = ({
|
|||
|
||||
// Validate audio buffer
|
||||
if (audioBuffer.byteLength < 10000) {
|
||||
console.log(`⚠️ Audio buffer too small: ${audioBuffer.byteLength} bytes`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -406,14 +424,18 @@ export const useWhisperTranscription = ({
|
|||
try {
|
||||
// Try to decode the audio buffer
|
||||
audioBufferFromBlob = await audioContext.decodeAudioData(audioBuffer)
|
||||
console.log(`✅ Successfully decoded real-time audio buffer: ${audioBufferFromBlob.length} samples`)
|
||||
} catch (decodeError) {
|
||||
console.log('⚠️ Real-time chunk decode failed, trying alternative approach:', decodeError)
|
||||
|
||||
// Try alternative approach: create a new blob with different MIME type
|
||||
try {
|
||||
const alternativeBlob = new Blob(validChunks, { type: 'audio/webm' })
|
||||
const alternativeBuffer = await alternativeBlob.arrayBuffer()
|
||||
audioBufferFromBlob = await audioContext.decodeAudioData(alternativeBuffer)
|
||||
console.log(`✅ Successfully decoded with alternative approach: ${audioBufferFromBlob.length} samples`)
|
||||
} catch (altError) {
|
||||
console.log('⚠️ Alternative decode also failed, skipping:', altError)
|
||||
await audioContext.close()
|
||||
return
|
||||
}
|
||||
|
|
@ -437,12 +459,15 @@ export const useWhisperTranscription = ({
|
|||
const maxAmplitude = Math.max(...processedAudioData.map(Math.abs))
|
||||
const dynamicRange = maxAmplitude - Math.min(...processedAudioData.map(Math.abs))
|
||||
|
||||
console.log(`🔊 Real-time audio analysis: RMS=${rms.toFixed(6)}, Max=${maxAmplitude.toFixed(6)}, Range=${dynamicRange.toFixed(6)}`)
|
||||
|
||||
if (rms < 0.001) {
|
||||
console.log('⚠️ Audio too quiet for transcription (RMS < 0.001)')
|
||||
return // Skip very quiet audio
|
||||
}
|
||||
|
||||
if (dynamicRange < 0.01) {
|
||||
console.log('⚠️ Audio has very low dynamic range, may be mostly noise')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -456,17 +481,20 @@ export const useWhisperTranscription = ({
|
|||
return // Skip very short audio
|
||||
}
|
||||
|
||||
console.log(`🎵 Real-time audio: ${processedAudioData.length} samples (${(processedAudioData.length / 16000).toFixed(2)}s)`)
|
||||
|
||||
let transcriptionText = ''
|
||||
|
||||
// Use RunPod if configured, otherwise use local model
|
||||
if (shouldUseRunPod) {
|
||||
console.log('🚀 Using RunPod WhisperX API for real-time transcription...')
|
||||
// Convert processed audio data back to blob for RunPod
|
||||
const wavBlob = await createWavBlob(processedAudioData, 16000)
|
||||
transcriptionText = await transcribeWithRunPod(wavBlob, language)
|
||||
} else {
|
||||
// Use local Whisper model
|
||||
if (!transcriberRef.current) {
|
||||
console.log('⚠️ Transcriber not available for real-time processing')
|
||||
return
|
||||
}
|
||||
const result = await transcriberRef.current(processedAudioData, {
|
||||
|
|
@ -484,8 +512,11 @@ export const useWhisperTranscription = ({
|
|||
}
|
||||
if (transcriptionText.trim()) {
|
||||
lastTranscriptionTimeRef.current = Date.now()
|
||||
console.log(`✅ Real-time transcript: "${transcriptionText.trim()}"`)
|
||||
console.log(`🔄 Calling handleStreamingTranscriptUpdate with: "${transcriptionText.trim()}"`)
|
||||
handleStreamingTranscriptUpdate(transcriptionText.trim())
|
||||
} else {
|
||||
console.log('⚠️ No real-time transcription text produced, trying fallback parameters...')
|
||||
|
||||
// Try with more permissive parameters for real-time processing (only for local model)
|
||||
if (!shouldUseRunPod && transcriberRef.current) {
|
||||
|
|
@ -502,11 +533,14 @@ export const useWhisperTranscription = ({
|
|||
|
||||
const fallbackText = fallbackResult?.text || ''
|
||||
if (fallbackText.trim()) {
|
||||
console.log(`✅ Fallback real-time transcript: "${fallbackText.trim()}"`)
|
||||
lastTranscriptionTimeRef.current = Date.now()
|
||||
handleStreamingTranscriptUpdate(fallbackText.trim())
|
||||
} else {
|
||||
console.log('⚠️ Fallback transcription also produced no text')
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.log('⚠️ Fallback transcription failed:', fallbackError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -519,17 +553,20 @@ export const useWhisperTranscription = ({
|
|||
// Process recorded audio chunks (final processing)
|
||||
const processAudioChunks = useCallback(async () => {
|
||||
if (audioChunksRef.current.length === 0) {
|
||||
console.log('⚠️ No audio chunks to process')
|
||||
return
|
||||
}
|
||||
|
||||
// For local model, ensure transcriber is loaded
|
||||
if (!shouldUseRunPod) {
|
||||
if (!transcriberRef.current) {
|
||||
console.log('⚠️ No transcriber available')
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure model is loaded
|
||||
if (!modelLoaded) {
|
||||
console.log('⚠️ Model not loaded yet, waiting...')
|
||||
try {
|
||||
await initializeTranscriber()
|
||||
} catch (error) {
|
||||
|
|
@ -542,6 +579,7 @@ export const useWhisperTranscription = ({
|
|||
|
||||
try {
|
||||
setIsTranscribing(true)
|
||||
console.log('🔄 Processing final audio chunks...')
|
||||
|
||||
// Create a blob from all chunks with proper MIME type detection
|
||||
let mimeType = 'audio/webm;codecs=opus'
|
||||
|
|
@ -553,14 +591,17 @@ export const useWhisperTranscription = ({
|
|||
const validChunks = audioChunksRef.current.filter(chunk => chunk && chunk.size > 1000)
|
||||
|
||||
if (validChunks.length === 0) {
|
||||
console.log('⚠️ No valid audio chunks to process')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`🔄 Processing ${validChunks.length} valid chunks out of ${audioChunksRef.current.length} total chunks`)
|
||||
|
||||
const audioBlob = new Blob(validChunks, { type: mimeType })
|
||||
|
||||
// Validate blob size
|
||||
if (audioBlob.size < 10000) {
|
||||
console.log(`⚠️ Audio blob too small for processing: ${audioBlob.size} bytes`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -569,6 +610,7 @@ export const useWhisperTranscription = ({
|
|||
|
||||
// Validate array buffer
|
||||
if (arrayBuffer.byteLength < 10000) {
|
||||
console.log(`⚠️ Audio buffer too small: ${arrayBuffer.byteLength} bytes`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -578,14 +620,17 @@ export const useWhisperTranscription = ({
|
|||
let audioBuffer: AudioBuffer
|
||||
try {
|
||||
audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
|
||||
console.log(`✅ Successfully decoded final audio buffer: ${audioBuffer.length} samples`)
|
||||
} catch (decodeError) {
|
||||
console.error('❌ Failed to decode final audio buffer:', decodeError)
|
||||
|
||||
// Try alternative approach with different MIME type
|
||||
try {
|
||||
console.log('🔄 Trying alternative MIME type for final processing...')
|
||||
const alternativeBlob = new Blob(validChunks, { type: 'audio/webm' })
|
||||
const alternativeBuffer = await alternativeBlob.arrayBuffer()
|
||||
audioBuffer = await audioContext.decodeAudioData(alternativeBuffer)
|
||||
console.log(`✅ Successfully decoded with alternative approach: ${audioBuffer.length} samples`)
|
||||
} catch (altError) {
|
||||
console.error('❌ Alternative decode also failed:', altError)
|
||||
await audioContext.close()
|
||||
|
|
@ -598,29 +643,38 @@ export const useWhisperTranscription = ({
|
|||
// Get the first channel as Float32Array
|
||||
const audioData = audioBuffer.getChannelData(0)
|
||||
|
||||
console.log(`🔍 Audio buffer info: sampleRate=${audioBuffer.sampleRate}, length=${audioBuffer.length}, duration=${audioBuffer.duration}s`)
|
||||
console.log(`🔍 Audio data: length=${audioData.length}, first 10 values:`, Array.from(audioData.slice(0, 10)))
|
||||
|
||||
// Check for meaningful audio content
|
||||
const rms = Math.sqrt(audioData.reduce((sum, val) => sum + val * val, 0) / audioData.length)
|
||||
console.log(`🔊 Audio RMS level: ${rms.toFixed(6)}`)
|
||||
|
||||
if (rms < 0.001) {
|
||||
console.log('⚠️ Audio appears to be mostly silence (RMS < 0.001)')
|
||||
}
|
||||
|
||||
// Resample if necessary
|
||||
let processedAudioData: Float32Array = audioData
|
||||
if (audioBuffer.sampleRate !== 16000) {
|
||||
console.log(`🔄 Resampling from ${audioBuffer.sampleRate}Hz to 16000Hz`)
|
||||
processedAudioData = resampleAudio(audioData as Float32Array, audioBuffer.sampleRate, 16000)
|
||||
}
|
||||
|
||||
console.log(`🎵 Processing audio: ${processedAudioData.length} samples (${(processedAudioData.length / 16000).toFixed(2)}s)`)
|
||||
|
||||
console.log('🔄 Starting transcription...')
|
||||
|
||||
let newText = ''
|
||||
|
||||
// Use RunPod if configured, otherwise use local model
|
||||
if (shouldUseRunPod) {
|
||||
console.log('🚀 Using RunPod WhisperX API...')
|
||||
// Convert processed audio data back to blob for RunPod
|
||||
// Create a WAV blob from the Float32Array
|
||||
const wavBlob = await createWavBlob(processedAudioData, 16000)
|
||||
newText = await transcribeWithRunPod(wavBlob, language)
|
||||
console.log('✅ RunPod transcription result:', newText)
|
||||
} else {
|
||||
// Use local Whisper model
|
||||
if (!transcriberRef.current) {
|
||||
|
|
@ -632,6 +686,7 @@ export const useWhisperTranscription = ({
|
|||
return_timestamps: false
|
||||
})
|
||||
|
||||
console.log('🔍 Transcription result:', result)
|
||||
newText = result?.text?.trim() || ''
|
||||
}
|
||||
if (newText) {
|
||||
|
|
@ -655,19 +710,24 @@ export const useWhisperTranscription = ({
|
|||
previousTranscriptLengthRef.current = updatedTranscript.length
|
||||
}
|
||||
|
||||
console.log(`✅ Transcription: "${processedText}" -> Total: "${updatedTranscript}"`)
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ No transcription text produced')
|
||||
|
||||
// Try alternative transcription parameters (only for local model)
|
||||
if (!shouldUseRunPod && transcriberRef.current) {
|
||||
console.log('🔄 Trying alternative transcription parameters...')
|
||||
try {
|
||||
const altResult = await transcriberRef.current(processedAudioData, {
|
||||
task: 'transcribe',
|
||||
return_timestamps: false
|
||||
})
|
||||
console.log('🔍 Alternative transcription result:', altResult)
|
||||
|
||||
if (altResult?.text?.trim()) {
|
||||
const processedAltText = processTranscript(altResult.text, enableStreaming)
|
||||
console.log('✅ Alternative transcription successful:', processedAltText)
|
||||
const currentTranscript = transcriptRef.current
|
||||
const updatedTranscript = currentTranscript ? `${currentTranscript} ${processedAltText}` : processedAltText
|
||||
|
||||
|
|
@ -682,6 +742,7 @@ export const useWhisperTranscription = ({
|
|||
}
|
||||
}
|
||||
} catch (altError) {
|
||||
console.log('⚠️ Alternative transcription also failed:', altError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -700,9 +761,12 @@ export const useWhisperTranscription = ({
|
|||
// Start recording
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
console.log('🎤 Starting recording...')
|
||||
console.log('🔍 enableStreaming in startRecording:', enableStreaming)
|
||||
|
||||
// Ensure model is loaded before starting (skip for RunPod)
|
||||
if (!shouldUseRunPod && !modelLoaded) {
|
||||
console.log('🔄 Model not loaded, initializing...')
|
||||
await initializeTranscriber()
|
||||
} else if (shouldUseRunPod) {
|
||||
// For RunPod, just mark as ready
|
||||
|
|
@ -749,6 +813,7 @@ export const useWhisperTranscription = ({
|
|||
|
||||
for (const option of options) {
|
||||
if (MediaRecorder.isTypeSupported(option.mimeType)) {
|
||||
console.log('🎵 Using MIME type:', option.mimeType)
|
||||
mediaRecorder = new MediaRecorder(stream, option)
|
||||
break
|
||||
}
|
||||
|
|
@ -760,6 +825,7 @@ export const useWhisperTranscription = ({
|
|||
|
||||
// Store the MIME type for later use
|
||||
const mimeType = mediaRecorder.mimeType
|
||||
console.log('🎵 Final MIME type:', mimeType)
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
|
||||
|
|
@ -769,44 +835,56 @@ export const useWhisperTranscription = ({
|
|||
// Validate chunk before adding
|
||||
if (event.data.size > 1000) { // Only add chunks with meaningful size
|
||||
audioChunksRef.current.push(event.data)
|
||||
console.log(`📦 Received chunk ${audioChunksRef.current.length}, size: ${event.data.size} bytes, type: ${event.data.type}`)
|
||||
|
||||
// Limit the number of chunks to prevent memory issues
|
||||
if (audioChunksRef.current.length > 20) {
|
||||
audioChunksRef.current = audioChunksRef.current.slice(-15) // Keep last 15 chunks
|
||||
}
|
||||
} else {
|
||||
console.log(`⚠️ Skipping small chunk: ${event.data.size} bytes`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle recording stop
|
||||
mediaRecorder.onstop = () => {
|
||||
console.log('🛑 Recording stopped, processing audio...')
|
||||
processAudioChunks()
|
||||
}
|
||||
|
||||
// Handle MediaRecorder state changes
|
||||
mediaRecorder.onstart = () => {
|
||||
console.log('🎤 MediaRecorder started')
|
||||
console.log('🔍 enableStreaming value:', enableStreaming)
|
||||
setIsRecording(true)
|
||||
isRecordingRef.current = true
|
||||
|
||||
// Start periodic transcription processing for streaming mode
|
||||
if (enableStreaming) {
|
||||
console.log('🔄 Starting streaming transcription (every 0.8 seconds)')
|
||||
periodicTranscriptionRef.current = setInterval(() => {
|
||||
console.log('🔄 Interval triggered, isRecordingRef.current:', isRecordingRef.current)
|
||||
if (isRecordingRef.current) {
|
||||
console.log('🔄 Running periodic streaming transcription...')
|
||||
processAccumulatedAudioChunks()
|
||||
} else {
|
||||
console.log('⚠️ Not running transcription - recording stopped')
|
||||
}
|
||||
}, 800) // Update every 0.8 seconds for better responsiveness
|
||||
} else {
|
||||
console.log('ℹ️ Streaming transcription disabled - enableStreaming is false')
|
||||
}
|
||||
}
|
||||
|
||||
// Start recording with appropriate timeslice
|
||||
const timeslice = enableStreaming ? 1000 : 2000 // Larger chunks for more stable processing
|
||||
console.log(`🎵 Starting recording with ${timeslice}ms timeslice`)
|
||||
mediaRecorder.start(timeslice)
|
||||
isRecordingRef.current = true
|
||||
setIsRecording(true)
|
||||
|
||||
console.log('✅ Recording started - MediaRecorder state:', mediaRecorder.state)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error starting recording:', error)
|
||||
|
|
@ -817,6 +895,7 @@ export const useWhisperTranscription = ({
|
|||
// Stop recording
|
||||
const stopRecording = useCallback(async () => {
|
||||
try {
|
||||
console.log('🛑 Stopping recording...')
|
||||
|
||||
// Clear periodic transcription timer
|
||||
if (periodicTranscriptionRef.current) {
|
||||
|
|
@ -836,6 +915,7 @@ export const useWhisperTranscription = ({
|
|||
isRecordingRef.current = false
|
||||
setIsRecording(false)
|
||||
|
||||
console.log('✅ Recording stopped')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error stopping recording:', error)
|
||||
|
|
@ -845,10 +925,12 @@ export const useWhisperTranscription = ({
|
|||
|
||||
// Pause recording (placeholder for compatibility)
|
||||
const pauseRecording = useCallback(async () => {
|
||||
console.log('⏸️ Pause recording not implemented')
|
||||
}, [])
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = useCallback(() => {
|
||||
console.log('🧹 Cleaning up transcription resources...')
|
||||
|
||||
// Stop recording if active
|
||||
if (isRecordingRef.current) {
|
||||
|
|
@ -876,11 +958,13 @@ export const useWhisperTranscription = ({
|
|||
// Clear chunks
|
||||
audioChunksRef.current = []
|
||||
|
||||
console.log('✅ Cleanup completed')
|
||||
}, [])
|
||||
|
||||
// Convenience functions for compatibility
|
||||
const startTranscription = useCallback(async () => {
|
||||
try {
|
||||
console.log('🎤 Starting transcription...')
|
||||
|
||||
// Reset all transcription state for clean start
|
||||
streamingTranscriptRef.current = ''
|
||||
|
|
@ -903,6 +987,7 @@ export const useWhisperTranscription = ({
|
|||
}
|
||||
|
||||
await startRecording()
|
||||
console.log('✅ Transcription started')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error starting transcription:', error)
|
||||
|
|
@ -912,7 +997,9 @@ export const useWhisperTranscription = ({
|
|||
|
||||
const stopTranscription = useCallback(async () => {
|
||||
try {
|
||||
console.log('🛑 Stopping transcription...')
|
||||
await stopRecording()
|
||||
console.log('✅ Transcription stopped')
|
||||
} catch (error) {
|
||||
console.error('❌ Error stopping transcription:', error)
|
||||
onError?.(error as Error)
|
||||
|
|
@ -921,7 +1008,9 @@ export const useWhisperTranscription = ({
|
|||
|
||||
const pauseTranscription = useCallback(async () => {
|
||||
try {
|
||||
console.log('⏸️ Pausing transcription...')
|
||||
await pauseRecording()
|
||||
console.log('✅ Transcription paused')
|
||||
} catch (error) {
|
||||
console.error('❌ Error pausing transcription:', error)
|
||||
onError?.(error as Error)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,4 @@
|
|||
/**
|
||||
* HoloSphere Service - PLACEHOLDER
|
||||
*
|
||||
* This service previously used the holosphere library (which uses GunDB).
|
||||
* It's now a stub awaiting Nostr integration for decentralized data storage.
|
||||
*
|
||||
* TODO: Integrate with Nostr protocol when Holons.io provides their Nostr-based API
|
||||
*/
|
||||
|
||||
// Feature flag to completely disable Holon functionality
|
||||
// Set to true when ready to re-enable
|
||||
export const HOLON_ENABLED = false
|
||||
|
||||
import HoloSphere from 'holosphere'
|
||||
import * as h3 from 'h3-js'
|
||||
|
||||
export interface HolonData {
|
||||
|
|
@ -38,141 +26,387 @@ export interface HolonConnection {
|
|||
status: 'connected' | 'disconnected' | 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder HoloSphere Service
|
||||
* Returns empty/default values until Nostr integration is available
|
||||
*/
|
||||
export class HoloSphereService {
|
||||
private sphere!: HoloSphere
|
||||
private isInitialized: boolean = false
|
||||
private connections: Map<string, HolonConnection> = new Map()
|
||||
private localCache: Map<string, any> = new Map() // Local-only cache for development
|
||||
private connectionErrorLogged: boolean = false // Track if we've already logged connection errors
|
||||
|
||||
constructor(_appName: string = 'canvas-holons', _strict: boolean = false, _openaiKey?: string) {
|
||||
this.isInitialized = true
|
||||
// Only log if Holon functionality is enabled
|
||||
if (HOLON_ENABLED) {
|
||||
constructor(appName: string = 'canvas-holons', strict: boolean = false, openaiKey?: string) {
|
||||
try {
|
||||
this.sphere = new HoloSphere(appName, strict, openaiKey)
|
||||
this.isInitialized = true
|
||||
console.log('✅ HoloSphere service initialized')
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize HoloSphere:', error)
|
||||
this.isInitialized = false
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
return this.isInitialized
|
||||
if (!this.isInitialized) {
|
||||
console.error('❌ HoloSphere not initialized')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Get a holon for specific coordinates and resolution
|
||||
async getHolon(lat: number, lng: number, resolution: number): Promise<string> {
|
||||
if (!HOLON_ENABLED) return ''
|
||||
if (!this.isInitialized) return ''
|
||||
try {
|
||||
return h3.latLngToCell(lat, lng, resolution)
|
||||
return await this.sphere.getHolon(lat, lng, resolution)
|
||||
} catch (error) {
|
||||
// Silently fail when disabled
|
||||
console.error('❌ Error getting holon:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Store data in local cache (placeholder for Nostr)
|
||||
// Store data in a holon
|
||||
async putData(holon: string, lens: string, data: any): Promise<boolean> {
|
||||
if (!HOLON_ENABLED) return false
|
||||
const key = `${holon}:${lens}`
|
||||
const existing = this.localCache.get(key) || {}
|
||||
this.localCache.set(key, { ...existing, ...data })
|
||||
return true
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
await this.sphere.put(holon, lens, data)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error storing data:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve data from local cache
|
||||
async getData(holon: string, lens: string, _key?: string): Promise<any> {
|
||||
if (!HOLON_ENABLED) return null
|
||||
const cacheKey = `${holon}:${lens}`
|
||||
return this.localCache.get(cacheKey) || null
|
||||
// Retrieve data from a holon
|
||||
async getData(holon: string, lens: string, key?: string): Promise<any> {
|
||||
if (!this.isInitialized) return null
|
||||
try {
|
||||
if (key) {
|
||||
return await this.sphere.get(holon, lens, key)
|
||||
} else {
|
||||
return await this.sphere.getAll(holon, lens)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error retrieving data:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve data with subscription (stub - just returns cached data)
|
||||
async getDataWithWait(holon: string, lens: string, _timeoutMs: number = 5000): Promise<any> {
|
||||
if (!HOLON_ENABLED) return null
|
||||
return this.getData(holon, lens)
|
||||
// Retrieve data with subscription and timeout (better for Gun's async nature)
|
||||
async getDataWithWait(holon: string, lens: string, timeoutMs: number = 5000): Promise<any> {
|
||||
if (!this.isInitialized) {
|
||||
console.log(`⚠️ HoloSphere not initialized for ${lens}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Check for WebSocket connection issues
|
||||
// Note: GunDB connection errors appear in browser console, we can't directly detect them
|
||||
// but we can provide better feedback when no data is received
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
let collectedData: any = {}
|
||||
let subscriptionActive = false
|
||||
|
||||
console.log(`🔍 getDataWithWait: holon=${holon}, lens=${lens}, timeout=${timeoutMs}ms`)
|
||||
|
||||
// Listen for WebSocket errors (they appear in console but we can't catch them directly)
|
||||
// Instead, we'll detect the pattern: subscription never fires + getAll never resolves
|
||||
|
||||
// Set up timeout (increased default to 5 seconds for network sync)
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
const keyCount = Object.keys(collectedData).length
|
||||
const status = subscriptionActive
|
||||
? '(subscription was active)'
|
||||
: '(subscription never fired - possible WebSocket connection issue)'
|
||||
|
||||
console.log(`⏱️ Timeout for lens ${lens}, returning collected data:`, keyCount, 'keys', status)
|
||||
|
||||
// If no data and subscription never fired, it's likely a connection issue
|
||||
// Only log this once to avoid console spam
|
||||
if (keyCount === 0 && !subscriptionActive && !this.connectionErrorLogged) {
|
||||
this.connectionErrorLogged = true
|
||||
console.error(`❌ GunDB Connection Issue: WebSocket to 'wss://gun.holons.io/gun' is failing`)
|
||||
console.error(`💡 This prevents loading data from the Holosphere. Possible causes:`)
|
||||
console.error(` • GunDB server may be down or unreachable`)
|
||||
console.error(` • Network/firewall blocking WebSocket connections`)
|
||||
console.error(` • Check browser console for WebSocket connection errors`)
|
||||
console.error(` • Data will not load until connection is established`)
|
||||
}
|
||||
|
||||
resolve(keyCount > 0 ? collectedData : null)
|
||||
}
|
||||
}, timeoutMs)
|
||||
|
||||
try {
|
||||
// Check if methods exist
|
||||
if (!this.sphere.subscribe) {
|
||||
console.error(`❌ sphere.subscribe does not exist`)
|
||||
}
|
||||
if (!this.sphere.getAll) {
|
||||
console.error(`❌ sphere.getAll does not exist`)
|
||||
}
|
||||
if (!this.sphere.get) {
|
||||
console.error(`❌ sphere.get does not exist`)
|
||||
}
|
||||
|
||||
console.log(`🔧 Attempting to subscribe to ${holon}/${lens}`)
|
||||
|
||||
// Try subscribe if it exists
|
||||
let unsubscribe: (() => void) | undefined = undefined
|
||||
if (this.sphere.subscribe) {
|
||||
try {
|
||||
const subscribeResult = this.sphere.subscribe(holon, lens, (data: any, key?: string) => {
|
||||
subscriptionActive = true
|
||||
console.log(`📥 Subscription callback fired for ${lens}:`, { data, key, dataType: typeof data, isObject: typeof data === 'object', isArray: Array.isArray(data) })
|
||||
|
||||
if (data !== null && data !== undefined) {
|
||||
if (key) {
|
||||
// If we have a key, it's a key-value pair
|
||||
collectedData[key] = data
|
||||
console.log(`📥 Added key-value pair: ${key} =`, data)
|
||||
} else if (typeof data === 'object' && !Array.isArray(data)) {
|
||||
// If it's an object, merge it
|
||||
collectedData = { ...collectedData, ...data }
|
||||
console.log(`📥 Merged object data, total keys:`, Object.keys(collectedData).length)
|
||||
} else if (Array.isArray(data)) {
|
||||
// If it's an array, convert to object with indices
|
||||
data.forEach((item, index) => {
|
||||
collectedData[String(index)] = item
|
||||
})
|
||||
console.log(`📥 Converted array to object, total keys:`, Object.keys(collectedData).length)
|
||||
} else {
|
||||
// Primitive value
|
||||
collectedData['value'] = data
|
||||
console.log(`📥 Added primitive value:`, data)
|
||||
}
|
||||
|
||||
console.log(`📥 Current collected data for ${lens}:`, Object.keys(collectedData).length, 'keys')
|
||||
}
|
||||
})
|
||||
// Handle Promise if subscribe returns one
|
||||
if (subscribeResult instanceof Promise) {
|
||||
subscribeResult.then((result: any) => {
|
||||
unsubscribe = result?.unsubscribe || undefined
|
||||
console.log(`✅ Subscribe called successfully for ${lens}`)
|
||||
}).catch((err) => {
|
||||
console.error(`❌ Error in subscribe promise for ${lens}:`, err)
|
||||
})
|
||||
} else if (subscribeResult && typeof subscribeResult === 'object' && subscribeResult !== null) {
|
||||
const result = subscribeResult as { unsubscribe?: () => void }
|
||||
unsubscribe = result?.unsubscribe || undefined
|
||||
console.log(`✅ Subscribe called successfully for ${lens}`)
|
||||
}
|
||||
} catch (subError) {
|
||||
console.error(`❌ Error calling subscribe for ${lens}:`, subError)
|
||||
}
|
||||
}
|
||||
|
||||
// Try getAll if it exists
|
||||
if (this.sphere.getAll) {
|
||||
console.log(`🔧 Attempting getAll for ${holon}/${lens}`)
|
||||
this.sphere.getAll(holon, lens).then((immediateData: any) => {
|
||||
console.log(`📦 getAll returned for ${lens}:`, {
|
||||
data: immediateData,
|
||||
type: typeof immediateData,
|
||||
isObject: typeof immediateData === 'object',
|
||||
isArray: Array.isArray(immediateData),
|
||||
keys: immediateData && typeof immediateData === 'object' ? Object.keys(immediateData).length : 'N/A'
|
||||
})
|
||||
|
||||
if (immediateData !== null && immediateData !== undefined) {
|
||||
if (typeof immediateData === 'object' && !Array.isArray(immediateData)) {
|
||||
collectedData = { ...collectedData, ...immediateData }
|
||||
console.log(`📦 Merged immediate data, total keys:`, Object.keys(collectedData).length)
|
||||
} else if (Array.isArray(immediateData)) {
|
||||
immediateData.forEach((item, index) => {
|
||||
collectedData[String(index)] = item
|
||||
})
|
||||
console.log(`📦 Converted immediate array to object, total keys:`, Object.keys(collectedData).length)
|
||||
} else {
|
||||
collectedData['value'] = immediateData
|
||||
console.log(`📦 Added immediate primitive value`)
|
||||
}
|
||||
}
|
||||
|
||||
// If we have data immediately, resolve early
|
||||
if (Object.keys(collectedData).length > 0 && !resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
if (unsubscribe) unsubscribe()
|
||||
console.log(`✅ Resolving early with ${Object.keys(collectedData).length} keys for ${lens}`)
|
||||
resolve(collectedData)
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
console.error(`⚠️ Error getting immediate data for ${lens}:`, error)
|
||||
})
|
||||
} else {
|
||||
// Fallback: try using getData method instead
|
||||
console.log(`🔧 getAll not available, trying getData as fallback for ${lens}`)
|
||||
this.getData(holon, lens).then((fallbackData: any) => {
|
||||
console.log(`📦 getData (fallback) returned for ${lens}:`, fallbackData)
|
||||
if (fallbackData !== null && fallbackData !== undefined) {
|
||||
if (typeof fallbackData === 'object' && !Array.isArray(fallbackData)) {
|
||||
collectedData = { ...collectedData, ...fallbackData }
|
||||
} else {
|
||||
collectedData['value'] = fallbackData
|
||||
}
|
||||
if (Object.keys(collectedData).length > 0 && !resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
if (unsubscribe) unsubscribe()
|
||||
console.log(`✅ Resolving with fallback data: ${Object.keys(collectedData).length} keys for ${lens}`)
|
||||
resolve(collectedData)
|
||||
}
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
console.error(`⚠️ Error in fallback getData for ${lens}:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error setting up subscription for ${lens}:`, error)
|
||||
clearTimeout(timeout)
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Delete data from local cache
|
||||
async deleteData(holon: string, lens: string, _key?: string): Promise<boolean> {
|
||||
if (!HOLON_ENABLED) return false
|
||||
const cacheKey = `${holon}:${lens}`
|
||||
this.localCache.delete(cacheKey)
|
||||
return true
|
||||
// Delete data from a holon
|
||||
async deleteData(holon: string, lens: string, key?: string): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
if (key) {
|
||||
await this.sphere.delete(holon, lens, key)
|
||||
} else {
|
||||
await this.sphere.deleteAll(holon, lens)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error deleting data:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Schema methods (stub)
|
||||
async setSchema(_lens: string, _schema: any): Promise<boolean> {
|
||||
if (!HOLON_ENABLED) return false
|
||||
return true
|
||||
// Set schema for data validation
|
||||
async setSchema(lens: string, schema: any): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
await this.sphere.setSchema(lens, schema)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error setting schema:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getSchema(_lens: string): Promise<any> {
|
||||
return null
|
||||
// Get current schema
|
||||
async getSchema(lens: string): Promise<any> {
|
||||
if (!this.isInitialized) return null
|
||||
try {
|
||||
return await this.sphere.getSchema(lens)
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting schema:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to changes (stub - no-op)
|
||||
subscribe(_holon: string, _lens: string, _callback: (data: any) => void): void {
|
||||
// No-op when disabled or in stub mode
|
||||
// Subscribe to changes in a holon
|
||||
subscribe(holon: string, lens: string, callback: (data: any) => void): void {
|
||||
if (!this.isInitialized) return
|
||||
try {
|
||||
this.sphere.subscribe(holon, lens, callback)
|
||||
} catch (error) {
|
||||
console.error('❌ Error subscribing to changes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get holon hierarchy using h3-js
|
||||
// Get holon hierarchy (parent and children)
|
||||
getHolonHierarchy(holon: string): { parent?: string; children: string[] } {
|
||||
if (!HOLON_ENABLED) return { children: [] }
|
||||
try {
|
||||
const resolution = h3.getResolution(holon)
|
||||
const parent = resolution > 0 ? h3.cellToParent(holon, resolution - 1) : undefined
|
||||
const children = h3.cellToChildren(holon, resolution + 1)
|
||||
return { parent, children }
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting holon hierarchy:', error)
|
||||
return { children: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// Get all scales for a holon
|
||||
// Get all scales for a holon (all containing holons)
|
||||
getHolonScalespace(holon: string): string[] {
|
||||
if (!HOLON_ENABLED) return []
|
||||
try {
|
||||
const resolution = h3.getResolution(holon)
|
||||
const scales: string[] = [holon]
|
||||
|
||||
// Get all parent holons up to resolution 0
|
||||
let current = holon
|
||||
for (let r = resolution - 1; r >= 0; r--) {
|
||||
current = h3.cellToParent(current, r)
|
||||
scales.unshift(current)
|
||||
}
|
||||
|
||||
return scales
|
||||
return this.sphere.getHolonScalespace(holon)
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting holon scalespace:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Federation methods (stub)
|
||||
async federate(_spaceId1: string, _spaceId2: string, _password1?: string, _password2?: string, _bidirectional?: boolean): Promise<boolean> {
|
||||
return false
|
||||
// Federation methods
|
||||
async federate(spaceId1: string, spaceId2: string, password1?: string, password2?: string, bidirectional?: boolean): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
await this.sphere.federate(spaceId1, spaceId2, password1, password2, bidirectional)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error federating spaces:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async propagate(_holon: string, _lens: string, _data: any, _options?: { useReferences?: boolean; targetSpaces?: string[] }): Promise<boolean> {
|
||||
return false
|
||||
async propagate(holon: string, lens: string, data: any, options?: { useReferences?: boolean; targetSpaces?: string[] }): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
await this.sphere.propagate(holon, lens, data, options)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error propagating data:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Message federation (stub)
|
||||
async federateMessage(_originalChatId: string, _messageId: string, _federatedChatId: string, _federatedMessageId: string, _type: string): Promise<boolean> {
|
||||
return false
|
||||
// Message federation
|
||||
async federateMessage(originalChatId: string, messageId: string, federatedChatId: string, federatedMessageId: string, type: string): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
await this.sphere.federateMessage(originalChatId, messageId, federatedChatId, federatedMessageId, type)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error federating message:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getFederatedMessages(_originalChatId: string, _messageId: string): Promise<any[]> {
|
||||
return []
|
||||
async getFederatedMessages(originalChatId: string, messageId: string): Promise<any[]> {
|
||||
if (!this.isInitialized) return []
|
||||
try {
|
||||
const result = await this.sphere.getFederatedMessages(originalChatId, messageId)
|
||||
return Array.isArray(result) ? result : []
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting federated messages:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async updateFederatedMessages(_originalChatId: string, _messageId: string, _updateCallback: (chatId: string, messageId: string) => Promise<void>): Promise<boolean> {
|
||||
return false
|
||||
async updateFederatedMessages(originalChatId: string, messageId: string, updateCallback: (chatId: string, messageId: string) => Promise<void>): Promise<boolean> {
|
||||
if (!this.isInitialized) return false
|
||||
try {
|
||||
await this.sphere.updateFederatedMessages(originalChatId, messageId, updateCallback)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating federated messages:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Utility methods for working with resolutions
|
||||
// Utility methods for working with coordinates and resolutions
|
||||
static getResolutionName(resolution: number): string {
|
||||
const names = [
|
||||
'Country', 'State/Province', 'Metropolitan Area', 'City', 'District',
|
||||
'Country', 'State/Province', 'Metropolitan Area', 'City', 'District',
|
||||
'Neighborhood', 'Block', 'Building', 'Room', 'Desk', 'Chair', 'Point'
|
||||
]
|
||||
return names[resolution] || `Level ${resolution}`
|
||||
|
|
@ -196,19 +430,22 @@ export class HoloSphereService {
|
|||
return descriptions[resolution] || `Geographic level ${resolution}`
|
||||
}
|
||||
|
||||
// Connection management
|
||||
// Get connection status
|
||||
getConnectionStatus(spaceId: string): HolonConnection | undefined {
|
||||
return this.connections.get(spaceId)
|
||||
}
|
||||
|
||||
// Add connection
|
||||
addConnection(connection: HolonConnection): void {
|
||||
this.connections.set(connection.id, connection)
|
||||
}
|
||||
|
||||
// Remove connection
|
||||
removeConnection(spaceId: string): boolean {
|
||||
return this.connections.delete(spaceId)
|
||||
}
|
||||
|
||||
// Get all connections
|
||||
getAllConnections(): HolonConnection[] {
|
||||
return Array.from(this.connections.values())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,229 +0,0 @@
|
|||
// Service for per-board activity logging
|
||||
|
||||
export interface ActivityEntry {
|
||||
id: string;
|
||||
action: 'created' | 'deleted' | 'updated';
|
||||
shapeType: string;
|
||||
shapeId: string;
|
||||
user: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface BoardActivity {
|
||||
slug: string;
|
||||
entries: ActivityEntry[];
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 100;
|
||||
|
||||
// Map internal shape types to friendly display names
|
||||
const SHAPE_DISPLAY_NAMES: Record<string, string> = {
|
||||
// Default tldraw shapes
|
||||
'text': 'text',
|
||||
'geo': 'shape',
|
||||
'draw': 'drawing',
|
||||
'arrow': 'arrow',
|
||||
'note': 'sticky note',
|
||||
'image': 'image',
|
||||
'video': 'video',
|
||||
'embed': 'embed',
|
||||
'frame': 'frame',
|
||||
'line': 'line',
|
||||
'highlight': 'highlight',
|
||||
'bookmark': 'bookmark',
|
||||
'group': 'group',
|
||||
// Custom shapes
|
||||
'ChatBox': 'chat box',
|
||||
'VideoChat': 'video chat',
|
||||
'Embed': 'embed',
|
||||
'Markdown': 'markdown note',
|
||||
'Slide': 'slide',
|
||||
'MycrozineTemplate': 'zine template',
|
||||
'MycroZineGenerator': 'zine generator',
|
||||
'Prompt': 'prompt',
|
||||
'ObsNote': 'Obsidian note',
|
||||
'Transcription': 'transcription',
|
||||
'Holon': 'holon',
|
||||
'HolonBrowser': 'holon browser',
|
||||
'ObsidianBrowser': 'Obsidian browser',
|
||||
'FathomMeetingsBrowser': 'Fathom browser',
|
||||
'FathomNote': 'Fathom note',
|
||||
'ImageGen': 'AI image',
|
||||
'VideoGen': 'AI video',
|
||||
'BlenderGen': '3D model',
|
||||
'Drawfast': 'drawfast',
|
||||
'Multmux': 'multmux',
|
||||
'MycelialIntelligence': 'mycelial AI',
|
||||
'PrivateWorkspace': 'private workspace',
|
||||
'GoogleItem': 'Google item',
|
||||
'Map': 'map',
|
||||
'WorkflowBlock': 'workflow block',
|
||||
'Calendar': 'calendar',
|
||||
'CalendarEvent': 'calendar event',
|
||||
};
|
||||
|
||||
// Get action icons
|
||||
const ACTION_ICONS: Record<string, string> = {
|
||||
'created': '+',
|
||||
'deleted': '-',
|
||||
'updated': '~',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the activity log for a board
|
||||
*/
|
||||
export const getActivityLog = (slug: string, limit: number = 50): ActivityEntry[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const data = localStorage.getItem(`board_activity_${slug}`);
|
||||
if (!data) return [];
|
||||
|
||||
const parsed: BoardActivity = JSON.parse(data);
|
||||
return (parsed.entries || []).slice(0, limit);
|
||||
} catch (error) {
|
||||
console.error('Error getting activity log:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Log an activity entry for a board
|
||||
*/
|
||||
export const logActivity = (
|
||||
slug: string,
|
||||
entry: Omit<ActivityEntry, 'id' | 'timestamp'>
|
||||
): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const entries = getActivityLog(slug, MAX_ENTRIES - 1);
|
||||
|
||||
const newEntry: ActivityEntry = {
|
||||
...entry,
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Add new entry at the beginning
|
||||
entries.unshift(newEntry);
|
||||
|
||||
// Prune to max size
|
||||
const prunedEntries = entries.slice(0, MAX_ENTRIES);
|
||||
|
||||
const data: BoardActivity = {
|
||||
slug,
|
||||
entries: prunedEntries,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
localStorage.setItem(`board_activity_${slug}`, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error('Error logging activity:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all activity for a board
|
||||
*/
|
||||
export const clearActivityLog = (slug: string): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.removeItem(`board_activity_${slug}`);
|
||||
} catch (error) {
|
||||
console.error('Error clearing activity log:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get display name for a shape type
|
||||
*/
|
||||
export const getShapeDisplayName = (shapeType: string): string => {
|
||||
return SHAPE_DISPLAY_NAMES[shapeType] || shapeType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get icon for an action
|
||||
*/
|
||||
export const getActionIcon = (action: string): string => {
|
||||
return ACTION_ICONS[action] || '?';
|
||||
};
|
||||
|
||||
/**
|
||||
* Format timestamp as relative time
|
||||
*/
|
||||
export const formatActivityTime = (timestamp: number): string => {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) {
|
||||
return 'Just now';
|
||||
} else if (minutes < 60) {
|
||||
return `${minutes}m ago`;
|
||||
} else if (hours < 24) {
|
||||
return `${hours}h ago`;
|
||||
} else if (days === 1) {
|
||||
return 'Yesterday';
|
||||
} else if (days < 7) {
|
||||
return `${days}d ago`;
|
||||
} else {
|
||||
return new Date(timestamp).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format an activity entry as a human-readable string
|
||||
*/
|
||||
export const formatActivityEntry = (entry: ActivityEntry): string => {
|
||||
const shapeName = getShapeDisplayName(entry.shapeType);
|
||||
const action = entry.action === 'created' ? 'added' :
|
||||
entry.action === 'deleted' ? 'deleted' :
|
||||
'updated';
|
||||
|
||||
return `${entry.user} ${action} ${shapeName}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Group activity entries by date
|
||||
*/
|
||||
export const groupActivitiesByDate = (entries: ActivityEntry[]): Map<string, ActivityEntry[]> => {
|
||||
const groups = new Map<string, ActivityEntry[]>();
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryDate = new Date(entry.timestamp);
|
||||
entryDate.setHours(0, 0, 0, 0);
|
||||
|
||||
let groupKey: string;
|
||||
if (entryDate.getTime() === today.getTime()) {
|
||||
groupKey = 'Today';
|
||||
} else if (entryDate.getTime() === yesterday.getTime()) {
|
||||
groupKey = 'Yesterday';
|
||||
} else {
|
||||
groupKey = entryDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
if (!groups.has(groupKey)) {
|
||||
groups.set(groupKey, []);
|
||||
}
|
||||
groups.get(groupKey)!.push(entry);
|
||||
}
|
||||
|
||||
return groups;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue