Compare commits
300 Commits
main
...
web3-integ
| Author | SHA1 | Date |
|---|---|---|
|
|
667a6a780e | |
|
|
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 |
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Frontend (VITE) Public Variables
|
||||||
|
VITE_GOOGLE_CLIENT_ID='your_google_client_id'
|
||||||
|
VITE_GOOGLE_MAPS_API_KEY='your_google_maps_api_key'
|
||||||
|
VITE_DAILY_DOMAIN='your_daily_domain'
|
||||||
|
VITE_TLDRAW_WORKER_URL='your_worker_url'
|
||||||
|
|
||||||
|
# Worker-only Variables (Do not prefix with VITE_)
|
||||||
|
CLOUDFLARE_API_TOKEN='your_cloudflare_token'
|
||||||
|
CLOUDFLARE_ACCOUNT_ID='your_account_id'
|
||||||
|
CLOUDFLARE_ZONE_ID='your_zone_id'
|
||||||
|
R2_BUCKET_NAME='your_bucket_name'
|
||||||
|
R2_PREVIEW_BUCKET_NAME='your_preview_bucket_name'
|
||||||
|
DAILY_API_KEY=your_daily_api_key_here
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
*.pdf filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.mov filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
name: Deploy Worker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main # Production deployment
|
||||||
|
- 'automerge/**' # Dev deployment for automerge branches (matches automerge/*, automerge/**/*, etc.)
|
||||||
|
workflow_dispatch: # Allows manual triggering from GitHub UI
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: 'Environment to deploy to'
|
||||||
|
required: true
|
||||||
|
default: 'dev'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- dev
|
||||||
|
- production
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Deploy Worker
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Determine Environment
|
||||||
|
id: env
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||||
|
echo "environment=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT
|
||||||
|
elif [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||||
|
echo "environment=production" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "environment=dev" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Workers (Production)
|
||||||
|
if: steps.env.outputs.environment == 'production'
|
||||||
|
run: |
|
||||||
|
npm install -g wrangler@latest
|
||||||
|
# Uses default wrangler.toml (production config) from root directory
|
||||||
|
wrangler deploy
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Workers (Dev)
|
||||||
|
if: steps.env.outputs.environment == 'dev'
|
||||||
|
run: |
|
||||||
|
npm install -g wrangler@latest
|
||||||
|
# Uses wrangler.dev.toml for dev environment
|
||||||
|
wrangler deploy --config wrangler.dev.toml
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# DISABLED: This workflow is preserved for future use in another repository
|
||||||
|
# To re-enable: Remove the `if: false` condition below
|
||||||
|
# This workflow syncs notes to a Quartz static site (separate from the canvas website)
|
||||||
|
|
||||||
|
name: Quartz Sync
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'content/**'
|
||||||
|
- 'src/lib/quartzSync.ts'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
note_id:
|
||||||
|
description: 'Specific note ID to sync'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-quartz:
|
||||||
|
# DISABLED: Set to false to prevent this workflow from running
|
||||||
|
if: false
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build Quartz
|
||||||
|
run: |
|
||||||
|
npx quartz build
|
||||||
|
env:
|
||||||
|
QUARTZ_PUBLISH: true
|
||||||
|
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
publish_dir: ./public
|
||||||
|
cname: ${{ secrets.QUARTZ_DOMAIN }}
|
||||||
|
|
||||||
|
- name: Notify sync completion
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "Quartz sync completed at $(date)"
|
||||||
|
echo "Triggered by: ${{ github.event_name }}"
|
||||||
|
echo "Commit: ${{ github.sha }}"
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
npm-debug.log_
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
|
||||||
|
pids
|
||||||
|
_.pid
|
||||||
|
_.seed
|
||||||
|
\*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
|
||||||
|
coverage
|
||||||
|
\*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
|
||||||
|
\*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
|
||||||
|
\*.tgz
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
.wrangler/
|
||||||
|
|
||||||
|
# Vercel
|
||||||
|
.vercel/
|
||||||
|
.dev.vars
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env*
|
||||||
|
.env.development
|
||||||
|
!.env.example
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.dev.vars
|
||||||
|
.env.production
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
legacy-peer-deps=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
auto-install-peers=true
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
const urls = new Set();
|
||||||
|
|
||||||
|
function checkURL(request, init) {
|
||||||
|
const url =
|
||||||
|
request instanceof URL
|
||||||
|
? request
|
||||||
|
: new URL(
|
||||||
|
(typeof request === "string"
|
||||||
|
? new Request(request, init)
|
||||||
|
: request
|
||||||
|
).url
|
||||||
|
);
|
||||||
|
if (url.port && url.port !== "443" && url.protocol === "https:") {
|
||||||
|
if (!urls.has(url.toString())) {
|
||||||
|
urls.add(url.toString());
|
||||||
|
console.warn(
|
||||||
|
`WARNING: known issue with \`fetch()\` requests to custom HTTPS ports in published Workers:\n` +
|
||||||
|
` - ${url.toString()} - the custom port will be ignored when the Worker is published using the \`wrangler deploy\` command.\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.fetch = new Proxy(globalThis.fetch, {
|
||||||
|
apply(target, thisArg, argArray) {
|
||||||
|
const [request, init] = argArray;
|
||||||
|
checkURL(request, init);
|
||||||
|
return Reflect.apply(target, thisArg, argArray);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import worker, * as OTHER_EXPORTS from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\worker\\worker.ts";
|
||||||
|
import * as __MIDDLEWARE_0__ from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\node_modules\\wrangler\\templates\\middleware\\middleware-ensure-req-body-drained.ts";
|
||||||
|
import * as __MIDDLEWARE_1__ from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\node_modules\\wrangler\\templates\\middleware\\middleware-miniflare3-json-error.ts";
|
||||||
|
|
||||||
|
export * from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\worker\\worker.ts";
|
||||||
|
|
||||||
|
export const __INTERNAL_WRANGLER_MIDDLEWARE__ = [
|
||||||
|
|
||||||
|
__MIDDLEWARE_0__.default,__MIDDLEWARE_1__.default
|
||||||
|
]
|
||||||
|
export default worker;
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
// This loads all middlewares exposed on the middleware object and then starts
|
||||||
|
// the invocation chain. The big idea is that we can add these to the middleware
|
||||||
|
// export dynamically through wrangler, or we can potentially let users directly
|
||||||
|
// add them as a sort of "plugin" system.
|
||||||
|
|
||||||
|
import ENTRY, { __INTERNAL_WRANGLER_MIDDLEWARE__ } from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\.wrangler\\tmp\\bundle-VlWfGj\\middleware-insertion-facade.js";
|
||||||
|
import { __facade_invoke__, __facade_register__, Dispatcher } from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\node_modules\\wrangler\\templates\\middleware\\common.ts";
|
||||||
|
import type { WorkerEntrypointConstructor } from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\.wrangler\\tmp\\bundle-VlWfGj\\middleware-insertion-facade.js";
|
||||||
|
|
||||||
|
// Preserve all the exports from the worker
|
||||||
|
export * from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\.wrangler\\tmp\\bundle-VlWfGj\\middleware-insertion-facade.js";
|
||||||
|
|
||||||
|
class __Facade_ScheduledController__ implements ScheduledController {
|
||||||
|
readonly #noRetry: ScheduledController["noRetry"];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly scheduledTime: number,
|
||||||
|
readonly cron: string,
|
||||||
|
noRetry: ScheduledController["noRetry"]
|
||||||
|
) {
|
||||||
|
this.#noRetry = noRetry;
|
||||||
|
}
|
||||||
|
|
||||||
|
noRetry() {
|
||||||
|
if (!(this instanceof __Facade_ScheduledController__)) {
|
||||||
|
throw new TypeError("Illegal invocation");
|
||||||
|
}
|
||||||
|
// Need to call native method immediately in case uncaught error thrown
|
||||||
|
this.#noRetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapExportedHandler(worker: ExportedHandler): ExportedHandler {
|
||||||
|
// If we don't have any middleware defined, just return the handler as is
|
||||||
|
if (
|
||||||
|
__INTERNAL_WRANGLER_MIDDLEWARE__ === undefined ||
|
||||||
|
__INTERNAL_WRANGLER_MIDDLEWARE__.length === 0
|
||||||
|
) {
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
// Otherwise, register all middleware once
|
||||||
|
for (const middleware of __INTERNAL_WRANGLER_MIDDLEWARE__) {
|
||||||
|
__facade_register__(middleware);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDispatcher: ExportedHandlerFetchHandler = function (
|
||||||
|
request,
|
||||||
|
env,
|
||||||
|
ctx
|
||||||
|
) {
|
||||||
|
if (worker.fetch === undefined) {
|
||||||
|
throw new Error("Handler does not export a fetch() function.");
|
||||||
|
}
|
||||||
|
return worker.fetch(request, env, ctx);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...worker,
|
||||||
|
fetch(request, env, ctx) {
|
||||||
|
const dispatcher: Dispatcher = function (type, init) {
|
||||||
|
if (type === "scheduled" && worker.scheduled !== undefined) {
|
||||||
|
const controller = new __Facade_ScheduledController__(
|
||||||
|
Date.now(),
|
||||||
|
init.cron ?? "",
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
return worker.scheduled(controller, env, ctx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return __facade_invoke__(request, env, ctx, dispatcher, fetchDispatcher);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapWorkerEntrypoint(
|
||||||
|
klass: WorkerEntrypointConstructor
|
||||||
|
): WorkerEntrypointConstructor {
|
||||||
|
// If we don't have any middleware defined, just return the handler as is
|
||||||
|
if (
|
||||||
|
__INTERNAL_WRANGLER_MIDDLEWARE__ === undefined ||
|
||||||
|
__INTERNAL_WRANGLER_MIDDLEWARE__.length === 0
|
||||||
|
) {
|
||||||
|
return klass;
|
||||||
|
}
|
||||||
|
// Otherwise, register all middleware once
|
||||||
|
for (const middleware of __INTERNAL_WRANGLER_MIDDLEWARE__) {
|
||||||
|
__facade_register__(middleware);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `extend`ing `klass` here so other RPC methods remain callable
|
||||||
|
return class extends klass {
|
||||||
|
#fetchDispatcher: ExportedHandlerFetchHandler<Record<string, unknown>> = (
|
||||||
|
request,
|
||||||
|
env,
|
||||||
|
ctx
|
||||||
|
) => {
|
||||||
|
this.env = env;
|
||||||
|
this.ctx = ctx;
|
||||||
|
if (super.fetch === undefined) {
|
||||||
|
throw new Error("Entrypoint class does not define a fetch() function.");
|
||||||
|
}
|
||||||
|
return super.fetch(request);
|
||||||
|
};
|
||||||
|
|
||||||
|
#dispatcher: Dispatcher = (type, init) => {
|
||||||
|
if (type === "scheduled" && super.scheduled !== undefined) {
|
||||||
|
const controller = new __Facade_ScheduledController__(
|
||||||
|
Date.now(),
|
||||||
|
init.cron ?? "",
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
return super.scheduled(controller);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(request: Request<unknown, IncomingRequestCfProperties>) {
|
||||||
|
return __facade_invoke__(
|
||||||
|
request,
|
||||||
|
this.env,
|
||||||
|
this.ctx,
|
||||||
|
this.#dispatcher,
|
||||||
|
this.#fetchDispatcher
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let WRAPPED_ENTRY: ExportedHandler | WorkerEntrypointConstructor | undefined;
|
||||||
|
if (typeof ENTRY === "object") {
|
||||||
|
WRAPPED_ENTRY = wrapExportedHandler(ENTRY);
|
||||||
|
} else if (typeof ENTRY === "function") {
|
||||||
|
WRAPPED_ENTRY = wrapWorkerEntrypoint(ENTRY);
|
||||||
|
}
|
||||||
|
export default WRAPPED_ENTRY;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,168 @@
|
||||||
|
# Migrating from Vercel to Cloudflare Pages
|
||||||
|
|
||||||
|
This guide will help you migrate your site from Vercel to Cloudflare Pages.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Current Setup:**
|
||||||
|
- ✅ Frontend: Vercel (static site)
|
||||||
|
- ✅ Backend: Cloudflare Worker (`jeffemmett-canvas.jeffemmett.workers.dev`)
|
||||||
|
|
||||||
|
**Target Setup:**
|
||||||
|
- ✅ Frontend: Cloudflare Pages (`canvas-website.pages.dev`)
|
||||||
|
- ✅ Backend: Cloudflare Worker (unchanged)
|
||||||
|
|
||||||
|
## Step 1: Configure Cloudflare Pages
|
||||||
|
|
||||||
|
### In Cloudflare Dashboard:
|
||||||
|
|
||||||
|
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/)
|
||||||
|
2. Navigate to **Pages** → **Create a project**
|
||||||
|
3. Connect your GitHub repository: `Jeff-Emmett/canvas-website`
|
||||||
|
4. Configure build settings:
|
||||||
|
- **Project name**: `canvas-website` (or your preferred name)
|
||||||
|
- **Production branch**: `main`
|
||||||
|
- **Build command**: `npm run build`
|
||||||
|
- **Build output directory**: `dist`
|
||||||
|
- **Root directory**: `/` (leave empty)
|
||||||
|
|
||||||
|
5. Click **Save and Deploy**
|
||||||
|
|
||||||
|
## Step 2: Configure Environment Variables
|
||||||
|
|
||||||
|
### In Cloudflare Pages Dashboard:
|
||||||
|
|
||||||
|
1. Go to your Pages project → **Settings** → **Environment variables**
|
||||||
|
2. Add all your `VITE_*` environment variables from Vercel:
|
||||||
|
|
||||||
|
**Required variables** (if you use them):
|
||||||
|
```
|
||||||
|
VITE_WORKER_ENV=production
|
||||||
|
VITE_GITHUB_TOKEN=...
|
||||||
|
VITE_QUARTZ_REPO=...
|
||||||
|
VITE_QUARTZ_BRANCH=...
|
||||||
|
VITE_CLOUDFLARE_API_KEY=...
|
||||||
|
VITE_CLOUDFLARE_ACCOUNT_ID=...
|
||||||
|
VITE_QUARTZ_API_URL=...
|
||||||
|
VITE_QUARTZ_API_KEY=...
|
||||||
|
VITE_DAILY_API_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Only add variables that start with `VITE_` (these are exposed to the browser)
|
||||||
|
|
||||||
|
3. Set different values for **Production** and **Preview** environments if needed
|
||||||
|
|
||||||
|
## Step 3: Configure Custom Domain (Optional)
|
||||||
|
|
||||||
|
If you have a custom domain:
|
||||||
|
|
||||||
|
1. Go to **Pages** → Your project → **Custom domains**
|
||||||
|
2. Click **Set up a custom domain**
|
||||||
|
3. Add your domain (e.g., `jeffemmett.com`)
|
||||||
|
4. Follow DNS instructions to add the CNAME record
|
||||||
|
|
||||||
|
## Step 4: Verify Routing
|
||||||
|
|
||||||
|
The `_redirects` file has been created to handle SPA routing. This replaces the `rewrites` from `vercel.json`.
|
||||||
|
|
||||||
|
**Routes configured:**
|
||||||
|
- `/board/*` → serves `index.html`
|
||||||
|
- `/inbox` → serves `index.html`
|
||||||
|
- `/contact` → serves `index.html`
|
||||||
|
- `/presentations` → serves `index.html`
|
||||||
|
- `/dashboard` → serves `index.html`
|
||||||
|
- All other routes → serves `index.html` (SPA fallback)
|
||||||
|
|
||||||
|
## Step 5: Update Worker URL for Production
|
||||||
|
|
||||||
|
Make sure your production environment uses the production worker:
|
||||||
|
|
||||||
|
1. In Cloudflare Pages → **Settings** → **Environment variables**
|
||||||
|
2. Set `VITE_WORKER_ENV=production` for **Production** environment
|
||||||
|
3. This will make the frontend connect to: `https://jeffemmett-canvas.jeffemmett.workers.dev`
|
||||||
|
|
||||||
|
## Step 6: Test the Deployment
|
||||||
|
|
||||||
|
1. After the first deployment completes, visit your Pages URL
|
||||||
|
2. Test all routes:
|
||||||
|
- `/board`
|
||||||
|
- `/inbox`
|
||||||
|
- `/contact`
|
||||||
|
- `/presentations`
|
||||||
|
- `/dashboard`
|
||||||
|
3. Verify the canvas app connects to the Worker
|
||||||
|
4. Test real-time collaboration features
|
||||||
|
|
||||||
|
## Step 7: Update DNS (If Using Custom Domain)
|
||||||
|
|
||||||
|
If you're using a custom domain:
|
||||||
|
|
||||||
|
1. Update your DNS records to point to Cloudflare Pages
|
||||||
|
2. Remove Vercel DNS records
|
||||||
|
3. Wait for DNS propagation (can take up to 48 hours)
|
||||||
|
|
||||||
|
## Step 8: Disable Vercel Deployment (Optional)
|
||||||
|
|
||||||
|
Once everything is working on Cloudflare Pages:
|
||||||
|
|
||||||
|
1. Go to Vercel Dashboard
|
||||||
|
2. Navigate to your project → **Settings** → **Git**
|
||||||
|
3. Disconnect the repository or delete the project
|
||||||
|
|
||||||
|
## Differences from Vercel
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
- **Vercel**: Configured in `vercel.json`
|
||||||
|
- **Cloudflare Pages**: Configured in `_headers` file (if needed) or via Cloudflare dashboard
|
||||||
|
|
||||||
|
### Redirects/Rewrites
|
||||||
|
- **Vercel**: Configured in `vercel.json` → `rewrites`
|
||||||
|
- **Cloudflare Pages**: Configured in `_redirects` file ✅ (already created)
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
- **Vercel**: Set in Vercel dashboard
|
||||||
|
- **Cloudflare Pages**: Set in Cloudflare Pages dashboard (same process)
|
||||||
|
|
||||||
|
### Build Settings
|
||||||
|
- **Vercel**: Auto-detected from `vercel.json`
|
||||||
|
- **Cloudflare Pages**: Configured in dashboard (already set above)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: Routes return 404
|
||||||
|
**Solution**: Make sure `_redirects` file is in the `dist` folder after build, or configure it in Cloudflare Pages dashboard
|
||||||
|
|
||||||
|
### Issue: Environment variables not working
|
||||||
|
**Solution**:
|
||||||
|
- Make sure variables start with `VITE_`
|
||||||
|
- Rebuild after adding variables
|
||||||
|
- Check browser console for errors
|
||||||
|
|
||||||
|
### Issue: Worker connection fails
|
||||||
|
**Solution**:
|
||||||
|
- Verify `VITE_WORKER_ENV=production` is set
|
||||||
|
- Check Worker is deployed and accessible
|
||||||
|
- Check CORS settings in Worker
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- ✅ Created `_redirects` file (replaces `vercel.json` rewrites)
|
||||||
|
- ✅ Created this migration guide
|
||||||
|
- ⚠️ `vercel.json` can be kept for reference or removed
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ Configure Cloudflare Pages project
|
||||||
|
2. ✅ Add environment variables
|
||||||
|
3. ✅ Test deployment
|
||||||
|
4. ⏳ Update DNS (if using custom domain)
|
||||||
|
5. ⏳ Disable Vercel (once confirmed working)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you encounter issues:
|
||||||
|
- Check Cloudflare Pages build logs
|
||||||
|
- Check browser console for errors
|
||||||
|
- Verify Worker is accessible
|
||||||
|
- Check environment variables are set correctly
|
||||||
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Cloudflare Pages Configuration
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
Cloudflare Pages cannot use the same `wrangler.toml` file as Workers because:
|
||||||
|
- `wrangler.toml` contains Worker-specific configuration (main, account_id, triggers, etc.)
|
||||||
|
- Pages projects have different configuration requirements
|
||||||
|
- Pages cannot have both `main` and `pages_build_output_dir` in the same file
|
||||||
|
|
||||||
|
## Solution: Configure in Cloudflare Dashboard
|
||||||
|
|
||||||
|
Since `wrangler.toml` is for Workers only, configure Pages settings in the Cloudflare Dashboard:
|
||||||
|
|
||||||
|
### Steps:
|
||||||
|
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/)
|
||||||
|
2. Navigate to **Pages** → Your Project
|
||||||
|
3. Go to **Settings** → **Builds & deployments**
|
||||||
|
4. Configure:
|
||||||
|
- **Build command**: `npm run build`
|
||||||
|
- **Build output directory**: `dist`
|
||||||
|
- **Root directory**: `/` (or leave empty)
|
||||||
|
5. Save settings
|
||||||
|
|
||||||
|
### Alternative: Use Environment Variables
|
||||||
|
If you need to configure Pages via code, you can set environment variables in the Cloudflare Pages dashboard under **Settings** → **Environment variables**.
|
||||||
|
|
||||||
|
## Worker Deployment
|
||||||
|
Workers are deployed separately using:
|
||||||
|
```bash
|
||||||
|
npm run deploy:worker
|
||||||
|
```
|
||||||
|
or
|
||||||
|
```bash
|
||||||
|
wrangler deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
The `wrangler.toml` file is used only for Worker deployments, not Pages.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Cloudflare Worker Native Deployment Setup
|
||||||
|
|
||||||
|
This guide explains how to set up Cloudflare's native Git integration for automatic worker deployments.
|
||||||
|
|
||||||
|
## Quick Setup Steps
|
||||||
|
|
||||||
|
### 1. Enable Git Integration in Cloudflare Dashboard
|
||||||
|
|
||||||
|
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/)
|
||||||
|
2. Navigate to **Workers & Pages** → **jeffemmett-canvas**
|
||||||
|
3. Go to **Settings** → **Builds & Deployments**
|
||||||
|
4. Click **"Connect to Git"** or **"Set up Git integration"**
|
||||||
|
5. Authorize Cloudflare to access your GitHub repository
|
||||||
|
6. Select your repository: `Jeff-Emmett/canvas-website`
|
||||||
|
7. Configure:
|
||||||
|
- **Production branch**: `main`
|
||||||
|
- **Build command**: Leave empty (wrangler automatically detects and builds from `wrangler.toml`)
|
||||||
|
- **Root directory**: `/` (or leave empty)
|
||||||
|
|
||||||
|
### 2. Configure Build Settings
|
||||||
|
|
||||||
|
Cloudflare will automatically:
|
||||||
|
- Detect `wrangler.toml` in the root directory
|
||||||
|
- Build and deploy the worker on every push to `main`
|
||||||
|
- Show build status in GitHub (commit statuses, PR comments)
|
||||||
|
|
||||||
|
### 3. Environment Variables
|
||||||
|
|
||||||
|
Set environment variables in Cloudflare Dashboard:
|
||||||
|
1. Go to **Workers & Pages** → **jeffemmett-canvas** → **Settings** → **Variables**
|
||||||
|
2. Add any required environment variables
|
||||||
|
3. These are separate from `wrangler.toml` (which should only have non-sensitive config)
|
||||||
|
|
||||||
|
### 4. Verify Deployment
|
||||||
|
|
||||||
|
After setup:
|
||||||
|
1. Push a commit to `main` branch
|
||||||
|
2. Check Cloudflare Dashboard → **Workers & Pages** → **jeffemmett-canvas** → **Deployments**
|
||||||
|
3. You should see a new deployment triggered by the Git push
|
||||||
|
4. Check GitHub commit status - you should see Cloudflare build status
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
- **On push to `main`**: Automatically deploys to production using `wrangler.toml`
|
||||||
|
- **On pull request**: Can optionally deploy to preview environment
|
||||||
|
- **Build status**: Appears in GitHub as commit status and PR comments
|
||||||
|
- **Deployments**: All visible in Cloudflare Dashboard
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Production (main branch)
|
||||||
|
- Uses `wrangler.toml` from root directory
|
||||||
|
- Worker name: `jeffemmett-canvas`
|
||||||
|
- R2 buckets: `jeffemmett-canvas`, `board-backups`
|
||||||
|
|
||||||
|
### Development/Preview
|
||||||
|
- For dev environment, you can:
|
||||||
|
- Use a separate worker with `wrangler.dev.toml` (requires manual deployment)
|
||||||
|
- Or configure preview deployments in Cloudflare dashboard
|
||||||
|
- Or use the deprecated GitHub Action (see `.github/workflows/deploy-worker.yml.disabled`)
|
||||||
|
|
||||||
|
## Manual Deployment (if needed)
|
||||||
|
|
||||||
|
If you need to deploy manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production
|
||||||
|
npm run deploy:worker
|
||||||
|
# or
|
||||||
|
wrangler deploy
|
||||||
|
|
||||||
|
# Development
|
||||||
|
npm run deploy:worker:dev
|
||||||
|
# or
|
||||||
|
wrangler deploy --config wrangler.dev.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Build fails
|
||||||
|
- Check Cloudflare Dashboard → Deployments → View logs
|
||||||
|
- Ensure `wrangler.toml` is in root directory
|
||||||
|
- Verify all required environment variables are set in Cloudflare dashboard
|
||||||
|
|
||||||
|
### Not deploying automatically
|
||||||
|
- Verify Git integration is connected in Cloudflare dashboard
|
||||||
|
- Check that "Automatically deploy from Git" is enabled
|
||||||
|
- Ensure you're pushing to the configured branch (`main`)
|
||||||
|
|
||||||
|
### Need to revert to GitHub Actions
|
||||||
|
- Rename `.github/workflows/deploy-worker.yml.disabled` back to `deploy-worker.yml`
|
||||||
|
- Disable Git integration in Cloudflare dashboard
|
||||||
|
|
||||||
|
## Benefits of Native Deployment
|
||||||
|
|
||||||
|
✅ **Simpler**: No workflow files to maintain
|
||||||
|
✅ **Integrated**: Build status in GitHub
|
||||||
|
✅ **Automatic**: Resource provisioning (KV, R2, Durable Objects)
|
||||||
|
✅ **Free**: No GitHub Actions minutes usage
|
||||||
|
✅ **Visible**: All deployments in Cloudflare dashboard
|
||||||
|
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
# Data Conversion Guide: TLDraw Sync to Automerge Sync
|
||||||
|
|
||||||
|
This guide explains the data conversion process from the old TLDraw sync format to the new Automerge sync format, and how to verify the conversion is working correctly.
|
||||||
|
|
||||||
|
## Data Format Changes
|
||||||
|
|
||||||
|
### Old Format (TLDraw Sync)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"documents": [
|
||||||
|
{ "state": { "id": "shape:abc123", "typeName": "shape", ... } },
|
||||||
|
{ "state": { "id": "page:page", "typeName": "page", ... } }
|
||||||
|
],
|
||||||
|
"schema": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Format (Automerge Sync)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"store": {
|
||||||
|
"shape:abc123": { "id": "shape:abc123", "typeName": "shape", ... },
|
||||||
|
"page:page": { "id": "page:page", "typeName": "page", ... }
|
||||||
|
},
|
||||||
|
"schema": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conversion Process
|
||||||
|
|
||||||
|
The conversion happens automatically when a document is loaded from R2. The `AutomergeDurableObject.getDocument()` method detects the format and converts it:
|
||||||
|
|
||||||
|
1. **Automerge Array Format**: Detected by `Array.isArray(rawDoc)`
|
||||||
|
- Converts via `convertAutomergeToStore()`
|
||||||
|
- Extracts `record.state` and uses it as the store record
|
||||||
|
|
||||||
|
2. **Store Format**: Detected by `rawDoc.store` existing
|
||||||
|
- Already in correct format, uses as-is
|
||||||
|
- No conversion needed
|
||||||
|
|
||||||
|
3. **Old Documents Format**: Detected by `rawDoc.documents` existing but no `store`
|
||||||
|
- Converts via `migrateDocumentsToStore()`
|
||||||
|
- Maps `doc.state.id` to `store[doc.state.id] = doc.state`
|
||||||
|
|
||||||
|
4. **Shape Property Migration**: After format conversion, all shapes are migrated via `migrateShapeProperties()`
|
||||||
|
- Ensures required properties exist (x, y, rotation, isLocked, opacity, meta, index)
|
||||||
|
- Moves `w`/`h` from top-level to `props` for geo shapes
|
||||||
|
- Fixes richText structure
|
||||||
|
- Preserves custom shape properties
|
||||||
|
|
||||||
|
## Validation & Error Handling
|
||||||
|
|
||||||
|
The conversion functions now include comprehensive validation:
|
||||||
|
|
||||||
|
- **Missing state.id**: Skipped with warning
|
||||||
|
- **Missing state.typeName**: Skipped with warning
|
||||||
|
- **Null/undefined records**: Skipped with warning
|
||||||
|
- **Invalid ID types**: Skipped with warning
|
||||||
|
- **Malformed shapes**: Fixed during shape migration
|
||||||
|
|
||||||
|
All validation errors are logged with detailed statistics.
|
||||||
|
|
||||||
|
## Custom Records
|
||||||
|
|
||||||
|
Custom record types (like `obsidian_vault:`) are preserved during conversion:
|
||||||
|
- Tracked during conversion
|
||||||
|
- Verified in logs
|
||||||
|
- Preserved in the final store
|
||||||
|
|
||||||
|
## Custom Shapes
|
||||||
|
|
||||||
|
Custom shape types are preserved:
|
||||||
|
- ObsNote
|
||||||
|
- Holon
|
||||||
|
- FathomMeetingsBrowser
|
||||||
|
- FathomTranscript
|
||||||
|
- HolonBrowser
|
||||||
|
- LocationShare
|
||||||
|
- ObsidianBrowser
|
||||||
|
|
||||||
|
All custom shape properties are preserved during migration.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
The conversion process logs comprehensive statistics:
|
||||||
|
|
||||||
|
```
|
||||||
|
📊 Automerge to Store conversion statistics:
|
||||||
|
- total: Number of records processed
|
||||||
|
- converted: Number successfully converted
|
||||||
|
- skipped: Number skipped (invalid)
|
||||||
|
- errors: Number of errors
|
||||||
|
- customRecordCount: Number of custom records
|
||||||
|
- errorCount: Number of error details
|
||||||
|
```
|
||||||
|
|
||||||
|
Similar statistics are logged for:
|
||||||
|
- Documents to Store migration
|
||||||
|
- Shape property migration
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Edge Cases
|
||||||
|
|
||||||
|
Run the test script to verify edge case handling:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx test-data-conversion.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This tests:
|
||||||
|
- Missing state.id
|
||||||
|
- Missing state.typeName
|
||||||
|
- Null/undefined records
|
||||||
|
- Missing state property
|
||||||
|
- Invalid ID types
|
||||||
|
- Custom records
|
||||||
|
- Malformed shapes
|
||||||
|
- Empty documents
|
||||||
|
- Mixed valid/invalid records
|
||||||
|
|
||||||
|
### Test with Real R2 Data
|
||||||
|
|
||||||
|
To test with actual R2 data:
|
||||||
|
|
||||||
|
1. **Check Worker Logs**: When a document is loaded, check the Cloudflare Worker logs for conversion statistics
|
||||||
|
2. **Verify Data Integrity**: After conversion, verify:
|
||||||
|
- All shapes appear correctly
|
||||||
|
- All properties are preserved
|
||||||
|
- No validation errors in TLDraw
|
||||||
|
- Custom records are present
|
||||||
|
- Custom shapes work correctly
|
||||||
|
|
||||||
|
3. **Monitor Conversion**: Watch for:
|
||||||
|
- High skip counts (may indicate data issues)
|
||||||
|
- Errors during conversion
|
||||||
|
- Missing custom records
|
||||||
|
- Shape migration issues
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
- [x] Format detection (Automerge array, store format, old documents format)
|
||||||
|
- [x] Validation for malformed records
|
||||||
|
- [x] Error handling and logging
|
||||||
|
- [x] Custom record preservation
|
||||||
|
- [x] Custom shape preservation
|
||||||
|
- [x] Shape property migration
|
||||||
|
- [x] Comprehensive logging
|
||||||
|
- [x] Edge case testing
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### High Skip Counts
|
||||||
|
If many records are being skipped:
|
||||||
|
1. Check error details in logs
|
||||||
|
2. Verify data format in R2
|
||||||
|
3. Check for missing required fields
|
||||||
|
|
||||||
|
### Missing Custom Records
|
||||||
|
If custom records are missing:
|
||||||
|
1. Check logs for custom record count
|
||||||
|
2. Verify records start with expected prefix (e.g., `obsidian_vault:`)
|
||||||
|
3. Check if records were filtered during conversion
|
||||||
|
|
||||||
|
### Shape Validation Errors
|
||||||
|
If shapes have validation errors:
|
||||||
|
1. Check shape migration logs
|
||||||
|
2. Verify required properties are present
|
||||||
|
3. Check for w/h in wrong location (should be in props for geo shapes)
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
The conversion is backward compatible:
|
||||||
|
- Old format documents are automatically converted
|
||||||
|
- New format documents are used as-is
|
||||||
|
- No data loss during conversion
|
||||||
|
- All properties are preserved
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
Potential improvements:
|
||||||
|
1. Add migration flag to track converted documents
|
||||||
|
2. Add backup before conversion
|
||||||
|
3. Add rollback mechanism
|
||||||
|
4. Add conversion progress tracking for large documents
|
||||||
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
# Data Conversion Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document summarizes the data conversion implementation from the old tldraw sync format to the new automerge sync format.
|
||||||
|
|
||||||
|
## Conversion Paths
|
||||||
|
|
||||||
|
The system handles three data formats automatically:
|
||||||
|
|
||||||
|
### 1. Automerge Array Format
|
||||||
|
- **Format**: `[{ state: { id: "...", ... } }, ...]`
|
||||||
|
- **Conversion**: `convertAutomergeToStore()`
|
||||||
|
- **Handles**: Raw Automerge document format
|
||||||
|
|
||||||
|
### 2. Store Format (Already Converted)
|
||||||
|
- **Format**: `{ store: { "recordId": {...}, ... }, schema: {...} }`
|
||||||
|
- **Conversion**: None needed - already in correct format
|
||||||
|
- **Handles**: Previously converted documents
|
||||||
|
|
||||||
|
### 3. Old Documents Format (Legacy)
|
||||||
|
- **Format**: `{ documents: [{ state: {...} }, ...] }`
|
||||||
|
- **Conversion**: `migrateDocumentsToStore()`
|
||||||
|
- **Handles**: Old tldraw sync format
|
||||||
|
|
||||||
|
## Validation & Error Handling
|
||||||
|
|
||||||
|
### Record Validation
|
||||||
|
- ✅ Validates `state` property exists
|
||||||
|
- ✅ Validates `state.id` exists and is a string
|
||||||
|
- ✅ Validates `state.typeName` exists (for documents format)
|
||||||
|
- ✅ Skips invalid records with detailed logging
|
||||||
|
- ✅ Preserves valid records
|
||||||
|
|
||||||
|
### Shape Migration
|
||||||
|
- ✅ Ensures required properties (x, y, rotation, opacity, isLocked, meta, index)
|
||||||
|
- ✅ Moves `w`/`h` from top-level to `props` for geo shapes
|
||||||
|
- ✅ Fixes richText structure
|
||||||
|
- ✅ Preserves custom shape properties (ObsNote, Holon, etc.)
|
||||||
|
- ✅ Tracks and verifies custom shapes
|
||||||
|
|
||||||
|
### Custom Records
|
||||||
|
- ✅ Preserves `obsidian_vault:` records
|
||||||
|
- ✅ Tracks custom record count
|
||||||
|
- ✅ Logs custom record IDs for verification
|
||||||
|
|
||||||
|
## Logging & Statistics
|
||||||
|
|
||||||
|
All conversion functions now provide comprehensive statistics:
|
||||||
|
|
||||||
|
### Conversion Statistics Include:
|
||||||
|
- Total records processed
|
||||||
|
- Successfully converted count
|
||||||
|
- Skipped records (with reasons)
|
||||||
|
- Errors encountered
|
||||||
|
- Custom records preserved
|
||||||
|
- Shape types distribution
|
||||||
|
- Custom shapes preserved
|
||||||
|
|
||||||
|
### Log Levels:
|
||||||
|
- **Info**: Conversion statistics, successful conversions
|
||||||
|
- **Warn**: Skipped records, warnings (first 10 shown)
|
||||||
|
- **Error**: Conversion errors with details
|
||||||
|
|
||||||
|
## Data Preservation Guarantees
|
||||||
|
|
||||||
|
### What is Preserved:
|
||||||
|
- ✅ All valid shape data
|
||||||
|
- ✅ All custom shape properties (ObsNote, Holon, etc.)
|
||||||
|
- ✅ All custom records (obsidian_vault)
|
||||||
|
- ✅ All metadata
|
||||||
|
- ✅ All text content
|
||||||
|
- ✅ All richText content (structure fixed, content preserved)
|
||||||
|
|
||||||
|
### What is Fixed:
|
||||||
|
- 🔧 Missing required properties (defaults added)
|
||||||
|
- 🔧 Invalid property locations (w/h moved to props)
|
||||||
|
- 🔧 Malformed richText structure
|
||||||
|
- 🔧 Missing typeName (inferred where possible)
|
||||||
|
|
||||||
|
### What is Skipped:
|
||||||
|
- ⚠️ Records with missing `state` property
|
||||||
|
- ⚠️ Records with missing `state.id`
|
||||||
|
- ⚠️ Records with invalid `state.id` type
|
||||||
|
- ⚠️ Records with missing `state.typeName` (for documents format)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- `test-data-conversion.ts`: Tests edge cases with malformed data
|
||||||
|
- Covers: missing fields, null records, invalid types, custom records
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
- Test with real R2 data (see `test-r2-conversion.md`)
|
||||||
|
- Verify data integrity after conversion
|
||||||
|
- Check logs for warnings/errors
|
||||||
|
|
||||||
|
## Migration Safety
|
||||||
|
|
||||||
|
### Safety Features:
|
||||||
|
1. **Non-destructive**: Original R2 data is not modified until first save
|
||||||
|
2. **Error handling**: Invalid records are skipped, not lost
|
||||||
|
3. **Comprehensive logging**: All actions are logged for debugging
|
||||||
|
4. **Fallback**: Creates empty document if conversion fails completely
|
||||||
|
|
||||||
|
### Rollback:
|
||||||
|
- Original data remains in R2 until overwritten
|
||||||
|
- Can restore from backup if needed
|
||||||
|
- Conversion errors don't corrupt existing data
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- Conversion happens once per room (cached)
|
||||||
|
- Statistics logging is efficient (limited to first 10 errors)
|
||||||
|
- Shape migration only processes shapes (not all records)
|
||||||
|
- Custom record tracking is lightweight
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ Conversion logic implemented and validated
|
||||||
|
2. ✅ Comprehensive logging added
|
||||||
|
3. ✅ Custom records/shapes preservation verified
|
||||||
|
4. ✅ Edge case handling implemented
|
||||||
|
5. ⏳ Test with real R2 data (manual process)
|
||||||
|
6. ⏳ Monitor production conversions
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `worker/AutomergeDurableObject.ts`: Main conversion logic
|
||||||
|
- `getDocument()`: Format detection and routing
|
||||||
|
- `convertAutomergeToStore()`: Automerge array conversion
|
||||||
|
- `migrateDocumentsToStore()`: Old documents format conversion
|
||||||
|
- `migrateShapeProperties()`: Shape property migration
|
||||||
|
|
||||||
|
## Key Improvements
|
||||||
|
|
||||||
|
1. **Validation**: All records are validated before conversion
|
||||||
|
2. **Logging**: Comprehensive statistics for debugging
|
||||||
|
3. **Error Handling**: Graceful handling of malformed data
|
||||||
|
4. **Preservation**: Custom records and shapes are tracked and verified
|
||||||
|
5. **Safety**: Non-destructive conversion with fallbacks
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
# Data Safety Verification: TldrawDurableObject → AutomergeDurableObject Migration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document verifies that the migration from `TldrawDurableObject` to `AutomergeDurableObject` is safe and will not result in data loss.
|
||||||
|
|
||||||
|
## R2 Bucket Configuration ✅
|
||||||
|
|
||||||
|
### Production Environment
|
||||||
|
- **Bucket Binding**: `TLDRAW_BUCKET`
|
||||||
|
- **Bucket Name**: `jeffemmett-canvas`
|
||||||
|
- **Storage Path**: `rooms/${roomId}`
|
||||||
|
- **Configuration**: `wrangler.toml` lines 30-32
|
||||||
|
|
||||||
|
### Development Environment
|
||||||
|
- **Bucket Binding**: `TLDRAW_BUCKET`
|
||||||
|
- **Bucket Name**: `jeffemmett-canvas-preview`
|
||||||
|
- **Storage Path**: `rooms/${roomId}`
|
||||||
|
- **Configuration**: `wrangler.toml` lines 72-74
|
||||||
|
|
||||||
|
## Data Storage Architecture
|
||||||
|
|
||||||
|
### Where Data is Stored
|
||||||
|
|
||||||
|
1. **Document Data (R2 Storage)** ✅
|
||||||
|
- **Location**: R2 bucket at path `rooms/${roomId}`
|
||||||
|
- **Format**: JSON document containing the full board state
|
||||||
|
- **Persistence**: Permanent storage, independent of Durable Object instances
|
||||||
|
- **Access**: Both `TldrawDurableObject` and `AutomergeDurableObject` use the same R2 bucket and path
|
||||||
|
|
||||||
|
2. **Room ID (Durable Object Storage)** ⚠️
|
||||||
|
- **Location**: Durable Object's internal storage (`ctx.storage`)
|
||||||
|
- **Purpose**: Cached room ID for the Durable Object instance
|
||||||
|
- **Recovery**: Can be re-initialized from URL path (`/connect/:roomId`)
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ R2 Bucket (TLDRAW_BUCKET) │
|
||||||
|
│ │
|
||||||
|
│ rooms/room-123 ←─── Document Data (PERSISTENT) │
|
||||||
|
│ rooms/room-456 ←─── Document Data (PERSISTENT) │
|
||||||
|
│ rooms/room-789 ←─── Document Data (PERSISTENT) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
▲ ▲
|
||||||
|
│ │
|
||||||
|
┌─────────────────┘ └─────────────────┐
|
||||||
|
│ │
|
||||||
|
┌───────┴────────┐ ┌─────────────┴────────┐
|
||||||
|
│ TldrawDurable │ │ AutomergeDurable │
|
||||||
|
│ Object │ │ Object │
|
||||||
|
│ (DEPRECATED) │ │ (ACTIVE) │
|
||||||
|
└────────────────┘ └──────────────────────┘
|
||||||
|
│ │
|
||||||
|
└─────────────────── Both read/write ─────────────────────┘
|
||||||
|
to the same R2 location
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Safety Guarantees
|
||||||
|
|
||||||
|
### ✅ No Data Loss Risk
|
||||||
|
|
||||||
|
1. **R2 Data is Independent**
|
||||||
|
- Document data is stored in R2, not in Durable Object storage
|
||||||
|
- R2 data persists even when Durable Object instances are deleted
|
||||||
|
- Both classes use the same R2 bucket (`TLDRAW_BUCKET`) and path (`rooms/${roomId}`)
|
||||||
|
|
||||||
|
2. **Stub Class Ensures Compatibility**
|
||||||
|
- `TldrawDurableObject` extends `AutomergeDurableObject`
|
||||||
|
- Uses the same R2 bucket and storage path
|
||||||
|
- Existing instances can access their data during migration
|
||||||
|
|
||||||
|
3. **Room ID Recovery**
|
||||||
|
- `roomId` is passed in the URL path (`/connect/:roomId`)
|
||||||
|
- Can be re-initialized if Durable Object storage is lost
|
||||||
|
- Code handles missing `roomId` by reading from URL (see `AutomergeDurableObject.ts` lines 43-49)
|
||||||
|
|
||||||
|
4. **Automatic Format Conversion**
|
||||||
|
- `AutomergeDurableObject` handles multiple data formats:
|
||||||
|
- Automerge Array Format: `[{ state: {...} }, ...]`
|
||||||
|
- Store Format: `{ store: { "recordId": {...}, ... }, schema: {...} }`
|
||||||
|
- Old Documents Format: `{ documents: [{ state: {...} }, ...] }`
|
||||||
|
- Conversion preserves all data, including custom shapes and records
|
||||||
|
|
||||||
|
### Migration Process
|
||||||
|
|
||||||
|
1. **Deployment with Stub**
|
||||||
|
- `TldrawDurableObject` stub class is exported
|
||||||
|
- Cloudflare recognizes the class exists
|
||||||
|
- Existing instances can continue operating
|
||||||
|
|
||||||
|
2. **Delete-Class Migration**
|
||||||
|
- Migration tag `v2` with `deleted_classes = ["TldrawDurableObject"]`
|
||||||
|
- Cloudflare will delete Durable Object instances (not R2 data)
|
||||||
|
- R2 data remains untouched
|
||||||
|
|
||||||
|
3. **Data Access After Migration**
|
||||||
|
- New `AutomergeDurableObject` instances can access the same R2 data
|
||||||
|
- Same bucket (`TLDRAW_BUCKET`) and path (`rooms/${roomId}`)
|
||||||
|
- Automatic format conversion ensures compatibility
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
- [x] R2 bucket binding is correctly configured (`TLDRAW_BUCKET`)
|
||||||
|
- [x] Both production and dev environments have R2 buckets configured
|
||||||
|
- [x] `AutomergeDurableObject` uses `env.TLDRAW_BUCKET`
|
||||||
|
- [x] Storage path is consistent (`rooms/${roomId}`)
|
||||||
|
- [x] Stub class extends `AutomergeDurableObject` (same R2 access)
|
||||||
|
- [x] Migration includes `delete-class` for `TldrawDurableObject`
|
||||||
|
- [x] Code handles missing `roomId` by reading from URL
|
||||||
|
- [x] Format conversion logic preserves all data types
|
||||||
|
- [x] Custom shapes and records are preserved during conversion
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
1. **Before Migration**
|
||||||
|
- Verify R2 bucket contains expected room data
|
||||||
|
- List rooms: `wrangler r2 object list TLDRAW_BUCKET --prefix "rooms/"`
|
||||||
|
- Check a sample room's format
|
||||||
|
|
||||||
|
2. **After Migration**
|
||||||
|
- Verify rooms are still accessible
|
||||||
|
- Check that data format is correctly converted
|
||||||
|
- Verify custom shapes and records are preserved
|
||||||
|
- Monitor worker logs for conversion statistics
|
||||||
|
|
||||||
|
3. **Data Integrity Checks**
|
||||||
|
- Shape count matches before/after
|
||||||
|
- Custom shapes (ObsNote, Holon, etc.) have all properties
|
||||||
|
- Custom records (obsidian_vault, etc.) are present
|
||||||
|
- No validation errors in console
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
✅ **The migration is safe and will not result in data loss.**
|
||||||
|
|
||||||
|
- All document data is stored in R2, which is independent of Durable Object instances
|
||||||
|
- Both classes use the same R2 bucket and storage path
|
||||||
|
- The stub class ensures compatibility during migration
|
||||||
|
- Format conversion logic preserves all data types
|
||||||
|
- Room IDs can be recovered from URL paths if needed
|
||||||
|
|
||||||
|
The only data that will be lost is the cached `roomId` in Durable Object storage, which can be easily re-initialized from the URL path.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
## Frontend Deployment (Cloudflare Pages)
|
||||||
|
|
||||||
|
The frontend is deployed to **Cloudflare Pages** (migrated from Vercel).
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- **Build command**: `npm run build`
|
||||||
|
- **Build output directory**: `dist`
|
||||||
|
- **SPA routing**: Handled by `_redirects` file
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
Set in Cloudflare Pages dashboard → Settings → Environment variables:
|
||||||
|
- All `VITE_*` variables needed for the frontend
|
||||||
|
- `VITE_WORKER_ENV=production` for production
|
||||||
|
|
||||||
|
See `CLOUDFLARE_PAGES_MIGRATION.md` for detailed migration guide.
|
||||||
|
|
||||||
|
## Worker Deployment Strategy
|
||||||
|
|
||||||
|
**Using Cloudflare's Native Git Integration** for automatic deployments.
|
||||||
|
|
||||||
|
### Current Setup
|
||||||
|
- ✅ **Cloudflare Workers Builds**: Automatic deployment on push to `main` branch
|
||||||
|
- ✅ **Build Status**: Integrated with GitHub (commit statuses, PR comments)
|
||||||
|
- ✅ **Environment Support**: Production and preview environments
|
||||||
|
|
||||||
|
### How to Configure Cloudflare Native Deployment
|
||||||
|
|
||||||
|
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/)
|
||||||
|
2. Navigate to **Workers & Pages** → **jeffemmett-canvas**
|
||||||
|
3. Go to **Settings** → **Builds & Deployments**
|
||||||
|
4. Ensure **"Automatically deploy from Git"** is enabled
|
||||||
|
5. Configure build settings:
|
||||||
|
- **Build command**: Leave empty (wrangler handles this automatically)
|
||||||
|
- **Root directory**: `/` (or leave empty)
|
||||||
|
- **Environment variables**: Set in Cloudflare dashboard (not in wrangler.toml)
|
||||||
|
|
||||||
|
### Why Use Cloudflare Native Deployment?
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- ✅ Simpler setup (no workflow files to maintain)
|
||||||
|
- ✅ Integrated with Cloudflare dashboard
|
||||||
|
- ✅ Automatic resource provisioning (KV, R2, Durable Objects)
|
||||||
|
- ✅ Build status in GitHub (commit statuses, PR comments)
|
||||||
|
- ✅ No GitHub Actions minutes usage
|
||||||
|
- ✅ Less moving parts, easier to debug
|
||||||
|
|
||||||
|
**Note:** The GitHub Action workflow has been deprecated (see `.github/workflows/deploy-worker.yml.disabled`) but kept as backup.
|
||||||
|
|
||||||
|
### Migration Fix
|
||||||
|
|
||||||
|
The worker now includes a migration to rename `TldrawDurableObject` → `AutomergeDurableObject`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[migrations]]
|
||||||
|
tag = "v2"
|
||||||
|
renamed_classes = [
|
||||||
|
{ from = "TldrawDurableObject", to = "AutomergeDurableObject" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
This fixes the error: "New version of script does not export class 'TldrawDurableObject'"
|
||||||
|
|
||||||
|
### Manual Deployment (if needed)
|
||||||
|
|
||||||
|
If you need to deploy manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production
|
||||||
|
npm run deploy:worker
|
||||||
|
|
||||||
|
# Development
|
||||||
|
npm run deploy:worker:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly:
|
||||||
|
```bash
|
||||||
|
wrangler deploy # Production (uses wrangler.toml)
|
||||||
|
wrangler deploy --config wrangler.dev.toml # Dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pages Deployment
|
||||||
|
|
||||||
|
Pages deployment is separate and should be configured in Cloudflare Pages dashboard:
|
||||||
|
- **Build command**: `npm run build`
|
||||||
|
- **Build output directory**: `dist`
|
||||||
|
- **Root directory**: `/` (or leave empty)
|
||||||
|
|
||||||
|
**Note**: `wrangler.toml` is for Workers only, not Pages.
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Deployment Summary
|
||||||
|
|
||||||
|
## Current Setup
|
||||||
|
|
||||||
|
### ✅ Frontend: Cloudflare Pages
|
||||||
|
- **Deployment**: Automatic on push to `main` branch
|
||||||
|
- **Build**: `npm run build`
|
||||||
|
- **Output**: `dist/`
|
||||||
|
- **Configuration**: Set in Cloudflare Pages dashboard
|
||||||
|
- **Environment Variables**: Set in Cloudflare Pages dashboard (VITE_* variables)
|
||||||
|
|
||||||
|
### ✅ Worker: Cloudflare Native Git Integration
|
||||||
|
- **Production**: Automatic deployment on push to `main` branch → uses `wrangler.toml`
|
||||||
|
- **Preview**: Automatic deployment for pull requests → uses `wrangler.toml` (or can be configured for dev)
|
||||||
|
- **Build Status**: Integrated with GitHub (commit statuses, PR comments)
|
||||||
|
- **Configuration**: Managed in Cloudflare Dashboard → Settings → Builds & Deployments
|
||||||
|
|
||||||
|
### ❌ Vercel: Can be disabled
|
||||||
|
- Frontend is now on Cloudflare Pages
|
||||||
|
- Worker was never on Vercel
|
||||||
|
- You can safely disconnect/delete the Vercel project
|
||||||
|
|
||||||
|
## Why Cloudflare Native Deployment?
|
||||||
|
|
||||||
|
**Cloudflare's native Git integration provides:**
|
||||||
|
|
||||||
|
1. ✅ **Simplicity**: No workflow files to maintain, automatic setup
|
||||||
|
2. ✅ **Integration**: Build status directly in GitHub (commit statuses, PR comments)
|
||||||
|
3. ✅ **Resource Provisioning**: Automatically provisions KV, R2, Durable Objects
|
||||||
|
4. ✅ **Environment Support**: Production and preview environments
|
||||||
|
5. ✅ **Dashboard Integration**: All deployments visible in Cloudflare dashboard
|
||||||
|
6. ✅ **No GitHub Actions Minutes**: Free deployment, no usage limits
|
||||||
|
|
||||||
|
**Note:** GitHub Actions workflow has been deprecated (see `.github/workflows/deploy-worker.yml.disabled`) but kept as backup if needed.
|
||||||
|
|
||||||
|
## Environment Switching
|
||||||
|
|
||||||
|
### For Local Development
|
||||||
|
|
||||||
|
You can switch between dev and prod workers locally using:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Switch to production worker
|
||||||
|
./switch-worker-env.sh production
|
||||||
|
|
||||||
|
# Switch to dev worker
|
||||||
|
./switch-worker-env.sh dev
|
||||||
|
|
||||||
|
# Switch to local worker (requires local worker running)
|
||||||
|
./switch-worker-env.sh local
|
||||||
|
```
|
||||||
|
|
||||||
|
This updates `.env.local` with `VITE_WORKER_ENV=production` or `VITE_WORKER_ENV=dev`.
|
||||||
|
|
||||||
|
**Default**: Now set to `production` (changed from `dev`)
|
||||||
|
|
||||||
|
### For Cloudflare Pages
|
||||||
|
|
||||||
|
Set environment variables in Cloudflare Pages dashboard:
|
||||||
|
- **Production**: `VITE_WORKER_ENV=production`
|
||||||
|
- **Preview**: `VITE_WORKER_ENV=dev` (for testing)
|
||||||
|
|
||||||
|
## Deployment Workflow
|
||||||
|
|
||||||
|
### Frontend (Cloudflare Pages)
|
||||||
|
1. Push to `main` → Auto-deploys to production
|
||||||
|
2. Create PR → Auto-deploys to preview environment
|
||||||
|
3. Environment variables set in Cloudflare dashboard
|
||||||
|
|
||||||
|
### Worker (Cloudflare Native)
|
||||||
|
1. **Production**: Push to `main` → Auto-deploys to production worker
|
||||||
|
2. **Preview**: Create PR → Auto-deploys to preview environment (optional)
|
||||||
|
3. **Manual**: Deploy via `wrangler deploy` command or Cloudflare dashboard
|
||||||
|
|
||||||
|
## Testing Both Environments
|
||||||
|
|
||||||
|
### Local Testing
|
||||||
|
```bash
|
||||||
|
# Test with production worker
|
||||||
|
./switch-worker-env.sh production
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Test with dev worker
|
||||||
|
./switch-worker-env.sh dev
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remote Testing
|
||||||
|
- **Production**: Visit your production Cloudflare Pages URL
|
||||||
|
- **Dev**: Visit your dev worker URL directly or use preview deployment
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ **Disable Vercel**: Go to Vercel dashboard → Disconnect repository
|
||||||
|
2. ✅ **Verify Cloudflare Pages**: Ensure it's deploying correctly
|
||||||
|
3. ✅ **Test Worker Deployments**: Push to main and verify production worker updates
|
||||||
|
4. ✅ **Test Dev Worker**: Push to `automerge/test` branch and verify dev worker updates
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
# Fathom API Integration for tldraw Canvas
|
||||||
|
|
||||||
|
This integration allows you to import Fathom meeting transcripts directly into your tldraw canvas at jeffemmett.com/board/test.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎥 **Import Fathom Meetings**: Browse and import your Fathom meeting recordings
|
||||||
|
- 📝 **Rich Transcript Display**: View full transcripts with speaker identification and timestamps
|
||||||
|
- ✅ **Action Items**: See extracted action items from meetings
|
||||||
|
- 📋 **AI Summaries**: Display AI-generated meeting summaries
|
||||||
|
- 🔗 **Direct Links**: Click to view meetings in Fathom
|
||||||
|
- 🎨 **Customizable Display**: Toggle between compact and expanded views
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Get Your Fathom API Key
|
||||||
|
|
||||||
|
1. Go to your [Fathom User Settings](https://app.usefathom.com/settings/integrations)
|
||||||
|
2. Navigate to the "Integrations" section
|
||||||
|
3. Generate an API key
|
||||||
|
4. Copy the API key for use in the canvas
|
||||||
|
|
||||||
|
### 2. Using the Integration
|
||||||
|
|
||||||
|
1. **Open the Canvas**: Navigate to `jeffemmett.com/board/test`
|
||||||
|
2. **Access Fathom Meetings**: Click the "Fathom Meetings" button in the toolbar (calendar icon)
|
||||||
|
3. **Enter API Key**: When prompted, enter your Fathom API key
|
||||||
|
4. **Browse Meetings**: The panel will load your recent Fathom meetings
|
||||||
|
5. **Add to Canvas**: Click "Add to Canvas" on any meeting to create a transcript shape
|
||||||
|
|
||||||
|
### 3. Customizing Transcript Shapes
|
||||||
|
|
||||||
|
Once added to the canvas, you can:
|
||||||
|
|
||||||
|
- **Toggle Transcript View**: Click the "📝 Transcript" button to show/hide the full transcript
|
||||||
|
- **Toggle Action Items**: Click the "✅ Actions" button to show/hide action items
|
||||||
|
- **Expand/Collapse**: Click the "📄 Expanded/Compact" button to change the view
|
||||||
|
- **Resize**: Drag the corners to resize the shape
|
||||||
|
- **Move**: Click and drag to reposition the shape
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The integration includes these backend endpoints:
|
||||||
|
|
||||||
|
- `GET /api/fathom/meetings` - List all meetings
|
||||||
|
- `GET /api/fathom/meetings/:id` - Get specific meeting details
|
||||||
|
- `POST /api/fathom/webhook` - Receive webhook notifications (for future real-time updates)
|
||||||
|
|
||||||
|
## Webhook Setup (Optional)
|
||||||
|
|
||||||
|
For real-time updates when new meetings are recorded:
|
||||||
|
|
||||||
|
1. **Get Webhook URL**: Your webhook endpoint is `https://jeffemmett-canvas.jeffemmett.workers.dev/api/fathom/webhook`
|
||||||
|
2. **Configure in Fathom**: Add this URL in your Fathom webhook settings
|
||||||
|
3. **Enable Notifications**: Turn on webhook notifications for new meetings
|
||||||
|
|
||||||
|
## Data Structure
|
||||||
|
|
||||||
|
The Fathom transcript shape includes:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
meetingId: string
|
||||||
|
meetingTitle: string
|
||||||
|
meetingUrl: string
|
||||||
|
summary: string
|
||||||
|
transcript: Array<{
|
||||||
|
speaker: string
|
||||||
|
text: string
|
||||||
|
timestamp: string
|
||||||
|
}>
|
||||||
|
actionItems: Array<{
|
||||||
|
text: string
|
||||||
|
assignee?: string
|
||||||
|
dueDate?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **"No API key provided"**: Make sure you've entered your Fathom API key correctly
|
||||||
|
2. **"Failed to fetch meetings"**: Check that your API key is valid and has the correct permissions
|
||||||
|
3. **Empty transcript**: Some meetings may not have transcripts if they were recorded without transcription enabled
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
|
||||||
|
- Check the browser console for error messages
|
||||||
|
- Verify your Fathom API key is correct
|
||||||
|
- Ensure you have recorded meetings in Fathom
|
||||||
|
- Contact support if issues persist
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- API keys are stored locally in your browser
|
||||||
|
- Webhook endpoints are currently not signature-verified (TODO for production)
|
||||||
|
- All data is processed client-side for privacy
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Real-time webhook notifications
|
||||||
|
- [ ] Search and filter meetings
|
||||||
|
- [ ] Export transcript data
|
||||||
|
- [ ] Integration with other meeting tools
|
||||||
|
- [ ] Advanced transcript formatting options
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Gesture Recognition Tool
|
||||||
|
|
||||||
|
This document describes all available gestures in the Canvas application. Use the gesture tool (press `g` or select from toolbar) to draw these gestures and trigger their actions.
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
1. **Activate the Gesture Tool**: Press `g` or select the gesture tool from the toolbar
|
||||||
|
2. **Draw a Gesture**: Use your mouse, pen, or finger to draw one of the gestures below
|
||||||
|
3. **Release**: The gesture will be recognized and the corresponding action will be performed
|
||||||
|
|
||||||
|
## Available Gestures
|
||||||
|
|
||||||
|
### Basic Gestures (Default Mode)
|
||||||
|
|
||||||
|
| Gesture | Description | Action |
|
||||||
|
|---------|-------------|---------|
|
||||||
|
| **X** | Draw an "X" shape | Deletes selected shapes |
|
||||||
|
| **Rectangle** | Draw a rectangle outline | Creates a rectangle shape at the gesture location |
|
||||||
|
| **Circle** | Draw a circle/oval | Selects and highlights shapes under the gesture |
|
||||||
|
| **Check** | Draw a checkmark (✓) | Changes color of shapes under the gesture to green |
|
||||||
|
| **Caret** | Draw a caret (^) pointing up | Aligns selected shapes to the top |
|
||||||
|
| **V** | Draw a "V" shape pointing down | Aligns selected shapes to the bottom |
|
||||||
|
| **Delete** | Draw a delete symbol (similar to X) | Deletes selected shapes |
|
||||||
|
| **Pigtail** | Draw a pigtail/spiral shape | Selects shapes under gesture and rotates them 90° counterclockwise |
|
||||||
|
|
||||||
|
### Layout Gestures (Hold Shift + Draw)
|
||||||
|
|
||||||
|
| Gesture | Description | Action |
|
||||||
|
|---------|-------------|---------|
|
||||||
|
| **Circle Layout** | Draw a circle while holding Shift | Arranges selected shapes in a circle around the gesture center |
|
||||||
|
| **Triangle Layout** | Draw a triangle while holding Shift | Arranges selected shapes in a triangle around the gesture center |
|
||||||
|
|
||||||
|
## Gesture Tips
|
||||||
|
|
||||||
|
- **Accuracy**: Draw gestures clearly and completely for best recognition
|
||||||
|
- **Size**: Gestures work at various sizes, but avoid extremely small or large drawings
|
||||||
|
- **Speed**: Draw at a natural pace - not too fast or too slow
|
||||||
|
- **Shift Key**: Hold Shift while drawing to access layout gestures
|
||||||
|
- **Selection**: Most gestures work on selected shapes, so select shapes first if needed
|
||||||
|
|
||||||
|
## Keyboard Shortcut
|
||||||
|
|
||||||
|
- **`g`**: Activate the gesture tool
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If a gesture isn't recognized, try drawing it more clearly or at a different size
|
||||||
|
- Make sure you're using the gesture tool (cursor should change to a cross)
|
||||||
|
- For layout gestures, remember to hold Shift while drawing
|
||||||
|
- Some gestures require shapes to be selected first
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Deleting Shapes
|
||||||
|
1. Select the shapes you want to delete
|
||||||
|
2. Press `g` to activate gesture tool
|
||||||
|
3. Draw an "X" over the shapes
|
||||||
|
4. Release - the shapes will be deleted
|
||||||
|
|
||||||
|
### Creating a Rectangle
|
||||||
|
1. Press `g` to activate gesture tool
|
||||||
|
2. Draw a rectangle outline where you want the shape
|
||||||
|
3. Release - a rectangle will be created
|
||||||
|
|
||||||
|
### Arranging Shapes in a Circle
|
||||||
|
1. Select the shapes you want to arrange
|
||||||
|
2. Press `g` to activate gesture tool
|
||||||
|
3. Hold Shift and draw a circle
|
||||||
|
4. Release - the shapes will be arranged in a circle
|
||||||
|
|
||||||
|
### Rotating Shapes
|
||||||
|
1. Select the shapes you want to rotate
|
||||||
|
2. Press `g` to activate gesture tool
|
||||||
|
3. Draw a pigtail/spiral over the shapes
|
||||||
|
4. Release - the shapes will rotate 90° counterclockwise
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Vercel → Cloudflare Pages Migration Checklist
|
||||||
|
|
||||||
|
## ✅ Completed Setup
|
||||||
|
|
||||||
|
- [x] Created `_redirects` file for SPA routing (in `src/public/`)
|
||||||
|
- [x] Updated `package.json` to remove Vercel from deploy script
|
||||||
|
- [x] Created migration guide (`CLOUDFLARE_PAGES_MIGRATION.md`)
|
||||||
|
- [x] Updated deployment documentation
|
||||||
|
|
||||||
|
## 📋 Action Items
|
||||||
|
|
||||||
|
### 1. Create Cloudflare Pages Project
|
||||||
|
- [ ] Go to [Cloudflare Dashboard](https://dash.cloudflare.com/)
|
||||||
|
- [ ] Navigate to **Pages** → **Create a project**
|
||||||
|
- [ ] Connect GitHub repository: `Jeff-Emmett/canvas-website`
|
||||||
|
- [ ] Configure:
|
||||||
|
- **Project name**: `canvas-website`
|
||||||
|
- **Production branch**: `main`
|
||||||
|
- **Build command**: `npm run build`
|
||||||
|
- **Build output directory**: `dist`
|
||||||
|
- **Root directory**: `/` (leave empty)
|
||||||
|
|
||||||
|
### 2. Set Environment Variables
|
||||||
|
- [ ] Go to Pages project → **Settings** → **Environment variables**
|
||||||
|
- [ ] Add all `VITE_*` variables from Vercel:
|
||||||
|
- `VITE_WORKER_ENV=production` (for production)
|
||||||
|
- `VITE_WORKER_ENV=dev` (for preview)
|
||||||
|
- Any other `VITE_*` variables you use
|
||||||
|
- [ ] Set different values for **Production** and **Preview** if needed
|
||||||
|
|
||||||
|
### 3. Test First Deployment
|
||||||
|
- [ ] Wait for first deployment to complete
|
||||||
|
- [ ] Visit Pages URL (e.g., `canvas-website.pages.dev`)
|
||||||
|
- [ ] Test routes:
|
||||||
|
- [ ] `/board`
|
||||||
|
- [ ] `/inbox`
|
||||||
|
- [ ] `/contact`
|
||||||
|
- [ ] `/presentations`
|
||||||
|
- [ ] `/dashboard`
|
||||||
|
- [ ] Verify canvas app connects to Worker
|
||||||
|
- [ ] Test real-time collaboration
|
||||||
|
|
||||||
|
### 4. Configure Custom Domain (if applicable)
|
||||||
|
- [ ] Go to Pages project → **Custom domains**
|
||||||
|
- [ ] Add your domain (e.g., `jeffemmett.com`)
|
||||||
|
- [ ] Update DNS records to point to Cloudflare Pages
|
||||||
|
- [ ] Wait for DNS propagation
|
||||||
|
|
||||||
|
### 5. Clean Up Vercel (after confirming Cloudflare works)
|
||||||
|
- [ ] Verify everything works on Cloudflare Pages
|
||||||
|
- [ ] Go to Vercel Dashboard
|
||||||
|
- [ ] Disconnect repository or delete project
|
||||||
|
- [ ] Update DNS records if using custom domain
|
||||||
|
|
||||||
|
## 🔍 Verification Steps
|
||||||
|
|
||||||
|
After migration, verify:
|
||||||
|
- ✅ All routes work (no 404s)
|
||||||
|
- ✅ Canvas app loads and connects to Worker
|
||||||
|
- ✅ Real-time collaboration works
|
||||||
|
- ✅ Environment variables are accessible
|
||||||
|
- ✅ Assets load correctly
|
||||||
|
- ✅ No console errors
|
||||||
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
- The `_redirects` file is in `src/public/` and will be copied to `dist/` during build
|
||||||
|
- Worker deployment is separate and unchanged
|
||||||
|
- Environment variables must start with `VITE_` to be accessible in the browser
|
||||||
|
- Cloudflare Pages automatically deploys on push to `main` branch
|
||||||
|
|
||||||
|
## 🆘 If Something Goes Wrong
|
||||||
|
|
||||||
|
1. Check Cloudflare Pages build logs
|
||||||
|
2. Check browser console for errors
|
||||||
|
3. Verify environment variables are set
|
||||||
|
4. Verify Worker is accessible
|
||||||
|
5. Check `_redirects` file is in `dist/` after build
|
||||||
|
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
# Quartz Database Setup Guide
|
||||||
|
|
||||||
|
This guide explains how to set up a Quartz database with read/write permissions for your canvas website. Based on the [Quartz static site generator](https://quartz.jzhao.xyz/) architecture, there are several approaches available.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Quartz is a static site generator that transforms Markdown content into websites. To enable read/write functionality, we've implemented multiple sync approaches that work with Quartz's architecture.
|
||||||
|
|
||||||
|
## Setup Options
|
||||||
|
|
||||||
|
### 1. GitHub Integration (Recommended)
|
||||||
|
|
||||||
|
This is the most natural approach since Quartz is designed to work with GitHub repositories.`
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
- A GitHub repository containing your Quartz site
|
||||||
|
- A GitHub Personal Access Token with repository write permissions
|
||||||
|
|
||||||
|
#### Setup Steps
|
||||||
|
|
||||||
|
1. **Create a GitHub Personal Access Token:**
|
||||||
|
- Go to GitHub Settings → Developer settings → Personal access tokens
|
||||||
|
- Generate a new token with `repo` permissions for the Jeff-Emmett/quartz repository
|
||||||
|
- Copy the token
|
||||||
|
|
||||||
|
2. **Configure Environment Variables:**
|
||||||
|
Create a `.env.local` file in your project root with:
|
||||||
|
```bash
|
||||||
|
# GitHub Integration for Jeff-Emmett/quartz
|
||||||
|
NEXT_PUBLIC_GITHUB_TOKEN=your_github_token_here
|
||||||
|
NEXT_PUBLIC_QUARTZ_REPO=Jeff-Emmett/quartz
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Replace `your_github_token_here` with your actual GitHub Personal Access Token.
|
||||||
|
|
||||||
|
3. **Set up GitHub Actions (Optional):**
|
||||||
|
- The included `.github/workflows/quartz-sync.yml` will automatically rebuild your Quartz site when content changes
|
||||||
|
- Make sure your repository has GitHub Pages enabled
|
||||||
|
|
||||||
|
#### How It Works
|
||||||
|
- When you sync a note, it creates/updates a Markdown file in your GitHub repository
|
||||||
|
- The file is placed in the `content/` directory with proper frontmatter
|
||||||
|
- GitHub Actions automatically rebuilds and deploys your Quartz site
|
||||||
|
- Your changes appear on your live Quartz site within minutes
|
||||||
|
|
||||||
|
### 2. Cloudflare Integration
|
||||||
|
|
||||||
|
Uses your existing Cloudflare infrastructure for persistent storage.
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
- Cloudflare account with R2 and Durable Objects enabled
|
||||||
|
- API token with appropriate permissions
|
||||||
|
|
||||||
|
#### Setup Steps
|
||||||
|
|
||||||
|
1. **Create Cloudflare API Token:**
|
||||||
|
- Go to Cloudflare Dashboard → My Profile → API Tokens
|
||||||
|
- Create a token with `Cloudflare R2:Edit` and `Durable Objects:Edit` permissions
|
||||||
|
- Note your Account ID
|
||||||
|
|
||||||
|
2. **Configure Environment Variables:**
|
||||||
|
```bash
|
||||||
|
# Add to your .env.local file
|
||||||
|
NEXT_PUBLIC_CLOUDFLARE_API_KEY=your_api_key_here
|
||||||
|
NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID=your_account_id_here
|
||||||
|
NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET=your-bucket-name
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Deploy the API Endpoint:**
|
||||||
|
- The `src/pages/api/quartz/sync.ts` endpoint handles Cloudflare storage
|
||||||
|
- Deploy this to your Cloudflare Workers or Vercel
|
||||||
|
|
||||||
|
#### How It Works
|
||||||
|
- Notes are stored in Cloudflare R2 for persistence
|
||||||
|
- Durable Objects handle real-time sync across devices
|
||||||
|
- The API endpoint manages note storage and retrieval
|
||||||
|
- Changes are immediately available to all connected clients
|
||||||
|
|
||||||
|
### 3. Direct Quartz API
|
||||||
|
|
||||||
|
If your Quartz site exposes an API for content updates.
|
||||||
|
|
||||||
|
#### Setup Steps
|
||||||
|
|
||||||
|
1. **Configure Environment Variables:**
|
||||||
|
```bash
|
||||||
|
# Add to your .env.local file
|
||||||
|
NEXT_PUBLIC_QUARTZ_API_URL=https://your-quartz-site.com/api
|
||||||
|
NEXT_PUBLIC_QUARTZ_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Implement API Endpoints:**
|
||||||
|
- Your Quartz site needs to expose `/api/notes` endpoints
|
||||||
|
- See the example implementation in the sync code
|
||||||
|
|
||||||
|
### 4. Webhook Integration
|
||||||
|
|
||||||
|
Send updates to a webhook that processes and syncs to Quartz.
|
||||||
|
|
||||||
|
#### Setup Steps
|
||||||
|
|
||||||
|
1. **Configure Environment Variables:**
|
||||||
|
```bash
|
||||||
|
# Add to your .env.local file
|
||||||
|
NEXT_PUBLIC_QUARTZ_WEBHOOK_URL=https://your-webhook-endpoint.com/quartz-sync
|
||||||
|
NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET=your_webhook_secret_here
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Set up Webhook Handler:**
|
||||||
|
- Create an endpoint that receives note updates
|
||||||
|
- Process the updates and sync to your Quartz site
|
||||||
|
- Implement proper authentication using the webhook secret
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Create a `.env.local` file with the following variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# GitHub Integration
|
||||||
|
NEXT_PUBLIC_GITHUB_TOKEN=your_github_token
|
||||||
|
NEXT_PUBLIC_QUARTZ_REPO=username/repo-name
|
||||||
|
|
||||||
|
# Cloudflare Integration
|
||||||
|
NEXT_PUBLIC_CLOUDFLARE_API_KEY=your_api_key
|
||||||
|
NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID=your_account_id
|
||||||
|
NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET=your-bucket-name
|
||||||
|
|
||||||
|
# Quartz API Integration
|
||||||
|
NEXT_PUBLIC_QUARTZ_API_URL=https://your-site.com/api
|
||||||
|
NEXT_PUBLIC_QUARTZ_API_KEY=your_api_key
|
||||||
|
|
||||||
|
# Webhook Integration
|
||||||
|
NEXT_PUBLIC_QUARTZ_WEBHOOK_URL=https://your-webhook.com/sync
|
||||||
|
NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET=your_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime Configuration
|
||||||
|
|
||||||
|
You can also configure sync settings at runtime:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { saveQuartzSyncSettings } from '@/config/quartzSync'
|
||||||
|
|
||||||
|
// Enable/disable specific sync methods
|
||||||
|
saveQuartzSyncSettings({
|
||||||
|
github: { enabled: true },
|
||||||
|
cloudflare: { enabled: false },
|
||||||
|
webhook: { enabled: true }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Sync
|
||||||
|
|
||||||
|
The sync functionality is automatically integrated into your ObsNote shapes. When you edit a note and click "Sync Updates", it will:
|
||||||
|
|
||||||
|
1. Try the configured sync methods in order of preference
|
||||||
|
2. Fall back to local storage if all methods fail
|
||||||
|
3. Provide feedback on the sync status
|
||||||
|
|
||||||
|
### Advanced Sync
|
||||||
|
|
||||||
|
For more control, you can use the QuartzSync class directly:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { QuartzSync, createQuartzNoteFromShape } from '@/lib/quartzSync'
|
||||||
|
|
||||||
|
const sync = new QuartzSync({
|
||||||
|
githubToken: 'your_token',
|
||||||
|
githubRepo: 'username/repo'
|
||||||
|
})
|
||||||
|
|
||||||
|
const note = createQuartzNoteFromShape(shape)
|
||||||
|
await sync.smartSync(note)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **"No vault configured for sync"**
|
||||||
|
- Make sure you've selected a vault in the Obsidian Vault Browser
|
||||||
|
- Check that the vault path is properly saved in your session
|
||||||
|
|
||||||
|
2. **GitHub API errors**
|
||||||
|
- Verify your GitHub token has the correct permissions
|
||||||
|
- Check that the repository name is correct (username/repo-name format)
|
||||||
|
|
||||||
|
3. **Cloudflare sync failures**
|
||||||
|
- Ensure your API key has the necessary permissions
|
||||||
|
- Verify the account ID and bucket name are correct
|
||||||
|
|
||||||
|
4. **Environment variables not loading**
|
||||||
|
- Make sure your `.env.local` file is in the project root
|
||||||
|
- Restart your development server after adding new variables
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Enable debug logging by opening the browser console. The sync process provides detailed logs for troubleshooting.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **API Keys**: Never commit API keys to version control
|
||||||
|
2. **GitHub Tokens**: Use fine-grained tokens with minimal required permissions
|
||||||
|
3. **Webhook Secrets**: Always use strong, unique secrets for webhook authentication
|
||||||
|
4. **CORS**: Configure CORS properly for API endpoints
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Start with GitHub Integration**: It's the most reliable and well-supported approach
|
||||||
|
2. **Use Fallbacks**: Always have local storage as a fallback option
|
||||||
|
3. **Monitor Sync Status**: Check the console logs for sync success/failure
|
||||||
|
4. **Test Thoroughly**: Verify sync works with different types of content
|
||||||
|
5. **Backup Important Data**: Don't rely solely on sync for critical content
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
1. Check the console logs for detailed error messages
|
||||||
|
2. Verify your environment variables are set correctly
|
||||||
|
3. Test with a simple note first
|
||||||
|
4. Check the GitHub repository for updates and issues
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Quartz Documentation](https://quartz.jzhao.xyz/)
|
||||||
|
- [Quartz GitHub Repository](https://github.com/jackyzha0/quartz)
|
||||||
|
- [GitHub API Documentation](https://docs.github.com/en/rest)
|
||||||
|
- [Cloudflare R2 Documentation](https://developers.cloudflare.com/r2/)
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Sanitization Explanation
|
||||||
|
|
||||||
|
## Why Sanitization Exists
|
||||||
|
|
||||||
|
Sanitization is **necessary** because TLDraw has strict schema requirements that must be met for shapes to render correctly. Without sanitization, we get validation errors and broken shapes.
|
||||||
|
|
||||||
|
## Critical Fixes (MUST KEEP)
|
||||||
|
|
||||||
|
These fixes are **required** for TLDraw to work:
|
||||||
|
|
||||||
|
1. **Move w/h/geo from top-level to props for geo shapes**
|
||||||
|
- TLDraw schema requires `w`, `h`, and `geo` to be in `props`, not at the top level
|
||||||
|
- Without this, TLDraw throws validation errors
|
||||||
|
|
||||||
|
2. **Remove w/h from group shapes**
|
||||||
|
- Group shapes don't have `w`/`h` properties
|
||||||
|
- Having them causes validation errors
|
||||||
|
|
||||||
|
3. **Remove w/h from line shapes**
|
||||||
|
- Line shapes use `points`, not `w`/`h`
|
||||||
|
- Having them causes validation errors
|
||||||
|
|
||||||
|
4. **Fix richText structure**
|
||||||
|
- TLDraw requires `richText` to be `{ content: [...], type: 'doc' }`
|
||||||
|
- Old data might have it as an array or missing structure
|
||||||
|
- We preserve all content, just fix the structure
|
||||||
|
|
||||||
|
5. **Fix crop structure for image/video**
|
||||||
|
- TLDraw requires `crop` to be `{ topLeft: {x,y}, bottomRight: {x,y} }` or `null`
|
||||||
|
- Old data might have `{ x, y, w, h }` format
|
||||||
|
- We convert the format, preserving the crop area
|
||||||
|
|
||||||
|
6. **Remove h/geo from text shapes**
|
||||||
|
- Text shapes don't have `h` or `geo` properties
|
||||||
|
- Having them causes validation errors
|
||||||
|
|
||||||
|
7. **Ensure required properties exist**
|
||||||
|
- Some shapes require certain properties (e.g., `points` for line shapes)
|
||||||
|
- We only add defaults if truly missing
|
||||||
|
|
||||||
|
## What We Preserve
|
||||||
|
|
||||||
|
We **preserve all user data**:
|
||||||
|
- ✅ `richText` content (we only fix structure, never delete content)
|
||||||
|
- ✅ `text` property on arrows
|
||||||
|
- ✅ All metadata (`meta` object)
|
||||||
|
- ✅ All valid shape properties
|
||||||
|
- ✅ Custom shape properties
|
||||||
|
|
||||||
|
## What We Remove (Only When Necessary)
|
||||||
|
|
||||||
|
We only remove properties that:
|
||||||
|
1. **Cause validation errors** (e.g., `w`/`h` on groups/lines)
|
||||||
|
2. **Are invalid for the shape type** (e.g., `geo` on text shapes)
|
||||||
|
|
||||||
|
We **never** remove:
|
||||||
|
- User-created content (text, richText)
|
||||||
|
- Valid metadata
|
||||||
|
- Properties that don't cause errors
|
||||||
|
|
||||||
|
## Current Sanitization Locations
|
||||||
|
|
||||||
|
1. **TLStoreToAutomerge.ts** - When saving from TLDraw to Automerge
|
||||||
|
- Minimal fixes only
|
||||||
|
- Preserves all data
|
||||||
|
|
||||||
|
2. **AutomergeToTLStore.ts** - When loading from Automerge to TLDraw
|
||||||
|
- Minimal fixes only
|
||||||
|
- Preserves all data
|
||||||
|
|
||||||
|
3. **useAutomergeStoreV2.ts** - Initial load processing
|
||||||
|
- More extensive (handles migration from old formats)
|
||||||
|
- Still preserves all user data
|
||||||
|
|
||||||
|
## Can We Simplify?
|
||||||
|
|
||||||
|
**Yes, but carefully:**
|
||||||
|
|
||||||
|
1. ✅ We can remove property deletions that don't cause validation errors
|
||||||
|
2. ✅ We can consolidate duplicate logic
|
||||||
|
3. ❌ We **cannot** remove schema fixes (w/h/geo movement, richText structure)
|
||||||
|
4. ❌ We **cannot** remove property deletions that cause validation errors
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Keep sanitization but:
|
||||||
|
1. Only delete properties that **actually cause validation errors**
|
||||||
|
2. Preserve all user data (text, richText, metadata)
|
||||||
|
3. Consolidate duplicate logic between files
|
||||||
|
4. Add comments explaining why each fix is necessary
|
||||||
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
# TLDraw Interactive Elements - Z-Index Requirements
|
||||||
|
|
||||||
|
## Important Note for Developers
|
||||||
|
|
||||||
|
When creating tldraw shapes that contain interactive elements (buttons, inputs, links, etc.), you **MUST** set appropriate z-index values to ensure these elements are clickable and accessible.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
TLDraw's canvas has its own event handling and layering system. Interactive elements within custom shapes can be blocked by the canvas's event listeners, making them unclickable or unresponsive.
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
Always add the following CSS properties to interactive elements:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.interactive-element {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1000; /* or higher if needed */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
```css
|
||||||
|
.custom-button {
|
||||||
|
/* ... other styles ... */
|
||||||
|
position: relative;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input Fields
|
||||||
|
```css
|
||||||
|
.custom-input {
|
||||||
|
/* ... other styles ... */
|
||||||
|
position: relative;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Links
|
||||||
|
```css
|
||||||
|
.custom-link {
|
||||||
|
/* ... other styles ... */
|
||||||
|
position: relative;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Z-Index Guidelines
|
||||||
|
|
||||||
|
- **1000**: Standard interactive elements (buttons, inputs, links)
|
||||||
|
- **1001-1999**: Dropdowns, modals, tooltips
|
||||||
|
- **2000+**: Critical overlays, error messages
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
Before deploying any tldraw shape with interactive elements:
|
||||||
|
|
||||||
|
- [ ] Test clicking all buttons/links
|
||||||
|
- [ ] Test input field focus and typing
|
||||||
|
- [ ] Test hover states
|
||||||
|
- [ ] Test on different screen sizes
|
||||||
|
- [ ] Verify elements work when shape is selected/deselected
|
||||||
|
- [ ] Verify elements work when shape is moved/resized
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
1. **Elements appear clickable but don't respond** → Add z-index
|
||||||
|
2. **Hover states don't work** → Add z-index
|
||||||
|
3. **Elements work sometimes but not others** → Check z-index conflicts
|
||||||
|
4. **Mobile touch events don't work** → Ensure z-index is high enough
|
||||||
|
|
||||||
|
## Files to Remember
|
||||||
|
|
||||||
|
This note should be updated whenever new interactive elements are added to tldraw shapes. Current shapes with interactive elements:
|
||||||
|
|
||||||
|
- `src/components/TranscribeComponent.tsx` - Copy button (z-index: 1000)
|
||||||
|
|
||||||
|
## Last Updated
|
||||||
|
|
||||||
|
Created: [Current Date]
|
||||||
|
Last Updated: [Current Date]
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Transcription Setup Guide
|
||||||
|
|
||||||
|
## Why the Start Button Doesn't Work
|
||||||
|
|
||||||
|
The transcription start button is likely disabled because the **OpenAI API key is not configured**. The button will be disabled and show a tooltip "OpenAI API key not configured - Please set your API key in settings" when this is the case.
|
||||||
|
|
||||||
|
## How to Fix It
|
||||||
|
|
||||||
|
### Step 1: Get an OpenAI API Key
|
||||||
|
1. Go to [OpenAI API Keys](https://platform.openai.com/api-keys)
|
||||||
|
2. Sign in to your OpenAI account
|
||||||
|
3. Click "Create new secret key"
|
||||||
|
4. Copy the API key (it starts with `sk-`)
|
||||||
|
|
||||||
|
### Step 2: Configure the API Key in Canvas
|
||||||
|
1. In your Canvas application, look for the **Settings** button (usually a gear icon)
|
||||||
|
2. Open the settings dialog
|
||||||
|
3. Find the **OpenAI API Key** field
|
||||||
|
4. Paste your API key
|
||||||
|
5. Save the settings
|
||||||
|
|
||||||
|
### Step 3: Test the Transcription
|
||||||
|
1. Create a transcription shape on the canvas
|
||||||
|
2. Click the "Start" button
|
||||||
|
3. Allow microphone access when prompted
|
||||||
|
4. Start speaking - you should see the transcription appear in real-time
|
||||||
|
|
||||||
|
## Debugging Information
|
||||||
|
|
||||||
|
The application now includes debug logging to help identify issues:
|
||||||
|
|
||||||
|
- **Console Logs**: Check the browser console for messages starting with `🔧 OpenAI Config Debug:`
|
||||||
|
- **Visual Indicators**: The transcription window will show "(API Key Required)" if not configured
|
||||||
|
- **Button State**: The start button will be disabled and grayed out if the API key is missing
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Button Still Disabled After Adding API Key
|
||||||
|
1. Refresh the page to reload the configuration
|
||||||
|
2. Check the browser console for any error messages
|
||||||
|
3. Verify the API key is correctly saved in settings
|
||||||
|
|
||||||
|
### Microphone Permission Issues
|
||||||
|
1. Make sure you've granted microphone access to the browser
|
||||||
|
2. Check that your microphone is working in other applications
|
||||||
|
3. Try refreshing the page and granting permission again
|
||||||
|
|
||||||
|
### No Audio Being Recorded
|
||||||
|
1. Check the browser console for audio-related error messages
|
||||||
|
2. Verify your microphone is not being used by another application
|
||||||
|
3. Try using a different browser if issues persist
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
The transcription system:
|
||||||
|
- Uses the device microphone directly (not Daily room audio)
|
||||||
|
- Records audio in WebM format
|
||||||
|
- Sends audio chunks to OpenAI's Whisper API
|
||||||
|
- Updates the transcription shape in real-time
|
||||||
|
- Requires a valid OpenAI API key to function
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Worker Environment Switching Guide
|
||||||
|
|
||||||
|
## Quick Switch Commands
|
||||||
|
|
||||||
|
### Switch to Dev Environment (Default)
|
||||||
|
```bash
|
||||||
|
./switch-worker-env.sh dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Switch to Production Environment
|
||||||
|
```bash
|
||||||
|
./switch-worker-env.sh production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Switch to Local Environment
|
||||||
|
```bash
|
||||||
|
./switch-worker-env.sh local
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Switching
|
||||||
|
|
||||||
|
You can also manually edit the environment by:
|
||||||
|
|
||||||
|
1. **Option 1**: Set environment variable
|
||||||
|
```bash
|
||||||
|
export VITE_WORKER_ENV=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Option 2**: Edit `.env.local` file
|
||||||
|
```
|
||||||
|
VITE_WORKER_ENV=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Option 3**: Edit `src/constants/workerUrl.ts` directly
|
||||||
|
```typescript
|
||||||
|
const WORKER_ENV = 'dev' // Change this line
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Environments
|
||||||
|
|
||||||
|
| Environment | URL | Description |
|
||||||
|
|-------------|-----|-------------|
|
||||||
|
| `local` | `http://localhost:5172` | Local worker (requires `npm run dev:worker:local`) |
|
||||||
|
| `dev` | `https://jeffemmett-canvas-automerge-dev.jeffemmett.workers.dev` | Cloudflare dev environment |
|
||||||
|
| `production` | `https://jeffemmett-canvas.jeffemmett.workers.dev` | Production environment |
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
- ✅ **Dev Environment**: Working with AutomergeDurableObject
|
||||||
|
- ✅ **R2 Data Loading**: Fixed format conversion
|
||||||
|
- ✅ **WebSocket**: Improved with keep-alive and reconnection
|
||||||
|
- 🔄 **Production**: Ready to deploy when testing is complete
|
||||||
|
|
||||||
|
## Testing the Fix
|
||||||
|
|
||||||
|
1. Switch to dev environment: `./switch-worker-env.sh dev`
|
||||||
|
2. Start your frontend: `npm run dev`
|
||||||
|
3. Check browser console for environment logs
|
||||||
|
4. Test R2 data loading in your canvas app
|
||||||
|
5. Verify WebSocket connections are stable
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Cloudflare Pages redirects and rewrites
|
||||||
|
# This file handles SPA routing and URL rewrites (replaces vercel.json rewrites)
|
||||||
|
|
||||||
|
# SPA fallback - all routes should serve index.html
|
||||||
|
/* /index.html 200
|
||||||
|
|
||||||
|
# Specific route rewrites (matching vercel.json)
|
||||||
|
/board/* /index.html 200
|
||||||
|
/board /index.html 200
|
||||||
|
/inbox /index.html 200
|
||||||
|
/contact /index.html 200
|
||||||
|
/presentations /index.html 200
|
||||||
|
/dashboard /index.html 200
|
||||||
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity ^0.8.20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @title WebCryptoProxy
|
||||||
|
* @notice Minimal proxy contract that verifies Web Crypto API (P-256) signatures
|
||||||
|
* and executes transactions. Designed to minimize gas costs and complexity.
|
||||||
|
* @dev Each user deploys their own proxy contract, which stores their Web Crypto public key
|
||||||
|
* and verifies P-256 signatures before executing transactions.
|
||||||
|
*/
|
||||||
|
contract WebCryptoProxy {
|
||||||
|
// Web Crypto P-256 public key (stored as bytes32 for gas efficiency)
|
||||||
|
// P-256 public keys are 65 bytes (0x04 || 32-byte X || 32-byte Y)
|
||||||
|
// We store the X coordinate (32 bytes) and Y coordinate (32 bytes) separately
|
||||||
|
bytes32 public publicKeyX;
|
||||||
|
bytes32 public publicKeyY;
|
||||||
|
|
||||||
|
// Replay protection: nonce tracking
|
||||||
|
mapping(uint256 => bool) public usedNonces;
|
||||||
|
|
||||||
|
// Events
|
||||||
|
event TransactionExecuted(
|
||||||
|
address indexed to,
|
||||||
|
uint256 value,
|
||||||
|
bytes data,
|
||||||
|
uint256 nonce
|
||||||
|
);
|
||||||
|
|
||||||
|
event PublicKeySet(bytes32 indexed publicKeyX, bytes32 indexed publicKeyY);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Constructor sets the Web Crypto public key
|
||||||
|
* @param _publicKeyX X coordinate of P-256 public key (32 bytes)
|
||||||
|
* @param _publicKeyY Y coordinate of P-256 public key (32 bytes)
|
||||||
|
*/
|
||||||
|
constructor(bytes32 _publicKeyX, bytes32 _publicKeyY) {
|
||||||
|
publicKeyX = _publicKeyX;
|
||||||
|
publicKeyY = _publicKeyY;
|
||||||
|
emit PublicKeySet(_publicKeyX, _publicKeyY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Execute a transaction if signature is valid
|
||||||
|
* @param to Target address for the transaction
|
||||||
|
* @param value Amount of ETH to send (in wei)
|
||||||
|
* @param data Transaction data (contract call data)
|
||||||
|
* @param nonce Unique nonce for replay protection
|
||||||
|
* @param deadline Transaction expiration timestamp
|
||||||
|
* @param signature Web Crypto P-256 signature (r, s, v format)
|
||||||
|
* @dev Signature verification uses P-256 curve, not secp256k1
|
||||||
|
* This requires a custom verification library or precompile
|
||||||
|
*/
|
||||||
|
function execute(
|
||||||
|
address to,
|
||||||
|
uint256 value,
|
||||||
|
bytes calldata data,
|
||||||
|
uint256 nonce,
|
||||||
|
uint256 deadline,
|
||||||
|
bytes calldata signature
|
||||||
|
) external {
|
||||||
|
// Check deadline
|
||||||
|
require(block.timestamp <= deadline, "WebCryptoProxy: Transaction expired");
|
||||||
|
|
||||||
|
// Check nonce hasn't been used
|
||||||
|
require(!usedNonces[nonce], "WebCryptoProxy: Nonce already used");
|
||||||
|
|
||||||
|
// Mark nonce as used
|
||||||
|
usedNonces[nonce] = true;
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
// Note: P-256 signature verification requires a library or precompile
|
||||||
|
// For now, this is a placeholder - you'll need to implement or import
|
||||||
|
// a P-256 verification function
|
||||||
|
bytes32 messageHash = keccak256(
|
||||||
|
abi.encodePacked(
|
||||||
|
"\x19\x01", // EIP-712 prefix
|
||||||
|
keccak256(abi.encode(
|
||||||
|
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
|
||||||
|
keccak256(bytes("WebCryptoProxy")),
|
||||||
|
keccak256(bytes("1")),
|
||||||
|
block.chainid,
|
||||||
|
address(this)
|
||||||
|
)),
|
||||||
|
keccak256(abi.encode(
|
||||||
|
keccak256("Transaction(address to,uint256 value,bytes data,uint256 nonce,uint256 deadline)"),
|
||||||
|
to,
|
||||||
|
value,
|
||||||
|
keccak256(data),
|
||||||
|
nonce,
|
||||||
|
deadline
|
||||||
|
))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Verify P-256 signature
|
||||||
|
// This requires a P-256 signature verification library
|
||||||
|
// Options:
|
||||||
|
// 1. Use a precompile (if available on your chain)
|
||||||
|
// 2. Import a P-256 verification library
|
||||||
|
// 3. Use a verification contract
|
||||||
|
// For now, we'll use a simplified check
|
||||||
|
// In production, replace this with proper P-256 verification
|
||||||
|
require(verifyP256Signature(messageHash, signature), "WebCryptoProxy: Invalid signature");
|
||||||
|
|
||||||
|
// Execute the transaction
|
||||||
|
(bool success, ) = to.call{value: value}(data);
|
||||||
|
require(success, "WebCryptoProxy: Transaction failed");
|
||||||
|
|
||||||
|
emit TransactionExecuted(to, value, data, nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Verify P-256 signature
|
||||||
|
* @dev This is a placeholder - implement with proper P-256 verification
|
||||||
|
* You can use libraries like:
|
||||||
|
* - A precompile if your chain supports it
|
||||||
|
* - A P-256 verification library (e.g., from OpenZeppelin or custom)
|
||||||
|
* - An external verification contract
|
||||||
|
* @param messageHash The message hash to verify
|
||||||
|
* @param signature The signature (needs to be parsed for r, s, v)
|
||||||
|
* @return true if signature is valid
|
||||||
|
*/
|
||||||
|
function verifyP256Signature(
|
||||||
|
bytes32 messageHash,
|
||||||
|
bytes calldata signature
|
||||||
|
) internal view returns (bool) {
|
||||||
|
// TODO: Implement P-256 signature verification
|
||||||
|
// For now, this is a placeholder that always returns false for safety
|
||||||
|
// In production, you must implement proper P-256 verification
|
||||||
|
|
||||||
|
// Example structure (you'll need to adapt based on your verification method):
|
||||||
|
// 1. Parse signature to extract r, s, v (or r, s for P-256)
|
||||||
|
// 2. Recover public key from signature
|
||||||
|
// 3. Compare recovered public key with stored publicKeyX and publicKeyY
|
||||||
|
// 4. Return true if they match
|
||||||
|
|
||||||
|
// Placeholder: This will reject all signatures until properly implemented
|
||||||
|
// Remove this and implement actual verification
|
||||||
|
revert("WebCryptoProxy: P-256 verification not yet implemented");
|
||||||
|
|
||||||
|
// Uncomment and implement when you have P-256 verification:
|
||||||
|
// bytes32 r = ...;
|
||||||
|
// bytes32 s = ...;
|
||||||
|
// (bytes32 recoveredX, bytes32 recoveredY) = recoverP256PublicKey(messageHash, r, s);
|
||||||
|
// return (recoveredX == publicKeyX && recoveredY == publicKeyY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Receive ETH
|
||||||
|
*/
|
||||||
|
receive() external payable {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Fallback function
|
||||||
|
*/
|
||||||
|
fallback() external payable {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity ^0.8.20;
|
||||||
|
|
||||||
|
import "./WebCryptoProxy.sol";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @title WebCryptoProxyFactory
|
||||||
|
* @notice Factory contract to deploy minimal proxy contracts for users
|
||||||
|
* @dev Uses CREATE2 for deterministic addresses based on public key
|
||||||
|
*/
|
||||||
|
contract WebCryptoProxyFactory {
|
||||||
|
// Mapping from public key hash to proxy address
|
||||||
|
mapping(bytes32 => address) public proxies;
|
||||||
|
|
||||||
|
// Event emitted when a new proxy is deployed
|
||||||
|
event ProxyDeployed(
|
||||||
|
bytes32 indexed publicKeyHash,
|
||||||
|
address indexed proxy,
|
||||||
|
bytes32 publicKeyX,
|
||||||
|
bytes32 publicKeyY
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Deploy a new proxy contract for a Web Crypto public key
|
||||||
|
* @param publicKeyX X coordinate of P-256 public key
|
||||||
|
* @param publicKeyY Y coordinate of P-256 public key
|
||||||
|
* @return proxy The address of the deployed proxy contract
|
||||||
|
*/
|
||||||
|
function deployProxy(
|
||||||
|
bytes32 publicKeyX,
|
||||||
|
bytes32 publicKeyY
|
||||||
|
) external returns (address proxy) {
|
||||||
|
// Create hash of public key for mapping
|
||||||
|
bytes32 publicKeyHash = keccak256(abi.encodePacked(publicKeyX, publicKeyY));
|
||||||
|
|
||||||
|
// Check if proxy already exists
|
||||||
|
require(proxies[publicKeyHash] == address(0), "WebCryptoProxyFactory: Proxy already exists");
|
||||||
|
|
||||||
|
// Deploy new proxy
|
||||||
|
proxy = address(new WebCryptoProxy(publicKeyX, publicKeyY));
|
||||||
|
|
||||||
|
// Store mapping
|
||||||
|
proxies[publicKeyHash] = proxy;
|
||||||
|
|
||||||
|
emit ProxyDeployed(publicKeyHash, proxy, publicKeyX, publicKeyY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Get proxy address for a public key
|
||||||
|
* @param publicKeyX X coordinate of P-256 public key
|
||||||
|
* @param publicKeyY Y coordinate of P-256 public key
|
||||||
|
* @return The proxy address, or address(0) if not deployed
|
||||||
|
*/
|
||||||
|
function getProxy(
|
||||||
|
bytes32 publicKeyX,
|
||||||
|
bytes32 publicKeyY
|
||||||
|
) external view returns (address) {
|
||||||
|
bytes32 publicKeyHash = keccak256(abi.encodePacked(publicKeyX, publicKeyY));
|
||||||
|
return proxies[publicKeyHash];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
# Blockchain Integration Implementation Summary
|
||||||
|
|
||||||
|
## Completed Components
|
||||||
|
|
||||||
|
### 1. Core Blockchain Signing Module ✅
|
||||||
|
- **File**: `src/lib/auth/cryptoBlockchain.ts`
|
||||||
|
- EIP-712 structured data signing
|
||||||
|
- Transaction authorization message creation
|
||||||
|
- Signature formatting utilities
|
||||||
|
- Key pair retrieval for signing
|
||||||
|
|
||||||
|
### 2. Ethereum Integration ✅
|
||||||
|
- **File**: `src/lib/blockchain/ethereum.ts`
|
||||||
|
- Wallet connection (MetaMask, etc.)
|
||||||
|
- Transaction building and submission
|
||||||
|
- Chain management (mainnet, sepolia, goerli)
|
||||||
|
- Proxy contract interaction utilities
|
||||||
|
|
||||||
|
### 3. Account Linking Service ✅
|
||||||
|
- **File**: `src/lib/auth/blockchainLinking.ts`
|
||||||
|
- Links Web Crypto accounts with Ethereum addresses
|
||||||
|
- Stores linked account mappings in localStorage
|
||||||
|
- Account verification utilities
|
||||||
|
|
||||||
|
### 4. Wallet Integration ✅
|
||||||
|
- **File**: `src/lib/blockchain/walletIntegration.ts`
|
||||||
|
- Proxy contract deployment preparation
|
||||||
|
- Public key coordinate extraction (P-256)
|
||||||
|
- Factory contract interaction
|
||||||
|
|
||||||
|
### 5. Key Storage ✅
|
||||||
|
- **File**: `src/lib/auth/keyStorage.ts`
|
||||||
|
- In-memory key pair storage (session-based)
|
||||||
|
- Placeholder for persistent storage implementation
|
||||||
|
- Key pair retrieval utilities
|
||||||
|
|
||||||
|
### 6. Smart Contracts ✅
|
||||||
|
- **File**: `contracts/WebCryptoProxy.sol`
|
||||||
|
- Minimal proxy contract for each user
|
||||||
|
- Stores P-256 public key
|
||||||
|
- Transaction execution with signature verification
|
||||||
|
- Replay protection via nonces
|
||||||
|
- Deadline enforcement
|
||||||
|
- **File**: `contracts/WebCryptoProxyFactory.sol`
|
||||||
|
- Factory for deploying proxy contracts
|
||||||
|
- Deterministic proxy addresses
|
||||||
|
|
||||||
|
### 7. React Context ✅
|
||||||
|
- **File**: `src/context/BlockchainContext.tsx`
|
||||||
|
- Blockchain state management
|
||||||
|
- Wallet connection state
|
||||||
|
- Linked account management
|
||||||
|
- Event listeners for wallet changes
|
||||||
|
|
||||||
|
### 8. UI Components ✅
|
||||||
|
- **File**: `src/components/auth/BlockchainLink.tsx`
|
||||||
|
- Link Web Crypto account with Ethereum wallet
|
||||||
|
- Connection status display
|
||||||
|
- **File**: `src/components/blockchain/WalletStatus.tsx`
|
||||||
|
- Display wallet connection status
|
||||||
|
- Show linked account information
|
||||||
|
- **File**: `src/components/blockchain/TransactionBuilder.tsx`
|
||||||
|
- Build and authorize transactions
|
||||||
|
- Web Crypto API signing integration
|
||||||
|
|
||||||
|
### 9. Documentation ✅
|
||||||
|
- **File**: `docs/BLOCKCHAIN_INTEGRATION.md`
|
||||||
|
- Complete integration guide
|
||||||
|
- Architecture overview
|
||||||
|
- Usage instructions
|
||||||
|
- Security considerations
|
||||||
|
|
||||||
|
## Dependencies Added
|
||||||
|
|
||||||
|
- `viem` - Ethereum interaction library
|
||||||
|
- `@noble/curves` - Cryptographic utilities (installed but not yet used for P-256 verification)
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### Updated Files
|
||||||
|
- `src/lib/auth/cryptoAuthService.ts` - Now stores key pairs during registration
|
||||||
|
- `src/lib/auth/crypto.ts` - Extended with blockchain utilities
|
||||||
|
|
||||||
|
## Known Limitations & TODOs
|
||||||
|
|
||||||
|
### Critical TODOs
|
||||||
|
|
||||||
|
1. **P-256 Signature Verification in Smart Contract**
|
||||||
|
- The `verifyP256Signature` function in `WebCryptoProxy.sol` is a placeholder
|
||||||
|
- Needs implementation using a P-256 verification library or precompile
|
||||||
|
- Options:
|
||||||
|
- Use `@noble/curves` compiled to Solidity
|
||||||
|
- Import a P-256 verification library
|
||||||
|
- Use a precompile (if available on target chain)
|
||||||
|
|
||||||
|
2. **Persistent Key Storage**
|
||||||
|
- Currently uses in-memory storage (lost on page refresh)
|
||||||
|
- Need to implement Web Crypto API's persistent key storage with IndexedDB
|
||||||
|
- See `src/lib/auth/keyStorage.ts` for TODO
|
||||||
|
|
||||||
|
3. **Nonce Management**
|
||||||
|
- Currently uses timestamp-based nonces
|
||||||
|
- Should query nonce from contract state
|
||||||
|
- Add nonce tracking utilities
|
||||||
|
|
||||||
|
4. **Proxy Contract Deployment**
|
||||||
|
- Factory address needs to be configured per chain
|
||||||
|
- Add deployment utilities
|
||||||
|
- Add proxy address lookup
|
||||||
|
|
||||||
|
### Nice-to-Have Features
|
||||||
|
|
||||||
|
- [ ] Support for multiple chains
|
||||||
|
- [ ] Transaction history tracking
|
||||||
|
- [ ] Gas estimation and optimization
|
||||||
|
- [ ] Error handling and retry logic
|
||||||
|
- [ ] Gnosis Safe multi-sig wallet support
|
||||||
|
- [ ] Transaction status monitoring
|
||||||
|
- [ ] Batch transaction support
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Connect wallet
|
||||||
|
const { connect, wallet } = useBlockchain();
|
||||||
|
await connect();
|
||||||
|
|
||||||
|
// 2. Link accounts
|
||||||
|
const { linkWebCryptoAccount } = useBlockchain();
|
||||||
|
await linkWebCryptoAccount(username);
|
||||||
|
|
||||||
|
// 3. Build and sign transaction
|
||||||
|
const authorization = {
|
||||||
|
to: '0x...',
|
||||||
|
value: '0',
|
||||||
|
data: '0x...',
|
||||||
|
nonce: Date.now(),
|
||||||
|
deadline: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
};
|
||||||
|
|
||||||
|
const signed = await signTransactionAuthorization(
|
||||||
|
privateKey,
|
||||||
|
authorization,
|
||||||
|
domain
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Wallet connection (MetaMask)
|
||||||
|
- [ ] Account linking flow
|
||||||
|
- [ ] Transaction signing with Web Crypto API
|
||||||
|
- [ ] Proxy contract deployment (once factory is deployed)
|
||||||
|
- [ ] Transaction execution through proxy
|
||||||
|
- [ ] Replay protection (nonce checking)
|
||||||
|
- [ ] Deadline enforcement
|
||||||
|
- [ ] Error handling
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Deploy `WebCryptoProxyFactory.sol` to a testnet
|
||||||
|
2. Update factory address in `walletIntegration.ts`
|
||||||
|
3. Implement P-256 signature verification in smart contract
|
||||||
|
4. Test end-to-end transaction flow
|
||||||
|
5. Implement persistent key storage
|
||||||
|
6. Add comprehensive error handling
|
||||||
|
7. Add transaction monitoring
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Private keys are stored in memory (session-based) - implement persistent storage for production
|
||||||
|
- P-256 signature verification must be properly implemented before production use
|
||||||
|
- Nonce management should use contract state, not timestamps
|
||||||
|
- Add rate limiting and gas limits to prevent abuse
|
||||||
|
- Implement proper error handling to prevent information leakage
|
||||||
|
|
||||||
|
|
@ -0,0 +1,316 @@
|
||||||
|
# Web Crypto API to Blockchain Integration
|
||||||
|
|
||||||
|
This document describes the implementation of linking Web Crypto API (ECDSA P-256) accounts with Ethereum-compatible wallets (MetaMask, Gnosis Safe) to enable blockchain transaction execution.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The integration uses a **minimal proxy contract pattern** where:
|
||||||
|
|
||||||
|
1. Each user deploys a lightweight proxy contract that stores their Web Crypto P-256 public key
|
||||||
|
2. Users sign transaction authorization messages with their Web Crypto API private key (P-256)
|
||||||
|
3. The proxy contract verifies the P-256 signature and executes the transaction
|
||||||
|
4. Transactions can be submitted either:
|
||||||
|
- **Through wallet** (MetaMask/Gnosis Safe) - User pays gas fees
|
||||||
|
- **Through relayer** (Gasless) - Relayer service pays gas fees on behalf of users
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
1. **Web Crypto API Signing** (`src/lib/auth/cryptoBlockchain.ts`)
|
||||||
|
- EIP-712 structured data signing
|
||||||
|
- Transaction authorization message creation
|
||||||
|
- Signature formatting for blockchain
|
||||||
|
|
||||||
|
2. **Ethereum Integration** (`src/lib/blockchain/ethereum.ts`)
|
||||||
|
- Wallet connection (MetaMask, etc.)
|
||||||
|
- Transaction building and submission
|
||||||
|
- Chain management
|
||||||
|
|
||||||
|
3. **Account Linking** (`src/lib/auth/blockchainLinking.ts`)
|
||||||
|
- Links Web Crypto accounts with Ethereum addresses
|
||||||
|
- Stores linked account mappings
|
||||||
|
- Verifies ownership of both keys
|
||||||
|
|
||||||
|
4. **Wallet Integration** (`src/lib/blockchain/walletIntegration.ts`)
|
||||||
|
- Proxy contract deployment
|
||||||
|
- Public key coordinate extraction
|
||||||
|
- Factory contract interaction
|
||||||
|
|
||||||
|
5. **Relayer Service** (`src/lib/blockchain/relayer.ts` & `worker/blockchainRelayer.ts`)
|
||||||
|
- Gasless transaction submission
|
||||||
|
- Relayer configuration management
|
||||||
|
- Cloudflare Worker relayer implementation
|
||||||
|
- Signature verification before submission
|
||||||
|
|
||||||
|
6. **Smart Contracts** (`contracts/`)
|
||||||
|
- `WebCryptoProxy.sol` - Minimal proxy contract for each user
|
||||||
|
- `WebCryptoProxyFactory.sol` - Factory for deploying proxies
|
||||||
|
|
||||||
|
7. **UI Components**
|
||||||
|
- `BlockchainLink.tsx` - Link Web Crypto account with wallet
|
||||||
|
- `WalletStatus.tsx` - Display connection status
|
||||||
|
- `TransactionBuilder.tsx` - Build and authorize transactions (with gasless option)
|
||||||
|
|
||||||
|
8. **Context** (`src/context/BlockchainContext.tsx`)
|
||||||
|
- React context for blockchain state management
|
||||||
|
- Wallet connection state
|
||||||
|
- Linked account management
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Challenge: P-256 vs secp256k1
|
||||||
|
|
||||||
|
- **Web Crypto API**: Uses ECDSA P-256 (NIST curve)
|
||||||
|
- **Ethereum**: Uses secp256k1 (different curve)
|
||||||
|
- **Solution**: Proxy contract verifies P-256 signatures on-chain
|
||||||
|
|
||||||
|
### Transaction Flow
|
||||||
|
|
||||||
|
#### Standard Flow (User Pays Gas)
|
||||||
|
|
||||||
|
1. User initiates transaction in the application
|
||||||
|
2. Application builds EIP-712 structured authorization message
|
||||||
|
3. Web Crypto API signs message with P-256 private key
|
||||||
|
4. Application constructs proxy contract call with signature
|
||||||
|
5. MetaMask/Gnosis Safe prompts user to submit transaction
|
||||||
|
6. Wallet submits transaction to proxy contract
|
||||||
|
7. Proxy contract verifies P-256 signature
|
||||||
|
8. If valid, proxy contract executes the transaction
|
||||||
|
9. User pays gas fees
|
||||||
|
|
||||||
|
#### Gasless Flow (Relayer Pays Gas)
|
||||||
|
|
||||||
|
1. User initiates transaction in the application
|
||||||
|
2. Application builds EIP-712 structured authorization message
|
||||||
|
3. Web Crypto API signs message with P-256 private key
|
||||||
|
4. User selects "Use gasless transaction" option
|
||||||
|
5. Application submits signed transaction to relayer service
|
||||||
|
6. Relayer verifies signature and transaction validity
|
||||||
|
7. Relayer submits transaction to proxy contract (pays gas)
|
||||||
|
8. Proxy contract verifies P-256 signature
|
||||||
|
9. If valid, proxy contract executes the transaction
|
||||||
|
10. User pays no gas fees
|
||||||
|
|
||||||
|
### EIP-712 Message Format
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
types: {
|
||||||
|
EIP712Domain: [...],
|
||||||
|
Transaction: [
|
||||||
|
{ name: 'to', type: 'address' },
|
||||||
|
{ name: 'value', type: 'uint256' },
|
||||||
|
{ name: 'data', type: 'bytes' },
|
||||||
|
{ name: 'nonce', type: 'uint256' },
|
||||||
|
{ name: 'deadline', type: 'uint256' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
domain: {
|
||||||
|
name: 'WebCryptoProxy',
|
||||||
|
version: '1',
|
||||||
|
chainId: number,
|
||||||
|
verifyingContract: string
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
to: string,
|
||||||
|
value: string,
|
||||||
|
data: string,
|
||||||
|
nonce: number,
|
||||||
|
deadline: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proxy Contract Structure
|
||||||
|
|
||||||
|
The minimal proxy contract (`WebCryptoProxy.sol`):
|
||||||
|
|
||||||
|
- Stores P-256 public key (X and Y coordinates)
|
||||||
|
- Verifies P-256 signatures
|
||||||
|
- Executes transactions when signature is valid
|
||||||
|
- Implements replay protection via nonces
|
||||||
|
- Enforces transaction deadlines
|
||||||
|
|
||||||
|
**Note**: The P-256 signature verification in the contract is currently a placeholder. You'll need to implement or import a P-256 verification library. Options include:
|
||||||
|
|
||||||
|
1. Use a precompile (if available on your chain)
|
||||||
|
2. Import a P-256 verification library (e.g., from OpenZeppelin)
|
||||||
|
3. Use an external verification contract
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install viem @noble/curves
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smart Contract Deployment
|
||||||
|
|
||||||
|
1. Deploy `WebCryptoProxyFactory.sol` to your target chain
|
||||||
|
2. Update factory address in `walletIntegration.ts`
|
||||||
|
3. Users deploy their proxy contracts via the factory
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
|
||||||
|
1. Wrap your app with `BlockchainProvider`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { BlockchainProvider } from './context/BlockchainContext';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BlockchainProvider>
|
||||||
|
{/* Your app */}
|
||||||
|
</BlockchainProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Use the blockchain context:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useBlockchain } from './context/BlockchainContext';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { wallet, connect, linkWebCryptoAccount } = useBlockchain();
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Linking Accounts
|
||||||
|
|
||||||
|
1. User logs in with Web Crypto API
|
||||||
|
2. User connects their Ethereum wallet (MetaMask, etc.)
|
||||||
|
3. User links accounts via `BlockchainLink` component
|
||||||
|
4. System stores the mapping between Web Crypto and Ethereum accounts
|
||||||
|
|
||||||
|
### Building Transactions
|
||||||
|
|
||||||
|
#### Standard Transaction (User Pays Gas)
|
||||||
|
|
||||||
|
1. User fills out transaction details in `TransactionBuilder`
|
||||||
|
2. Application creates EIP-712 authorization message
|
||||||
|
3. Web Crypto API signs the message
|
||||||
|
4. Application builds proxy contract call
|
||||||
|
5. Wallet prompts user to submit transaction
|
||||||
|
6. Transaction executes through proxy contract
|
||||||
|
7. User pays gas fees
|
||||||
|
|
||||||
|
#### Gasless Transaction (Relayer Pays Gas)
|
||||||
|
|
||||||
|
1. User fills out transaction details in `TransactionBuilder`
|
||||||
|
2. User checks "Use gasless transaction" checkbox
|
||||||
|
3. Application creates EIP-712 authorization message
|
||||||
|
4. Web Crypto API signs the message
|
||||||
|
5. Application submits to relayer service
|
||||||
|
6. Relayer verifies and submits transaction
|
||||||
|
7. Transaction executes through proxy contract
|
||||||
|
8. Relayer pays gas fees (user pays nothing)
|
||||||
|
|
||||||
|
**Note**: Gasless transactions require a relayer service to be deployed and configured. See [Gasless Transactions Setup](./GASLESS_TRANSACTIONS.md) for details.
|
||||||
|
|
||||||
|
## Gasless Transactions
|
||||||
|
|
||||||
|
The system supports gasless transactions through a relayer service. This allows users to execute blockchain transactions without paying gas fees.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **User signs** transaction with Web Crypto API
|
||||||
|
2. **Relayer receives** signed transaction request
|
||||||
|
3. **Relayer verifies** signature and transaction validity
|
||||||
|
4. **Relayer submits** transaction to blockchain (pays gas)
|
||||||
|
5. **Transaction executes** through proxy contract
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
See [Gasless Transactions Setup](./GASLESS_TRANSACTIONS.md) for complete setup instructions, including:
|
||||||
|
- Deploying the relayer worker
|
||||||
|
- Configuring relayer URLs
|
||||||
|
- Funding the relayer wallet
|
||||||
|
- Security considerations
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
Users can enable gasless transactions by checking the "Use gasless transaction" checkbox in the `TransactionBuilder` component. The system automatically falls back to wallet-based transactions if the relayer is unavailable.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Private Key Storage**: Web Crypto private keys should use secure storage (IndexedDB, Web Crypto API key storage)
|
||||||
|
2. **Signature Verification**: P-256 verification must be properly implemented in the smart contract
|
||||||
|
3. **Replay Protection**: Nonces prevent transaction replay attacks
|
||||||
|
4. **Deadline Enforcement**: Transactions expire after deadline
|
||||||
|
5. **Gas Limits**: Set reasonable gas limits for executed transactions
|
||||||
|
6. **Relayer Security**: Relayer private key must be stored securely (Cloudflare Workers secrets)
|
||||||
|
7. **Relayer Rate Limiting**: Implement rate limiting to prevent abuse of gasless transactions
|
||||||
|
8. **Relayer Monitoring**: Monitor relayer wallet balance and transaction costs
|
||||||
|
|
||||||
|
## Limitations & TODO
|
||||||
|
|
||||||
|
### Current Limitations
|
||||||
|
|
||||||
|
1. **P-256 Verification**: The smart contract's P-256 signature verification is not yet implemented
|
||||||
|
2. **Private Key Storage**: Currently uses simplified storage - needs secure key management
|
||||||
|
3. **Nonce Management**: Nonces are currently timestamp-based - should use contract state
|
||||||
|
4. **Proxy Deployment**: Factory address needs to be configured per chain
|
||||||
|
|
||||||
|
### TODO
|
||||||
|
|
||||||
|
- [ ] Implement P-256 signature verification in smart contract
|
||||||
|
- [ ] Add secure private key storage using Web Crypto API key storage
|
||||||
|
- [ ] Implement proper nonce management from contract state
|
||||||
|
- [ ] Add support for multiple chains
|
||||||
|
- [ ] Add transaction history tracking
|
||||||
|
- [ ] Add error handling and retry logic
|
||||||
|
- [ ] Add support for Gnosis Safe multi-sig wallets
|
||||||
|
- [ ] Add gas estimation and optimization
|
||||||
|
- [ ] Add transaction status monitoring
|
||||||
|
- [x] Implement gasless transactions via relayer service
|
||||||
|
- [ ] Add relayer rate limiting and abuse prevention
|
||||||
|
- [ ] Implement paymaster contract integration (alternative to relayer)
|
||||||
|
- [ ] Add relayer health monitoring and automatic failover
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib/
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── cryptoBlockchain.ts # Blockchain signing utilities
|
||||||
|
│ │ └── blockchainLinking.ts # Account linking service
|
||||||
|
│ └── blockchain/
|
||||||
|
│ ├── ethereum.ts # Ethereum integration
|
||||||
|
│ ├── walletIntegration.ts # Wallet integration
|
||||||
|
│ ├── relayer.ts # Gasless transaction relayer client
|
||||||
|
│ └── index.ts # Exports
|
||||||
|
├── components/
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ └── BlockchainLink.tsx # Link account UI
|
||||||
|
│ └── blockchain/
|
||||||
|
│ ├── WalletStatus.tsx # Status display
|
||||||
|
│ └── TransactionBuilder.tsx # Transaction builder (with gasless option)
|
||||||
|
├── context/
|
||||||
|
│ └── BlockchainContext.tsx # Blockchain state context
|
||||||
|
worker/
|
||||||
|
└── blockchainRelayer.ts # Cloudflare Worker relayer service
|
||||||
|
contracts/
|
||||||
|
├── WebCryptoProxy.sol # Minimal proxy contract
|
||||||
|
└── WebCryptoProxyFactory.sol # Factory contract
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Gasless Transactions Setup](./GASLESS_TRANSACTIONS.md) - Complete guide for setting up and using gasless transactions
|
||||||
|
- [Blockchain Implementation Summary](./BLOCKCHAIN_IMPLEMENTATION_SUMMARY.md) - Implementation details and status
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)
|
||||||
|
- [EIP-712: Typed Structured Data Hashing and Signing](https://eips.ethereum.org/EIPS/eip-712)
|
||||||
|
- [Viem Documentation](https://viem.sh/)
|
||||||
|
- [Ethereum Cryptography](https://github.com/ethereum/js-ethereum-cryptography)
|
||||||
|
- [ERC-4337: Account Abstraction](https://eips.ethereum.org/EIPS/eip-4337) - Alternative approach for gasless transactions
|
||||||
|
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
# Enhanced Audio Transcription with Speaker Identification
|
||||||
|
|
||||||
|
This document describes the enhanced audio transcription system that identifies different speakers and ensures complete transcript preservation in real-time.
|
||||||
|
|
||||||
|
## 🎯 Key Features
|
||||||
|
|
||||||
|
### 1. **Speaker Identification**
|
||||||
|
- **Voice Fingerprinting**: Uses audio analysis to create unique voice profiles for each speaker
|
||||||
|
- **Real-time Detection**: Automatically identifies when speakers change during conversation
|
||||||
|
- **Visual Indicators**: Each speaker gets a unique color and label for easy identification
|
||||||
|
- **Speaker Statistics**: Tracks speaking time and segment count for each participant
|
||||||
|
|
||||||
|
### 2. **Enhanced Transcript Structure**
|
||||||
|
- **Structured Segments**: Each transcript segment includes speaker ID, timestamps, and confidence scores
|
||||||
|
- **Complete Preservation**: No words are lost during real-time updates
|
||||||
|
- **Backward Compatibility**: Maintains legacy transcript format for existing integrations
|
||||||
|
- **Multiple Export Formats**: Support for text, JSON, and SRT subtitle formats
|
||||||
|
|
||||||
|
### 3. **Real-time Updates**
|
||||||
|
- **Live Speaker Detection**: Continuously monitors voice activity and speaker changes
|
||||||
|
- **Interim Text Display**: Shows partial results as they're being spoken
|
||||||
|
- **Smooth Transitions**: Seamless updates between interim and final transcript segments
|
||||||
|
- **Auto-scroll**: Automatically scrolls to show the latest content
|
||||||
|
|
||||||
|
## 🔧 Technical Implementation
|
||||||
|
|
||||||
|
### Audio Analysis System
|
||||||
|
|
||||||
|
The system uses advanced audio analysis to identify speakers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface VoiceCharacteristics {
|
||||||
|
pitch: number // Fundamental frequency
|
||||||
|
volume: number // Audio amplitude
|
||||||
|
spectralCentroid: number // Frequency distribution center
|
||||||
|
mfcc: number[] // Mel-frequency cepstral coefficients
|
||||||
|
zeroCrossingRate: number // Voice activity indicator
|
||||||
|
energy: number // Overall audio energy
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Speaker Identification Algorithm
|
||||||
|
|
||||||
|
1. **Voice Activity Detection**: Monitors audio levels to detect when someone is speaking
|
||||||
|
2. **Feature Extraction**: Analyzes voice characteristics in real-time
|
||||||
|
3. **Similarity Matching**: Compares current voice with known speaker profiles
|
||||||
|
4. **Profile Creation**: Creates new speaker profiles for unrecognized voices
|
||||||
|
5. **Confidence Scoring**: Assigns confidence levels to speaker identifications
|
||||||
|
|
||||||
|
### Transcript Management
|
||||||
|
|
||||||
|
The enhanced transcript system provides:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TranscriptSegment {
|
||||||
|
id: string // Unique segment identifier
|
||||||
|
speakerId: string // Associated speaker ID
|
||||||
|
speakerName: string // Display name for speaker
|
||||||
|
text: string // Transcribed text
|
||||||
|
startTime: number // Segment start time (ms)
|
||||||
|
endTime: number // Segment end time (ms)
|
||||||
|
confidence: number // Recognition confidence (0-1)
|
||||||
|
isFinal: boolean // Whether segment is finalized
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 User Interface Enhancements
|
||||||
|
|
||||||
|
### Speaker Display
|
||||||
|
- **Color-coded Labels**: Each speaker gets a unique color for easy identification
|
||||||
|
- **Speaker List**: Shows all identified speakers with speaking time statistics
|
||||||
|
- **Current Speaker Highlighting**: Highlights the currently speaking participant
|
||||||
|
- **Speaker Management**: Ability to rename speakers and manage their profiles
|
||||||
|
|
||||||
|
### Transcript Controls
|
||||||
|
- **Show/Hide Speaker Labels**: Toggle speaker name display
|
||||||
|
- **Show/Hide Timestamps**: Toggle timestamp display for each segment
|
||||||
|
- **Auto-scroll Toggle**: Control automatic scrolling behavior
|
||||||
|
- **Export Options**: Download transcripts in multiple formats
|
||||||
|
|
||||||
|
### Visual Indicators
|
||||||
|
- **Border Colors**: Each transcript segment has a colored border matching the speaker
|
||||||
|
- **Speaking Status**: Visual indicators show who is currently speaking
|
||||||
|
- **Interim Text**: Italicized, gray text shows partial results
|
||||||
|
- **Final Text**: Regular text shows confirmed transcript segments
|
||||||
|
|
||||||
|
## 📊 Data Export and Analysis
|
||||||
|
|
||||||
|
### Export Formats
|
||||||
|
|
||||||
|
1. **Text Format**:
|
||||||
|
```
|
||||||
|
[00:01:23] Speaker 1: Hello, how are you today?
|
||||||
|
[00:01:28] Speaker 2: I'm doing well, thank you for asking.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **JSON Format**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"segments": [...],
|
||||||
|
"speakers": [...],
|
||||||
|
"sessionStartTime": 1234567890,
|
||||||
|
"totalDuration": 300000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **SRT Subtitle Format**:
|
||||||
|
```
|
||||||
|
1
|
||||||
|
00:00:01,230 --> 00:00:05,180
|
||||||
|
Speaker 1: Hello, how are you today?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Statistics and Analytics
|
||||||
|
|
||||||
|
The system tracks comprehensive statistics:
|
||||||
|
- Total speaking time per speaker
|
||||||
|
- Number of segments per speaker
|
||||||
|
- Average segment length
|
||||||
|
- Session duration and timeline
|
||||||
|
- Recognition confidence scores
|
||||||
|
|
||||||
|
## 🔄 Real-time Processing Flow
|
||||||
|
|
||||||
|
1. **Audio Capture**: Microphone stream is captured and analyzed
|
||||||
|
2. **Voice Activity Detection**: System detects when someone starts/stops speaking
|
||||||
|
3. **Speaker Identification**: Voice characteristics are analyzed and matched to known speakers
|
||||||
|
4. **Speech Recognition**: Web Speech API processes audio into text
|
||||||
|
5. **Transcript Update**: New segments are added with speaker information
|
||||||
|
6. **UI Update**: Interface updates to show new content with speaker labels
|
||||||
|
|
||||||
|
## 🛠️ Configuration Options
|
||||||
|
|
||||||
|
### Audio Analysis Settings
|
||||||
|
- **Voice Activity Threshold**: Sensitivity for detecting speech
|
||||||
|
- **Silence Timeout**: Time before considering a speaker change
|
||||||
|
- **Similarity Threshold**: Minimum similarity for speaker matching
|
||||||
|
- **Feature Update Rate**: How often voice profiles are updated
|
||||||
|
|
||||||
|
### Display Options
|
||||||
|
- **Speaker Colors**: Customizable color palette for speakers
|
||||||
|
- **Timestamp Format**: Choose between different time display formats
|
||||||
|
- **Auto-scroll Behavior**: Control when and how auto-scrolling occurs
|
||||||
|
- **Segment Styling**: Customize visual appearance of transcript segments
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Speaker Not Identified**:
|
||||||
|
- Ensure good microphone quality
|
||||||
|
- Check for background noise
|
||||||
|
- Verify speaker is speaking clearly
|
||||||
|
- Allow time for voice profile creation
|
||||||
|
|
||||||
|
2. **Incorrect Speaker Assignment**:
|
||||||
|
- Check microphone positioning
|
||||||
|
- Verify audio quality
|
||||||
|
- Consider adjusting similarity threshold
|
||||||
|
- Manually rename speakers if needed
|
||||||
|
|
||||||
|
3. **Missing Transcript Segments**:
|
||||||
|
- Check internet connection stability
|
||||||
|
- Verify browser compatibility
|
||||||
|
- Ensure microphone permissions are granted
|
||||||
|
- Check for audio processing errors
|
||||||
|
|
||||||
|
### Performance Optimization
|
||||||
|
|
||||||
|
1. **Audio Quality**: Use high-quality microphones for better speaker identification
|
||||||
|
2. **Environment**: Minimize background noise for clearer voice analysis
|
||||||
|
3. **Browser**: Use Chrome or Chromium-based browsers for best performance
|
||||||
|
4. **Network**: Ensure stable internet connection for speech recognition
|
||||||
|
|
||||||
|
## 🚀 Future Enhancements
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
- **Machine Learning Integration**: Improved speaker identification using ML models
|
||||||
|
- **Voice Cloning Detection**: Identify when speakers are using voice modification
|
||||||
|
- **Emotion Recognition**: Detect emotional tone in speech
|
||||||
|
- **Language Detection**: Automatic language identification and switching
|
||||||
|
- **Cloud Processing**: Offload heavy processing to cloud services
|
||||||
|
|
||||||
|
### Integration Possibilities
|
||||||
|
- **Video Analysis**: Combine with video feeds for enhanced speaker detection
|
||||||
|
- **Meeting Platforms**: Integration with Zoom, Teams, and other platforms
|
||||||
|
- **AI Summarization**: Automatic meeting summaries with speaker attribution
|
||||||
|
- **Search and Indexing**: Full-text search across all transcript segments
|
||||||
|
|
||||||
|
## 📝 Usage Examples
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
1. Start a video chat session
|
||||||
|
2. Click the transcription button
|
||||||
|
3. Allow microphone access
|
||||||
|
4. Begin speaking - speakers will be automatically identified
|
||||||
|
5. View real-time transcript with speaker labels
|
||||||
|
|
||||||
|
### Advanced Features
|
||||||
|
1. **Customize Display**: Toggle speaker labels and timestamps
|
||||||
|
2. **Export Transcripts**: Download in your preferred format
|
||||||
|
3. **Manage Speakers**: Rename speakers for better organization
|
||||||
|
4. **Analyze Statistics**: View speaking time and participation metrics
|
||||||
|
|
||||||
|
### Integration with Other Tools
|
||||||
|
- **Meeting Notes**: Combine with note-taking tools
|
||||||
|
- **Action Items**: Extract action items with speaker attribution
|
||||||
|
- **Follow-up**: Use transcripts for meeting follow-up and documentation
|
||||||
|
- **Compliance**: Maintain records for regulatory requirements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*The enhanced transcription system provides a comprehensive solution for real-time speaker identification and transcript management, ensuring no spoken words are lost while providing rich metadata about conversation participants.*
|
||||||
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
# Gasless Transactions Setup
|
||||||
|
|
||||||
|
This document explains how to set up and use gasless transactions with the Web Crypto API blockchain integration.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Gasless transactions allow users to execute blockchain transactions without paying gas fees. Instead, a relayer service pays for the gas on behalf of users.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
1. **User signs transaction** with Web Crypto API (P-256)
|
||||||
|
2. **Relayer service** receives the signed transaction request
|
||||||
|
3. **Relayer verifies** the signature matches the user's Web Crypto public key
|
||||||
|
4. **Relayer submits** the transaction to the blockchain and pays gas
|
||||||
|
5. **Transaction executes** through the proxy contract
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Relayer Service (`worker/blockchainRelayer.ts`)
|
||||||
|
|
||||||
|
A Cloudflare Worker that:
|
||||||
|
- Accepts signed transaction requests via HTTP POST
|
||||||
|
- Verifies Web Crypto API signatures (P-256)
|
||||||
|
- Submits transactions to the blockchain
|
||||||
|
- Pays for gas fees
|
||||||
|
|
||||||
|
### 2. Client Relayer Library (`src/lib/blockchain/relayer.ts`)
|
||||||
|
|
||||||
|
Client-side utilities for:
|
||||||
|
- Submitting transactions to the relayer
|
||||||
|
- Checking relayer availability
|
||||||
|
- Managing relayer configuration
|
||||||
|
|
||||||
|
### 3. Transaction Builder (`src/components/blockchain/TransactionBuilder.tsx`)
|
||||||
|
|
||||||
|
UI component with option to use gasless transactions via checkbox.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Step 1: Deploy Relayer Worker
|
||||||
|
|
||||||
|
1. Add the relayer worker to your `wrangler.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[workers]]
|
||||||
|
name = "blockchain-relayer"
|
||||||
|
route = "/relayer/*"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set up the relayer private key as a Cloudflare Workers secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler secret put RELAYER_PRIVATE_KEY
|
||||||
|
# Enter your relayer wallet's private key (0x...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Security Warning**: The relayer private key must be kept secure. It will be used to pay for all gas fees.
|
||||||
|
|
||||||
|
### Step 2: Fund Relayer Wallet
|
||||||
|
|
||||||
|
The relayer wallet needs ETH to pay for gas:
|
||||||
|
- For mainnet: Send ETH to the relayer address
|
||||||
|
- For testnets: Get testnet ETH from faucets
|
||||||
|
|
||||||
|
### Step 3: Configure Relayer URL
|
||||||
|
|
||||||
|
In your application, configure the relayer URL for each chain:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { setRelayerConfig } from './lib/blockchain/relayer';
|
||||||
|
|
||||||
|
// Set relayer URL for a chain
|
||||||
|
setRelayerConfig(1, 'https://your-worker.workers.dev/relayer'); // Mainnet
|
||||||
|
setRelayerConfig(11155111, 'https://your-worker.workers.dev/relayer'); // Sepolia
|
||||||
|
```
|
||||||
|
|
||||||
|
Or set it in `relayer.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const relayerUrls: Record<number, string> = {
|
||||||
|
1: 'https://your-worker.workers.dev/relayer',
|
||||||
|
11155111: 'https://your-worker.workers.dev/relayer',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Update Worker Routes
|
||||||
|
|
||||||
|
Add the relayer route to your main worker or deploy it as a separate worker:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In your main worker.ts
|
||||||
|
if (url.pathname.startsWith('/relayer')) {
|
||||||
|
return await blockchainRelayer.fetch(request, env);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
|
||||||
|
1. Build a transaction in the Transaction Builder component
|
||||||
|
2. Check the "Use gasless transaction" checkbox
|
||||||
|
3. Sign with Web Crypto API
|
||||||
|
4. Transaction is submitted to relayer (no wallet confirmation needed)
|
||||||
|
5. Relayer pays for gas and submits transaction
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { submitToRelayer, getRelayerConfig } from './lib/blockchain/relayer';
|
||||||
|
|
||||||
|
// Check if relayer is available
|
||||||
|
const relayerConfig = getRelayerConfig(chainId);
|
||||||
|
if (relayerConfig) {
|
||||||
|
// Submit to relayer
|
||||||
|
const result = await submitToRelayer(relayerConfig, {
|
||||||
|
authorization: signedAuthorization,
|
||||||
|
signature: signature,
|
||||||
|
proxyContractAddress: proxyAddress,
|
||||||
|
chainId: chainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('Transaction hash:', result.transactionHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### 1. Relayer Private Key Security
|
||||||
|
- Store private key as Cloudflare Workers secret (never in code)
|
||||||
|
- Use a dedicated wallet with limited funds
|
||||||
|
- Monitor relayer wallet balance
|
||||||
|
- Set up alerts for low balance
|
||||||
|
|
||||||
|
### 2. Signature Verification
|
||||||
|
- The relayer must verify Web Crypto P-256 signatures
|
||||||
|
- Currently, this verification happens in the proxy contract
|
||||||
|
- Consider adding additional verification in the relayer for early rejection
|
||||||
|
|
||||||
|
### 3. Rate Limiting
|
||||||
|
- Implement rate limiting to prevent abuse
|
||||||
|
- Limit transactions per user/IP
|
||||||
|
- Set maximum gas limits per transaction
|
||||||
|
|
||||||
|
### 4. Nonce Management
|
||||||
|
- Ensure nonces are properly managed to prevent replay attacks
|
||||||
|
- The proxy contract handles nonce checking, but relayer should also validate
|
||||||
|
|
||||||
|
### 5. Deadline Enforcement
|
||||||
|
- Relayer should check transaction deadlines before submission
|
||||||
|
- Reject expired transactions
|
||||||
|
|
||||||
|
## Cost Management
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
- Track gas costs per transaction
|
||||||
|
- Monitor relayer wallet balance
|
||||||
|
- Set up automatic refilling if balance is low
|
||||||
|
|
||||||
|
### Limits
|
||||||
|
- Set maximum gas price limits
|
||||||
|
- Set maximum transaction value limits
|
||||||
|
- Implement daily/monthly spending limits
|
||||||
|
|
||||||
|
### Funding
|
||||||
|
- Consider using a paymaster contract (e.g., Pimlico, Alchemy) for more sophisticated gas management
|
||||||
|
- Implement user deposits if needed
|
||||||
|
- Consider subscription models for gasless transactions
|
||||||
|
|
||||||
|
## Alternative: Paymaster Contracts
|
||||||
|
|
||||||
|
Instead of a relayer service, you could use paymaster contracts:
|
||||||
|
|
||||||
|
1. **ERC-4337 Account Abstraction**: Use a paymaster contract that sponsors transactions
|
||||||
|
2. **Pimlico**: Third-party paymaster service
|
||||||
|
3. **Alchemy Gas Manager**: Gas sponsorship service
|
||||||
|
|
||||||
|
These services handle gas payment on-chain rather than through a relayer.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Relayer Not Available
|
||||||
|
- Check relayer URL configuration
|
||||||
|
- Verify relayer worker is deployed
|
||||||
|
- Check relayer health endpoint: `GET /relayer/health`
|
||||||
|
|
||||||
|
### Transaction Fails
|
||||||
|
- Check relayer wallet has sufficient balance
|
||||||
|
- Verify signature is valid
|
||||||
|
- Check transaction deadline hasn't expired
|
||||||
|
- Verify proxy contract is deployed
|
||||||
|
|
||||||
|
### High Gas Costs
|
||||||
|
- Monitor gas prices
|
||||||
|
- Consider using Layer 2 networks (lower gas)
|
||||||
|
- Implement gas price limits
|
||||||
|
- Use EIP-1559 for better gas estimation
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Implement paymaster contract integration
|
||||||
|
- [ ] Add user deposit system for gasless transactions
|
||||||
|
- [ ] Implement rate limiting and abuse prevention
|
||||||
|
- [ ] Add gas price monitoring and limits
|
||||||
|
- [ ] Support for multiple relayer endpoints (load balancing)
|
||||||
|
- [ ] Transaction batching for efficiency
|
||||||
|
- [ ] Support for Layer 2 networks (Arbitrum, Optimism, etc.)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
# Obsidian Vault Integration
|
||||||
|
|
||||||
|
This document describes the Obsidian vault integration feature that allows you to import and work with your Obsidian notes directly on the canvas.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Vault Import**: Load your local Obsidian vault using the File System Access API
|
||||||
|
- **Searchable Interface**: Browse and search through all your obs_notes with real-time filtering
|
||||||
|
- **Tag-based Filtering**: Filter obs_notes by tags for better organization
|
||||||
|
- **Canvas Integration**: Drag obs_notes from the browser directly onto the canvas as rectangle shapes
|
||||||
|
- **Rich ObsNote Display**: ObsNotes show title, content preview, tags, and metadata
|
||||||
|
- **Markdown Rendering**: Support for basic markdown formatting in obs_note previews
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
### 1. Access the Obsidian Browser
|
||||||
|
|
||||||
|
You can access the Obsidian browser in multiple ways:
|
||||||
|
|
||||||
|
- **Toolbar Button**: Click the "Obsidian Note" button in the toolbar (file-text icon)
|
||||||
|
- **Context Menu**: Right-click on the canvas and select "Open Obsidian Browser"
|
||||||
|
- **Keyboard Shortcut**: Press `Alt+O` to open the browser
|
||||||
|
- **Tool Selection**: Select the "Obsidian Note" tool from the toolbar or context menu
|
||||||
|
|
||||||
|
This will open the Obsidian Vault Browser overlay
|
||||||
|
|
||||||
|
### 2. Load Your Vault
|
||||||
|
|
||||||
|
The browser will attempt to use the File System Access API to let you select your Obsidian vault directory. If this isn't supported in your browser, it will fall back to demo data.
|
||||||
|
|
||||||
|
**Supported Browsers for File System Access API:**
|
||||||
|
- Chrome 86+
|
||||||
|
- Edge 86+
|
||||||
|
- Opera 72+
|
||||||
|
|
||||||
|
### 3. Browse and Search ObsNotes
|
||||||
|
|
||||||
|
- **Search**: Use the search box to find obs_notes by title, content, or tags
|
||||||
|
- **Filter by Tags**: Click on any tag to filter obs_notes by that tag
|
||||||
|
- **Clear Filters**: Click "Clear Filters" to remove all active filters
|
||||||
|
|
||||||
|
### 4. Add ObsNotes to Canvas
|
||||||
|
|
||||||
|
- Click on any obs_note in the browser to add it to the canvas
|
||||||
|
- The obs_note will appear as a rectangle shape at the center of your current view
|
||||||
|
- You can move, resize, and style the obs_note shapes like any other canvas element
|
||||||
|
|
||||||
|
### 5. Keyboard Shortcuts
|
||||||
|
|
||||||
|
- **Alt+O**: Open Obsidian browser or select Obsidian Note tool
|
||||||
|
- **Escape**: Close the Obsidian browser
|
||||||
|
- **Enter**: Select the currently highlighted obs_note (when browsing)
|
||||||
|
|
||||||
|
## ObsNote Shape Features
|
||||||
|
|
||||||
|
### Display Options
|
||||||
|
- **Title**: Shows the obs_note title at the top
|
||||||
|
- **Content Preview**: Displays a formatted preview of the obs_note content
|
||||||
|
- **Tags**: Shows up to 3 tags, with a "+N" indicator for additional tags
|
||||||
|
- **Metadata**: Displays file path and link count
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
- **Background Color**: Customizable background color
|
||||||
|
- **Text Color**: Customizable text color
|
||||||
|
- **Preview Mode**: Toggle between preview and full content view
|
||||||
|
|
||||||
|
### Markdown Support
|
||||||
|
The obs_note shapes support basic markdown formatting:
|
||||||
|
- Headers (# ## ###)
|
||||||
|
- Bold (**text**)
|
||||||
|
- Italic (*text*)
|
||||||
|
- Inline code (`code`)
|
||||||
|
- Lists (- item, 1. item)
|
||||||
|
- Wiki links ([[link]])
|
||||||
|
- External links ([text](url))
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib/
|
||||||
|
│ └── obsidianImporter.ts # Core vault import logic
|
||||||
|
├── shapes/
|
||||||
|
│ └── NoteShapeUtil.tsx # Canvas shape for displaying notes
|
||||||
|
├── tools/
|
||||||
|
│ └── NoteTool.ts # Tool for creating note shapes
|
||||||
|
├── components/
|
||||||
|
│ ├── ObsidianVaultBrowser.tsx # Main browser interface
|
||||||
|
│ └── ObsidianToolbarButton.tsx # Toolbar button component
|
||||||
|
└── css/
|
||||||
|
├── obsidian-browser.css # Browser styling
|
||||||
|
└── obsidian-toolbar.css # Toolbar button styling
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### ObsidianImporter Class
|
||||||
|
|
||||||
|
The `ObsidianImporter` class handles:
|
||||||
|
- Reading markdown files from directories
|
||||||
|
- Parsing frontmatter and metadata
|
||||||
|
- Extracting tags, links, and other obs_note properties
|
||||||
|
- Searching and filtering functionality
|
||||||
|
|
||||||
|
### ObsNoteShape Class
|
||||||
|
|
||||||
|
The `ObsNoteShape` class extends TLDraw's `BaseBoxShapeUtil` and provides:
|
||||||
|
- Rich obs_note display with markdown rendering
|
||||||
|
- Interactive preview/full content toggle
|
||||||
|
- Customizable styling options
|
||||||
|
- Integration with TLDraw's shape system
|
||||||
|
|
||||||
|
### File System Access
|
||||||
|
|
||||||
|
The integration uses the modern File System Access API when available, with graceful fallback to demo data for browsers that don't support it.
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
- **File System Access API**: Chrome 86+, Edge 86+, Opera 72+
|
||||||
|
- **Fallback Mode**: All modern browsers (uses demo data)
|
||||||
|
- **Canvas Rendering**: All browsers supported by TLDraw
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements for future versions:
|
||||||
|
- Real-time vault synchronization
|
||||||
|
- Bidirectional editing (edit obs_notes on canvas, sync back to vault)
|
||||||
|
- Advanced search with regex support
|
||||||
|
- ObsNote linking and backlink visualization
|
||||||
|
- Custom obs_note templates
|
||||||
|
- Export canvas content back to Obsidian
|
||||||
|
- Support for Obsidian plugins and custom CSS
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Vault Won't Load
|
||||||
|
- Ensure you're using a supported browser
|
||||||
|
- Check that the selected directory contains markdown files
|
||||||
|
- Verify you have read permissions for the directory
|
||||||
|
|
||||||
|
### ObsNotes Not Displaying Correctly
|
||||||
|
- Check that the markdown files are properly formatted
|
||||||
|
- Ensure the files have `.md` extensions
|
||||||
|
- Verify the obs_note content isn't corrupted
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
- Large vaults may take time to load initially
|
||||||
|
- Consider filtering by tags to reduce the number of displayed obs_notes
|
||||||
|
- Use search to quickly find specific obs_notes
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
To extend the Obsidian integration:
|
||||||
|
1. Add new features to the `ObsidianImporter` class
|
||||||
|
2. Extend the `NoteShape` for new display options
|
||||||
|
3. Update the `ObsidianVaultBrowser` for new UI features
|
||||||
|
4. Add corresponding CSS styles for new components
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
# Transcription Tool for Canvas
|
||||||
|
|
||||||
|
The Transcription Tool is a powerful feature that allows you to transcribe audio from participants in your Canvas sessions using the Web Speech API. This tool provides real-time speech-to-text conversion, making it easy to capture and document conversations, presentations, and discussions.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🎤 Real-time Transcription
|
||||||
|
- Live speech-to-text conversion using the Web Speech API
|
||||||
|
- Support for multiple languages including English, Spanish, French, German, and more
|
||||||
|
- Continuous recording with interim and final results
|
||||||
|
|
||||||
|
### 🌐 Multi-language Support
|
||||||
|
- **English (US/UK)**: Primary language support
|
||||||
|
- **European Languages**: Spanish, French, German, Italian, Portuguese
|
||||||
|
- **Asian Languages**: Japanese, Korean, Chinese (Simplified)
|
||||||
|
- Easy language switching during recording sessions
|
||||||
|
|
||||||
|
### 👥 Participant Management
|
||||||
|
- Automatic participant detection and tracking
|
||||||
|
- Individual transcript tracking for each speaker
|
||||||
|
- Visual indicators for speaking status
|
||||||
|
|
||||||
|
### 📝 Transcript Management
|
||||||
|
- Real-time transcript display with auto-scroll
|
||||||
|
- Clear transcript functionality
|
||||||
|
- Download transcripts as text files
|
||||||
|
- Persistent storage within the Canvas session
|
||||||
|
|
||||||
|
### ⚙️ Advanced Controls
|
||||||
|
- Auto-scroll toggle for better reading experience
|
||||||
|
- Recording start/stop controls
|
||||||
|
- Error handling and status indicators
|
||||||
|
- Microphone permission management
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
### 1. Adding the Tool to Your Canvas
|
||||||
|
|
||||||
|
1. In your Canvas session, look for the **Transcribe** tool in the toolbar
|
||||||
|
2. Click on the Transcribe tool icon
|
||||||
|
3. Click and drag on the canvas to create a transcription widget
|
||||||
|
4. The widget will appear with default dimensions (400x300 pixels)
|
||||||
|
|
||||||
|
### 2. Starting a Recording Session
|
||||||
|
|
||||||
|
1. **Select Language**: Choose your preferred language from the dropdown menu
|
||||||
|
2. **Enable Auto-scroll**: Check the auto-scroll checkbox for automatic scrolling
|
||||||
|
3. **Start Recording**: Click the "🎤 Start Recording" button
|
||||||
|
4. **Grant Permissions**: Allow microphone access when prompted by your browser
|
||||||
|
|
||||||
|
### 3. During Recording
|
||||||
|
|
||||||
|
- **Live Transcription**: See real-time text as people speak
|
||||||
|
- **Participant Tracking**: Monitor who is speaking
|
||||||
|
- **Status Indicators**: Red dot shows active recording
|
||||||
|
- **Auto-scroll**: Transcript automatically scrolls to show latest content
|
||||||
|
|
||||||
|
### 4. Managing Your Transcript
|
||||||
|
|
||||||
|
- **Stop Recording**: Click "⏹️ Stop Recording" to end the session
|
||||||
|
- **Clear Transcript**: Use "🗑️ Clear" to reset the transcript
|
||||||
|
- **Download**: Click "💾 Download" to save as a text file
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
### ✅ Supported Browsers
|
||||||
|
- **Chrome/Chromium**: Full support with `webkitSpeechRecognition`
|
||||||
|
- **Edge (Chromium)**: Full support
|
||||||
|
- **Safari**: Limited support (may require additional setup)
|
||||||
|
|
||||||
|
### ❌ Unsupported Browsers
|
||||||
|
- **Firefox**: No native support for Web Speech API
|
||||||
|
- **Internet Explorer**: No support
|
||||||
|
|
||||||
|
### 🔧 Recommended Setup
|
||||||
|
For the best experience, use **Chrome** or **Chromium-based browsers** with:
|
||||||
|
- Microphone access enabled
|
||||||
|
- HTTPS connection (required for microphone access)
|
||||||
|
- Stable internet connection
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Web Speech API Integration
|
||||||
|
The tool uses the Web Speech API's `SpeechRecognition` interface:
|
||||||
|
- **Continuous Mode**: Enables ongoing transcription
|
||||||
|
- **Interim Results**: Shows partial results in real-time
|
||||||
|
- **Language Detection**: Automatically adjusts to selected language
|
||||||
|
- **Error Handling**: Graceful fallback for unsupported features
|
||||||
|
|
||||||
|
### Audio Processing
|
||||||
|
- **Microphone Access**: Secure microphone permission handling
|
||||||
|
- **Audio Stream Management**: Proper cleanup of audio resources
|
||||||
|
- **Quality Optimization**: Optimized for voice recognition
|
||||||
|
|
||||||
|
### Data Persistence
|
||||||
|
- **Session Storage**: Transcripts persist during the Canvas session
|
||||||
|
- **Shape Properties**: All settings and data stored in the Canvas shape
|
||||||
|
- **Real-time Updates**: Changes sync across all participants
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### "Speech recognition not supported in this browser"
|
||||||
|
- **Solution**: Use Chrome or a Chromium-based browser
|
||||||
|
- **Alternative**: Check if you're using the latest browser version
|
||||||
|
|
||||||
|
#### "Unable to access microphone"
|
||||||
|
- **Solution**: Check browser permissions for microphone access
|
||||||
|
- **Alternative**: Ensure you're on an HTTPS connection
|
||||||
|
|
||||||
|
#### Poor transcription quality
|
||||||
|
- **Solutions**:
|
||||||
|
- Speak clearly and at a moderate pace
|
||||||
|
- Reduce background noise
|
||||||
|
- Ensure good microphone positioning
|
||||||
|
- Check internet connection stability
|
||||||
|
|
||||||
|
#### Language not working correctly
|
||||||
|
- **Solution**: Verify the selected language matches the spoken language
|
||||||
|
- **Alternative**: Try restarting the recording session
|
||||||
|
|
||||||
|
### Performance Tips
|
||||||
|
|
||||||
|
1. **Close unnecessary tabs** to free up system resources
|
||||||
|
2. **Use a good quality microphone** for better accuracy
|
||||||
|
3. **Minimize background noise** in your environment
|
||||||
|
4. **Speak at a natural pace** - not too fast or slow
|
||||||
|
5. **Ensure stable internet connection** for optimal performance
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
- **Speaker Identification**: Advanced voice recognition for multiple speakers
|
||||||
|
- **Export Formats**: Support for PDF, Word, and other document formats
|
||||||
|
- **Real-time Translation**: Multi-language translation capabilities
|
||||||
|
- **Voice Commands**: Canvas control through voice commands
|
||||||
|
- **Cloud Storage**: Automatic transcript backup and sharing
|
||||||
|
|
||||||
|
### Integration Possibilities
|
||||||
|
- **Daily.co Integration**: Enhanced participant detection from video sessions
|
||||||
|
- **AI Enhancement**: Improved accuracy using machine learning
|
||||||
|
- **Collaborative Editing**: Real-time transcript editing by multiple users
|
||||||
|
- **Search and Indexing**: Full-text search within transcripts
|
||||||
|
|
||||||
|
## Support and Feedback
|
||||||
|
|
||||||
|
If you encounter issues or have suggestions for improvements:
|
||||||
|
|
||||||
|
1. **Check Browser Compatibility**: Ensure you're using a supported browser
|
||||||
|
2. **Review Permissions**: Verify microphone access is granted
|
||||||
|
3. **Check Network**: Ensure stable internet connection
|
||||||
|
4. **Report Issues**: Contact the development team with detailed error information
|
||||||
|
|
||||||
|
## Privacy and Security
|
||||||
|
|
||||||
|
### Data Handling
|
||||||
|
- **Local Processing**: Speech recognition happens locally in your browser
|
||||||
|
- **No Cloud Storage**: Transcripts are not automatically uploaded to external services
|
||||||
|
- **Session Privacy**: Data is only shared within your Canvas session
|
||||||
|
- **User Control**: You control when and what to record
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
- **Inform Participants**: Let others know when recording
|
||||||
|
- **Respect Privacy**: Don't record sensitive or confidential information
|
||||||
|
- **Secure Sharing**: Be careful when sharing transcript files
|
||||||
|
- **Regular Cleanup**: Clear transcripts when no longer needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*The Transcription Tool is designed to enhance collaboration and documentation in Canvas sessions. Use it responsibly and respect the privacy of all participants.*
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
# WebCryptoAPI Authentication Implementation
|
||||||
|
|
||||||
|
This document describes the complete WebCryptoAPI authentication system implemented in this project.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The WebCryptoAPI authentication system provides cryptographic authentication using ECDSA P-256 key pairs, challenge-response authentication, and secure key storage. It integrates with the existing ODD (Open Data Directory) framework while providing a fallback authentication mechanism.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
1. **Crypto Module** (`src/lib/auth/crypto.ts`)
|
||||||
|
- WebCryptoAPI wrapper functions
|
||||||
|
- Key pair generation (ECDSA P-256)
|
||||||
|
- Public key export/import
|
||||||
|
- Data signing and verification
|
||||||
|
- User credential storage
|
||||||
|
|
||||||
|
2. **CryptoAuthService** (`src/lib/auth/cryptoAuthService.ts`)
|
||||||
|
- High-level authentication service
|
||||||
|
- Challenge-response authentication
|
||||||
|
- User registration and login
|
||||||
|
- Credential verification
|
||||||
|
|
||||||
|
3. **Enhanced AuthService** (`src/lib/auth/authService.ts`)
|
||||||
|
- Integrates crypto authentication with ODD
|
||||||
|
- Fallback mechanisms
|
||||||
|
- Session management
|
||||||
|
|
||||||
|
4. **UI Components**
|
||||||
|
- `CryptoLogin.tsx` - Cryptographic authentication UI
|
||||||
|
- `CryptoTest.tsx` - Test component for verification
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### ✅ Implemented
|
||||||
|
|
||||||
|
- **ECDSA P-256 Key Pairs**: Secure cryptographic key generation
|
||||||
|
- **Challenge-Response Authentication**: Prevents replay attacks
|
||||||
|
- **Public Key Infrastructure**: Store and verify public keys
|
||||||
|
- **Browser Support Detection**: Checks for WebCryptoAPI availability
|
||||||
|
- **Secure Context Validation**: Ensures HTTPS requirement
|
||||||
|
- **Fallback Authentication**: Works with existing ODD system
|
||||||
|
- **Modern UI**: Responsive design with dark mode support
|
||||||
|
- **Comprehensive Testing**: Test component for verification
|
||||||
|
|
||||||
|
### 🔧 Technical Details
|
||||||
|
|
||||||
|
#### Key Generation
|
||||||
|
```typescript
|
||||||
|
const keyPair = await crypto.generateKeyPair();
|
||||||
|
// Returns CryptoKeyPair with public and private keys
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Public Key Export/Import
|
||||||
|
```typescript
|
||||||
|
const publicKeyBase64 = await crypto.exportPublicKey(keyPair.publicKey);
|
||||||
|
const importedKey = await crypto.importPublicKey(publicKeyBase64);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Data Signing and Verification
|
||||||
|
```typescript
|
||||||
|
const signature = await crypto.signData(privateKey, data);
|
||||||
|
const isValid = await crypto.verifySignature(publicKey, signature, data);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Challenge-Response Authentication
|
||||||
|
```typescript
|
||||||
|
// Generate challenge
|
||||||
|
const challenge = `${username}:${timestamp}:${random}`;
|
||||||
|
|
||||||
|
// Sign challenge during registration
|
||||||
|
const signature = await crypto.signData(privateKey, challenge);
|
||||||
|
|
||||||
|
// Verify during login
|
||||||
|
const isValid = await crypto.verifySignature(publicKey, signature, challenge);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Requirements
|
||||||
|
|
||||||
|
### Minimum Requirements
|
||||||
|
- **WebCryptoAPI Support**: `window.crypto.subtle`
|
||||||
|
- **Secure Context**: HTTPS or localhost
|
||||||
|
- **Modern Browser**: Chrome 37+, Firefox 34+, Safari 11+, Edge 12+
|
||||||
|
|
||||||
|
### Feature Detection
|
||||||
|
```typescript
|
||||||
|
const hasWebCrypto = typeof window.crypto !== 'undefined' &&
|
||||||
|
typeof window.crypto.subtle !== 'undefined';
|
||||||
|
const isSecure = window.isSecureContext;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### ✅ Implemented Security Measures
|
||||||
|
|
||||||
|
1. **Secure Context Requirement**: Only works over HTTPS
|
||||||
|
2. **ECDSA P-256**: Industry-standard elliptic curve
|
||||||
|
3. **Challenge-Response**: Prevents replay attacks
|
||||||
|
4. **Key Storage**: Public keys stored securely
|
||||||
|
5. **Input Validation**: Username format validation
|
||||||
|
6. **Error Handling**: Comprehensive error management
|
||||||
|
|
||||||
|
### ⚠️ Security Notes
|
||||||
|
|
||||||
|
1. **Private Key Storage**: Currently simplified for demo purposes
|
||||||
|
- In production, use Web Crypto API's key storage
|
||||||
|
- Consider hardware security modules (HSM)
|
||||||
|
- Implement proper key derivation
|
||||||
|
|
||||||
|
2. **Session Management**:
|
||||||
|
- Integrates with existing ODD session system
|
||||||
|
- Consider implementing JWT tokens
|
||||||
|
- Add session expiration
|
||||||
|
|
||||||
|
3. **Network Security**:
|
||||||
|
- All crypto operations happen client-side
|
||||||
|
- No private keys transmitted over network
|
||||||
|
- Consider adding server-side verification
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Authentication Flow
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CryptoAuthService } from './lib/auth/cryptoAuthService';
|
||||||
|
|
||||||
|
// Register a new user
|
||||||
|
const registerResult = await CryptoAuthService.register('username');
|
||||||
|
if (registerResult.success) {
|
||||||
|
console.log('User registered successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login with existing user
|
||||||
|
const loginResult = await CryptoAuthService.login('username');
|
||||||
|
if (loginResult.success) {
|
||||||
|
console.log('User authenticated successfully');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with React Context
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAuth } from './context/AuthContext';
|
||||||
|
|
||||||
|
const { login, register } = useAuth();
|
||||||
|
|
||||||
|
// The AuthService automatically tries crypto auth first,
|
||||||
|
// then falls back to ODD authentication
|
||||||
|
const success = await login('username');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing the Implementation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import CryptoTest from './components/auth/CryptoTest';
|
||||||
|
|
||||||
|
// Render the test component to verify functionality
|
||||||
|
<CryptoTest />
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib/
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── crypto.ts # WebCryptoAPI wrapper
|
||||||
|
│ │ ├── cryptoAuthService.ts # High-level auth service
|
||||||
|
│ │ ├── authService.ts # Enhanced auth service
|
||||||
|
│ │ └── account.ts # User account management
|
||||||
|
│ └── utils/
|
||||||
|
│ └── browser.ts # Browser support detection
|
||||||
|
├── components/
|
||||||
|
│ └── auth/
|
||||||
|
│ ├── CryptoLogin.tsx # Crypto auth UI
|
||||||
|
│ └── CryptoTest.tsx # Test component
|
||||||
|
└── css/
|
||||||
|
└── crypto-auth.css # Styles for crypto components
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Required Packages
|
||||||
|
- `one-webcrypto`: WebCryptoAPI polyfill (^1.0.3)
|
||||||
|
- `@oddjs/odd`: Open Data Directory framework (^0.37.2)
|
||||||
|
|
||||||
|
### Browser APIs Used
|
||||||
|
- `window.crypto.subtle`: WebCryptoAPI
|
||||||
|
- `window.localStorage`: Key storage
|
||||||
|
- `window.isSecureContext`: Security context check
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
1. Navigate to the application
|
||||||
|
2. Use the `CryptoTest` component to run automated tests
|
||||||
|
3. Verify all test cases pass
|
||||||
|
4. Test on different browsers and devices
|
||||||
|
|
||||||
|
### Test Cases
|
||||||
|
- [x] Browser support detection
|
||||||
|
- [x] Secure context validation
|
||||||
|
- [x] Key pair generation
|
||||||
|
- [x] Public key export/import
|
||||||
|
- [x] Data signing and verification
|
||||||
|
- [x] User registration
|
||||||
|
- [x] User login
|
||||||
|
- [x] Credential verification
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **"Browser not supported"**
|
||||||
|
- Ensure you're using a modern browser
|
||||||
|
- Check if WebCryptoAPI is available
|
||||||
|
- Verify HTTPS or localhost
|
||||||
|
|
||||||
|
2. **"Secure context required"**
|
||||||
|
- Access the application over HTTPS
|
||||||
|
- For development, use localhost
|
||||||
|
|
||||||
|
3. **"Key generation failed"**
|
||||||
|
- Check browser console for errors
|
||||||
|
- Verify WebCryptoAPI permissions
|
||||||
|
- Try refreshing the page
|
||||||
|
|
||||||
|
4. **"Authentication failed"**
|
||||||
|
- Verify user exists
|
||||||
|
- Check stored credentials
|
||||||
|
- Clear browser data and retry
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Enable debug logging by setting:
|
||||||
|
```typescript
|
||||||
|
localStorage.setItem('debug_crypto', 'true');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Planned Improvements
|
||||||
|
1. **Enhanced Key Storage**: Use Web Crypto API's key storage
|
||||||
|
2. **Server-Side Verification**: Add server-side signature verification
|
||||||
|
3. **Multi-Factor Authentication**: Add additional authentication factors
|
||||||
|
4. **Key Rotation**: Implement automatic key rotation
|
||||||
|
5. **Hardware Security**: Support for hardware security modules
|
||||||
|
|
||||||
|
### Advanced Features
|
||||||
|
1. **Zero-Knowledge Proofs**: Implement ZKP for enhanced privacy
|
||||||
|
2. **Threshold Cryptography**: Distributed key management
|
||||||
|
3. **Post-Quantum Cryptography**: Prepare for quantum threats
|
||||||
|
4. **Biometric Integration**: Add biometric authentication
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When contributing to the WebCryptoAPI authentication system:
|
||||||
|
|
||||||
|
1. **Security First**: All changes must maintain security standards
|
||||||
|
2. **Test Thoroughly**: Run the test suite before submitting
|
||||||
|
3. **Document Changes**: Update this documentation
|
||||||
|
4. **Browser Compatibility**: Test on multiple browsers
|
||||||
|
5. **Performance**: Ensure crypto operations don't block UI
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [WebCryptoAPI Specification](https://www.w3.org/TR/WebCryptoAPI/)
|
||||||
|
- [ECDSA Algorithm](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm)
|
||||||
|
- [P-256 Curve](https://en.wikipedia.org/wiki/NIST_Curve_P-256)
|
||||||
|
- [Challenge-Response Authentication](https://en.wikipedia.org/wiki/Challenge%E2%80%93response_authentication)
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
# GitHub Integration Setup for Quartz Sync
|
||||||
|
|
||||||
|
## Quick Setup Guide
|
||||||
|
|
||||||
|
### 1. Create GitHub Personal Access Token
|
||||||
|
|
||||||
|
1. Go to: https://github.com/settings/tokens
|
||||||
|
2. Click "Generate new token" → "Generate new token (classic)"
|
||||||
|
3. Configure:
|
||||||
|
- **Note:** "Canvas Website Quartz Sync"
|
||||||
|
- **Expiration:** 90 days (or your preference)
|
||||||
|
- **Scopes:**
|
||||||
|
- ✅ `repo` (Full control of private repositories)
|
||||||
|
- ✅ `workflow` (Update GitHub Action workflows)
|
||||||
|
4. Click "Generate token" and **copy it immediately**
|
||||||
|
|
||||||
|
### 2. Set Up Your Quartz Repository
|
||||||
|
|
||||||
|
For the Jeff-Emmett/quartz repository, you can either:
|
||||||
|
|
||||||
|
**Option A: Use the existing Jeff-Emmett/quartz repository**
|
||||||
|
- Fork the repository to your GitHub account
|
||||||
|
- Clone your fork locally
|
||||||
|
- Set up the environment variables to point to your fork
|
||||||
|
|
||||||
|
**Option B: Create a new Quartz repository**
|
||||||
|
```bash
|
||||||
|
# Create a new Quartz site
|
||||||
|
git clone https://github.com/jackyzha0/quartz.git your-quartz-site
|
||||||
|
cd your-quartz-site
|
||||||
|
npm install
|
||||||
|
npx quartz create
|
||||||
|
|
||||||
|
# Push to GitHub
|
||||||
|
git add .
|
||||||
|
git commit -m "Initial Quartz setup"
|
||||||
|
git remote add origin https://github.com/your-username/your-quartz-repo.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure Environment Variables
|
||||||
|
|
||||||
|
Create a `.env.local` file in your project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# GitHub Integration for Quartz Sync
|
||||||
|
NEXT_PUBLIC_GITHUB_TOKEN=your_github_token_here
|
||||||
|
NEXT_PUBLIC_QUARTZ_REPO=Jeff-Emmett/quartz
|
||||||
|
NEXT_PUBLIC_QUARTZ_BRANCH=main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Enable GitHub Pages
|
||||||
|
|
||||||
|
1. Go to your repository → Settings → Pages
|
||||||
|
2. Source: "GitHub Actions"
|
||||||
|
3. This will automatically deploy your Quartz site when you push changes
|
||||||
|
|
||||||
|
### 5. Test the Integration
|
||||||
|
|
||||||
|
1. Start your development server: `npm run dev`
|
||||||
|
2. Import some Obsidian notes or create new ones
|
||||||
|
3. Edit a note and click "Sync Updates"
|
||||||
|
4. Check your GitHub repository - you should see new/updated files in the `content/` directory
|
||||||
|
5. Your Quartz site should automatically rebuild and show the changes
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **When you sync a note:**
|
||||||
|
- The system creates/updates a Markdown file in your GitHub repository
|
||||||
|
- File is placed in the `content/` directory with proper frontmatter
|
||||||
|
- GitHub Actions automatically rebuilds and deploys your Quartz site
|
||||||
|
|
||||||
|
2. **File structure in your repository:**
|
||||||
|
```
|
||||||
|
your-quartz-repo/
|
||||||
|
├── content/
|
||||||
|
│ ├── note-1.md
|
||||||
|
│ ├── note-2.md
|
||||||
|
│ └── ...
|
||||||
|
├── .github/workflows/
|
||||||
|
│ └── quartz-sync.yml
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Automatic deployment:**
|
||||||
|
- Changes trigger GitHub Actions workflow
|
||||||
|
- Quartz site rebuilds automatically
|
||||||
|
- Changes appear on your live site within minutes
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **"GitHub API error: 401 Unauthorized"**
|
||||||
|
- Check your GitHub token is correct
|
||||||
|
- Verify the token has `repo` permissions
|
||||||
|
|
||||||
|
2. **"Repository not found"**
|
||||||
|
- Check the repository name format: `username/repo-name`
|
||||||
|
- Ensure the repository exists and is accessible
|
||||||
|
|
||||||
|
3. **"Sync successful but no changes on site"**
|
||||||
|
- Check GitHub Actions tab for workflow status
|
||||||
|
- Verify GitHub Pages is enabled
|
||||||
|
- Wait a few minutes for the build to complete
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Check the browser console for detailed sync logs:
|
||||||
|
- Look for "✅ Successfully synced to Quartz!" messages
|
||||||
|
- Check for any error messages in red
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Never commit your `.env.local` file to version control
|
||||||
|
- Use fine-grained tokens with minimal required permissions
|
||||||
|
- Regularly rotate your GitHub tokens
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Once set up, you can:
|
||||||
|
- Edit notes directly in the canvas
|
||||||
|
- Sync changes to your Quartz site
|
||||||
|
- Share your live Quartz site with others
|
||||||
|
- Use GitHub's version control for your notes
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Jeff Emmett</title>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta http-equiv="Permissions-Policy" content="midi=*, microphone=*, camera=*, autoplay=*">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Social Meta Tags -->
|
||||||
|
<meta name="description"
|
||||||
|
content="My research investigates the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
|
||||||
|
|
||||||
|
<meta property="og:url" content="https://jeffemmett.com">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:title" content="Jeff Emmett">
|
||||||
|
<meta property="og:description"
|
||||||
|
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
|
||||||
|
<meta property="og:image" content="/website-embed.png">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta property="twitter:domain" content="jeffemmett.com">
|
||||||
|
<meta property="twitter:url" content="https://jeffemmett.com">
|
||||||
|
<meta name="twitter:title" content="Jeff Emmett">
|
||||||
|
<meta name="twitter:description"
|
||||||
|
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
|
||||||
|
<meta name="twitter:image" content="/website-embed.png">
|
||||||
|
|
||||||
|
<!-- Analytics -->
|
||||||
|
<script data-goatcounter="https://jeff.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/App.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,90 @@
|
||||||
|
{
|
||||||
|
"name": "jeffemmett",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Jeff Emmett's personal website",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"npm run dev:client\" \"npm run dev:worker:local\"",
|
||||||
|
"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": "tsc && vite build",
|
||||||
|
"build:worker": "wrangler build --config wrangler.dev.toml",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "Jeff Emmett",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.33.1",
|
||||||
|
"@automerge/automerge": "^3.1.1",
|
||||||
|
"@automerge/automerge-repo": "^2.2.0",
|
||||||
|
"@automerge/automerge-repo-react-hooks": "^2.2.0",
|
||||||
|
"@chengsokdara/use-whisper": "^0.2.0",
|
||||||
|
"@daily-co/daily-js": "^0.60.0",
|
||||||
|
"@daily-co/daily-react": "^0.20.0",
|
||||||
|
"@noble/curves": "^2.0.1",
|
||||||
|
"@oddjs/odd": "^0.37.2",
|
||||||
|
"@tldraw/assets": "^3.15.4",
|
||||||
|
"@tldraw/tldraw": "^3.15.4",
|
||||||
|
"@tldraw/tlschema": "^3.15.4",
|
||||||
|
"@types/markdown-it": "^14.1.1",
|
||||||
|
"@types/marked": "^5.0.2",
|
||||||
|
"@uiw/react-md-editor": "^4.0.5",
|
||||||
|
"@vercel/analytics": "^1.2.2",
|
||||||
|
"@xenova/transformers": "^2.17.2",
|
||||||
|
"ai": "^4.1.0",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"cherry-markdown": "^0.8.57",
|
||||||
|
"cloudflare-workers-unfurl": "^0.0.7",
|
||||||
|
"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",
|
||||||
|
"jspdf": "^2.5.2",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"marked": "^15.0.4",
|
||||||
|
"one-webcrypto": "^1.0.3",
|
||||||
|
"openai": "^4.79.3",
|
||||||
|
"rbush": "^4.0.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-cmdk": "^1.3.9",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^7.0.2",
|
||||||
|
"recoil": "^0.7.7",
|
||||||
|
"tldraw": "^3.15.4",
|
||||||
|
"use-whisper": "^0.0.1",
|
||||||
|
"vercel": "^39.1.1",
|
||||||
|
"viem": "^2.38.6",
|
||||||
|
"webcola": "^3.4.0",
|
||||||
|
"webnative": "^0.36.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/types": "^6.0.0",
|
||||||
|
"@cloudflare/workers-types": "^4.20240821.1",
|
||||||
|
"@types/lodash.throttle": "^4",
|
||||||
|
"@types/rbush": "^4.0.0",
|
||||||
|
"@types/react": "^19.0.1",
|
||||||
|
"@types/react-dom": "^19.0.1",
|
||||||
|
"@vitejs/plugin-react": "^4.0.3",
|
||||||
|
"concurrently": "^9.1.0",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"vite-plugin-top-level-await": "^1.6.0",
|
||||||
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
|
"wrangler": "^4.33.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Quartz Sync Configuration
|
||||||
|
# Copy this file to .env.local and fill in your actual values
|
||||||
|
|
||||||
|
# GitHub Integration (Recommended)
|
||||||
|
# Get your token from: https://github.com/settings/tokens
|
||||||
|
NEXT_PUBLIC_GITHUB_TOKEN=your_github_token_here
|
||||||
|
# Format: username/repository-name
|
||||||
|
NEXT_PUBLIC_QUARTZ_REPO=Jeff-Emmett/quartz
|
||||||
|
|
||||||
|
# Cloudflare Integration
|
||||||
|
# Get your API key from: https://dash.cloudflare.com/profile/api-tokens
|
||||||
|
NEXT_PUBLIC_CLOUDFLARE_API_KEY=your_cloudflare_api_key_here
|
||||||
|
# Find your Account ID in the Cloudflare dashboard sidebar
|
||||||
|
NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID=your_cloudflare_account_id_here
|
||||||
|
# Optional: Specify a custom R2 bucket name
|
||||||
|
NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET=your-quartz-notes-bucket
|
||||||
|
|
||||||
|
# Quartz API Integration (if your Quartz site has an API)
|
||||||
|
NEXT_PUBLIC_QUARTZ_API_URL=https://your-quartz-site.com/api
|
||||||
|
NEXT_PUBLIC_QUARTZ_API_KEY=your_quartz_api_key_here
|
||||||
|
|
||||||
|
# Webhook Integration (for custom sync handlers)
|
||||||
|
NEXT_PUBLIC_QUARTZ_WEBHOOK_URL=https://your-webhook-endpoint.com/quartz-sync
|
||||||
|
NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET=your_webhook_secret_here
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
import "tldraw/tldraw.css"
|
||||||
|
import "@/css/style.css"
|
||||||
|
import { Default } from "@/routes/Default"
|
||||||
|
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"
|
||||||
|
import { Contact } from "@/routes/Contact"
|
||||||
|
import { Board } from "./routes/Board"
|
||||||
|
import { Inbox } from "./routes/Inbox"
|
||||||
|
import { Presentations } from "./routes/Presentations"
|
||||||
|
import { Resilience } from "./routes/Resilience"
|
||||||
|
import { inject } from "@vercel/analytics"
|
||||||
|
import { createRoot } from "react-dom/client"
|
||||||
|
import { DailyProvider } from "@daily-co/daily-react"
|
||||||
|
import Daily from "@daily-co/daily-js"
|
||||||
|
import "tldraw/tldraw.css";
|
||||||
|
import "@/css/style.css";
|
||||||
|
import "@/css/auth.css"; // Import auth styles
|
||||||
|
import "@/css/crypto-auth.css"; // Import crypto auth styles
|
||||||
|
import "@/css/starred-boards.css"; // Import starred boards styles
|
||||||
|
import "@/css/user-profile.css"; // Import user profile styles
|
||||||
|
import "@/css/location.css"; // Import location sharing styles
|
||||||
|
import { Dashboard } from "./routes/Dashboard";
|
||||||
|
import { LocationShareCreate } from "./routes/LocationShareCreate";
|
||||||
|
import { LocationShareView } from "./routes/LocationShareView";
|
||||||
|
import { LocationDashboardRoute } from "./routes/LocationDashboardRoute";
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
// Import React Context providers
|
||||||
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
|
import { FileSystemProvider } from './context/FileSystemContext';
|
||||||
|
import { NotificationProvider } from './context/NotificationContext';
|
||||||
|
import { BlockchainProvider } from './context/BlockchainContext';
|
||||||
|
import NotificationsDisplay from './components/NotificationsDisplay';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
|
||||||
|
// Import auth components
|
||||||
|
import CryptoLogin from './components/auth/CryptoLogin';
|
||||||
|
import CryptoDebug from './components/auth/CryptoDebug';
|
||||||
|
|
||||||
|
inject();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
* Allows guests to browse, but provides login option
|
||||||
|
*/
|
||||||
|
const OptionalAuthRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Wait for authentication to initialize before rendering
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session.loading) {
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
|
}, [session.loading]);
|
||||||
|
|
||||||
|
if (!isInitialized) {
|
||||||
|
return <div className="loading">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always render the content, authentication is optional
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main App with context providers
|
||||||
|
*/
|
||||||
|
const AppWithProviders = () => {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth page - renders login/register component (kept for direct access)
|
||||||
|
*/
|
||||||
|
const AuthPage = () => {
|
||||||
|
const { session } = useAuth();
|
||||||
|
|
||||||
|
// Redirect to home if already authenticated
|
||||||
|
if (session.authed) {
|
||||||
|
return <Navigate to="/" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<CryptoLogin onSuccess={() => window.location.href = '/'} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AuthProvider>
|
||||||
|
<FileSystemProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<BlockchainProvider>
|
||||||
|
<DailyProvider callObject={callObject}>
|
||||||
|
<BrowserRouter>
|
||||||
|
{/* Display notifications */}
|
||||||
|
<NotificationsDisplay />
|
||||||
|
|
||||||
|
<Routes>
|
||||||
|
{/* Auth routes */}
|
||||||
|
<Route path="/login" element={<AuthPage />} />
|
||||||
|
|
||||||
|
{/* Optional auth routes */}
|
||||||
|
<Route path="/" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<Default />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/contact" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<Contact />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/board/:slug" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<Board />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/inbox" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<Inbox />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/debug" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<CryptoDebug />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/dashboard" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/presentations" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<Presentations />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/presentations/resilience" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<Resilience />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
|
{/* Location sharing routes */}
|
||||||
|
<Route path="/share-location" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<LocationShareCreate />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/location/:token" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<LocationShareView />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/location-dashboard" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<LocationDashboardRoute />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</DailyProvider>
|
||||||
|
</BlockchainProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</FileSystemProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the app
|
||||||
|
createRoot(document.getElementById("root")!).render(<AppWithProviders />);
|
||||||
|
|
||||||
|
export default AppWithProviders;
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
import CommandPalette, { filterItems, getItemIndex } from "react-cmdk"
|
||||||
|
import { Fragment, useEffect, useState } from "react"
|
||||||
|
import {
|
||||||
|
Editor,
|
||||||
|
TLShape,
|
||||||
|
TLShapeId,
|
||||||
|
unwrapLabel,
|
||||||
|
useActions,
|
||||||
|
useEditor,
|
||||||
|
useLocalStorageState,
|
||||||
|
useTranslation,
|
||||||
|
useValue,
|
||||||
|
} from "tldraw"
|
||||||
|
// import { generateText } from "@/utils/llmUtils"
|
||||||
|
import "@/css/style.css"
|
||||||
|
|
||||||
|
function toNearest(n: number, places = 2) {
|
||||||
|
return Math.round(n * 10 ** places) / 10 ** places
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimpleShape {
|
||||||
|
type: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
rotation: string
|
||||||
|
properties: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function simplifiedShape(editor: Editor, shape: TLShape): SimpleShape {
|
||||||
|
const bounds = editor.getShapePageBounds(shape.id)
|
||||||
|
return {
|
||||||
|
type: shape.type,
|
||||||
|
x: toNearest(shape.x),
|
||||||
|
y: toNearest(shape.y),
|
||||||
|
rotation: `${toNearest(shape.rotation, 3)} radians`,
|
||||||
|
properties: {
|
||||||
|
...shape.props,
|
||||||
|
w: toNearest(bounds?.width || 0),
|
||||||
|
h: toNearest(bounds?.height || 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CmdK = () => {
|
||||||
|
const editor = useEditor()
|
||||||
|
const actions = useActions()
|
||||||
|
const trans = useTranslation()
|
||||||
|
|
||||||
|
const [inputRefs, setInputRefs] = useState<Set<string>>(new Set())
|
||||||
|
const [response, setResponse] = useLocalStorageState("response", "")
|
||||||
|
const [open, setOpen] = useState<boolean>(false)
|
||||||
|
const [input, setInput] = useLocalStorageState("input", "")
|
||||||
|
const [page, setPage] = useLocalStorageState<"search" | "llm">(
|
||||||
|
"page",
|
||||||
|
"search",
|
||||||
|
)
|
||||||
|
|
||||||
|
const availableRefs = useValue<Map<string, TLShapeId[]>>(
|
||||||
|
"avaiable refs",
|
||||||
|
() => {
|
||||||
|
const nameToShapeIdMap = new Map<string, TLShapeId[]>(
|
||||||
|
editor
|
||||||
|
.getCurrentPageShapes()
|
||||||
|
.filter((shape) => shape.meta.name)
|
||||||
|
.map((shape) => [shape.meta.name as string, [shape.id]]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const selected = editor.getSelectedShapeIds()
|
||||||
|
const inView = editor
|
||||||
|
.getShapesAtPoint(editor.getViewportPageBounds().center, {
|
||||||
|
margin: 1200,
|
||||||
|
})
|
||||||
|
.map((o) => o.id)
|
||||||
|
|
||||||
|
return new Map([
|
||||||
|
...nameToShapeIdMap,
|
||||||
|
["selected", selected],
|
||||||
|
["here", inView],
|
||||||
|
])
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Track the shapes we are referencing in the input */
|
||||||
|
useEffect(() => {
|
||||||
|
const namesInInput = input
|
||||||
|
.split(" ")
|
||||||
|
.filter((name) => name.startsWith("@"))
|
||||||
|
.map((name) => name.slice(1).match(/^[a-zA-Z0-9]+/)?.[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
setInputRefs(new Set(namesInInput as string[]))
|
||||||
|
}, [input])
|
||||||
|
|
||||||
|
/** Handle keyboard shortcuts for Opening and closing the command bar in search/llm mode */
|
||||||
|
useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === " " && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setPage("search")
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setPage("llm")
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", down)
|
||||||
|
return () => document.removeEventListener("keydown", down)
|
||||||
|
}, [setPage])
|
||||||
|
|
||||||
|
const menuItems = filterItems(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
heading: "Actions",
|
||||||
|
id: "actions",
|
||||||
|
items: Object.entries(actions).map(([key, action]) => ({
|
||||||
|
id: key,
|
||||||
|
children: trans(unwrapLabel(action.label)),
|
||||||
|
onClick: () => action.onSelect("unknown"),
|
||||||
|
itemType: "foobar",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Other",
|
||||||
|
id: "other",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "llm",
|
||||||
|
children: "LLM",
|
||||||
|
icon: "ArrowRightOnRectangleIcon",
|
||||||
|
closeOnSelect: false,
|
||||||
|
onClick: () => {
|
||||||
|
setInput("")
|
||||||
|
setPage("llm")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input,
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContextItem =
|
||||||
|
| { name: string; shape: SimpleShape; shapes?: never }
|
||||||
|
| { name: string; shape?: never; shapes: SimpleShape[] }
|
||||||
|
|
||||||
|
const handlePromptSubmit = () => {
|
||||||
|
const cleanedPrompt = input.trim()
|
||||||
|
const context: ContextItem[] = []
|
||||||
|
|
||||||
|
for (const name of inputRefs) {
|
||||||
|
if (!availableRefs.has(name)) continue
|
||||||
|
const shapes = availableRefs.get(name)?.map((id) => editor.getShape(id))
|
||||||
|
if (!shapes || shapes.length < 1) continue
|
||||||
|
|
||||||
|
if (shapes.length === 1) {
|
||||||
|
const contextShape: SimpleShape = simplifiedShape(editor, shapes[0]!)
|
||||||
|
context.push({ name, shape: contextShape })
|
||||||
|
} else {
|
||||||
|
const contextShapes: SimpleShape[] = []
|
||||||
|
for (const shape of shapes) {
|
||||||
|
contextShapes.push(simplifiedShape(editor, shape!))
|
||||||
|
}
|
||||||
|
context.push({ name, shapes: contextShapes })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = `You are a helpful assistant. Respond in plaintext.
|
||||||
|
|
||||||
|
Context:
|
||||||
|
${JSON.stringify(context)}
|
||||||
|
`
|
||||||
|
|
||||||
|
setResponse("🤖...")
|
||||||
|
// generateText(cleanedPrompt, systemPrompt, (partialResponse, _) => {
|
||||||
|
// setResponse(partialResponse)
|
||||||
|
// })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContextPrefix = ({ inputRefs }: { inputRefs: Set<string> }) => {
|
||||||
|
return inputRefs.size > 0 ? (
|
||||||
|
<span>Ask with: </span>
|
||||||
|
) : (
|
||||||
|
<span style={{ opacity: 0.5 }}>No references</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LLMView = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CommandPalette.ListItem
|
||||||
|
className="references"
|
||||||
|
index={0}
|
||||||
|
showType={false}
|
||||||
|
onClick={handlePromptSubmit}
|
||||||
|
closeOnSelect={false}
|
||||||
|
>
|
||||||
|
<ContextPrefix inputRefs={inputRefs} />
|
||||||
|
{Array.from(inputRefs).map((name, index, array) => {
|
||||||
|
const refShapeIds = availableRefs.get(name)
|
||||||
|
if (!refShapeIds) return null
|
||||||
|
return (
|
||||||
|
<Fragment key={name}>
|
||||||
|
<span
|
||||||
|
className={refShapeIds ? "reference" : "reference-missing"}
|
||||||
|
onKeyDown={() => {}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
if (!refShapeIds) return
|
||||||
|
editor.setSelectedShapes(refShapeIds)
|
||||||
|
editor.zoomToSelection({
|
||||||
|
animation: {
|
||||||
|
duration: 200,
|
||||||
|
easing: (t: number) => t * t * (3 - 2 * t),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
{index < array.length - 1 && (
|
||||||
|
<span style={{ marginLeft: "0em" }}>,</span>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandPalette.ListItem>
|
||||||
|
|
||||||
|
{response && (
|
||||||
|
<>
|
||||||
|
<CommandPalette.ListItem
|
||||||
|
disabled={true}
|
||||||
|
className="llm-response"
|
||||||
|
index={1}
|
||||||
|
showType={false}
|
||||||
|
>
|
||||||
|
{response}
|
||||||
|
</CommandPalette.ListItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchView = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{menuItems.length ? (
|
||||||
|
menuItems.map((list) => (
|
||||||
|
<CommandPalette.List key={list.id} heading={list.heading}>
|
||||||
|
{list.items.map(({ id, ...rest }) => (
|
||||||
|
<CommandPalette.ListItem
|
||||||
|
key={id}
|
||||||
|
index={getItemIndex(menuItems, id)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CommandPalette.List>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<CommandPalette.FreeSearchAction label="Search for" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPalette
|
||||||
|
placeholder={page === "search" ? "Search..." : "Ask..."}
|
||||||
|
onChangeSearch={setInput}
|
||||||
|
onChangeOpen={setOpen}
|
||||||
|
search={input}
|
||||||
|
isOpen={open}
|
||||||
|
page={page}
|
||||||
|
>
|
||||||
|
<CommandPalette.Page id="search">
|
||||||
|
<SearchView />
|
||||||
|
</CommandPalette.Page>
|
||||||
|
<CommandPalette.Page id="llm">
|
||||||
|
<LLMView />
|
||||||
|
</CommandPalette.Page>
|
||||||
|
</CommandPalette>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,512 @@
|
||||||
|
import { DEFAULT_GESTURES, ALT_GESTURES } from "@/default_gestures"
|
||||||
|
import { DollarRecognizer } from "@/gestures"
|
||||||
|
import {
|
||||||
|
StateNode,
|
||||||
|
TLDefaultSizeStyle,
|
||||||
|
TLDrawShape,
|
||||||
|
TLDrawShapeSegment,
|
||||||
|
TLEventHandlers,
|
||||||
|
TLHighlightShape,
|
||||||
|
TLPointerEventInfo,
|
||||||
|
TLShapePartial,
|
||||||
|
TLTextShape,
|
||||||
|
Vec,
|
||||||
|
createShapeId,
|
||||||
|
uniqueId,
|
||||||
|
} from "tldraw"
|
||||||
|
|
||||||
|
const STROKE_WIDTH = 10
|
||||||
|
const SHOW_LABELS = true
|
||||||
|
const PRESSURE = 0.5
|
||||||
|
|
||||||
|
export class GestureTool extends StateNode {
|
||||||
|
static override id = "gesture"
|
||||||
|
static override initial = "idle"
|
||||||
|
static override children = () => [Idle, Drawing]
|
||||||
|
static recognizer = new DollarRecognizer(DEFAULT_GESTURES)
|
||||||
|
static recognizerAlt = new DollarRecognizer(ALT_GESTURES)
|
||||||
|
|
||||||
|
override shapeType = "draw"
|
||||||
|
|
||||||
|
override onExit = () => {
|
||||||
|
const drawingState = this.children!.drawing as Drawing
|
||||||
|
drawingState.initialShape = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Idle extends StateNode {
|
||||||
|
static override id = "idle"
|
||||||
|
|
||||||
|
tooltipElement?: HTMLDivElement
|
||||||
|
tooltipTimeout?: NodeJS.Timeout
|
||||||
|
mouseMoveHandler?: (e: MouseEvent) => void
|
||||||
|
|
||||||
|
override onPointerDown: TLEventHandlers["onPointerDown"] = (info) => {
|
||||||
|
this.parent.transition("drawing", info)
|
||||||
|
}
|
||||||
|
|
||||||
|
override onEnter = () => {
|
||||||
|
this.editor.setCursor({ type: "cross", rotation: 0 })
|
||||||
|
|
||||||
|
// Create tooltip element
|
||||||
|
this.tooltipElement = document.createElement('div')
|
||||||
|
this.tooltipElement.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-line;
|
||||||
|
z-index: 10000;
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 300px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
`
|
||||||
|
|
||||||
|
// Set tooltip content
|
||||||
|
this.tooltipElement.innerHTML = `
|
||||||
|
<strong>Gesture Tool Active</strong><br><br>
|
||||||
|
<strong>Basic Gestures:</strong><br>
|
||||||
|
• X, Rectangle, Circle, Check<br>
|
||||||
|
• Caret, V, Delete, Pigtail<br><br>
|
||||||
|
<strong>Shift + Draw:</strong><br>
|
||||||
|
• Circle Layout, Triangle Layout<br><br>
|
||||||
|
Press 'g' again or select another tool to exit
|
||||||
|
`
|
||||||
|
|
||||||
|
// Add tooltip to DOM
|
||||||
|
document.body.appendChild(this.tooltipElement)
|
||||||
|
|
||||||
|
// Function to update tooltip position
|
||||||
|
this.mouseMoveHandler = (e: MouseEvent) => {
|
||||||
|
if (this.tooltipElement) {
|
||||||
|
const x = e.clientX + 20
|
||||||
|
const y = e.clientY - 20
|
||||||
|
|
||||||
|
// Keep tooltip within viewport bounds
|
||||||
|
const rect = this.tooltipElement.getBoundingClientRect()
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
|
||||||
|
let finalX = x
|
||||||
|
let finalY = y
|
||||||
|
|
||||||
|
// Adjust if tooltip would go off the right edge
|
||||||
|
if (x + rect.width > viewportWidth) {
|
||||||
|
finalX = e.clientX - rect.width - 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust if tooltip would go off the bottom edge
|
||||||
|
if (y + rect.height > viewportHeight) {
|
||||||
|
finalY = e.clientY - rect.height - 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure tooltip doesn't go off the top or left
|
||||||
|
finalX = Math.max(10, finalX)
|
||||||
|
finalY = Math.max(10, finalY)
|
||||||
|
|
||||||
|
this.tooltipElement.style.left = `${finalX}px`
|
||||||
|
this.tooltipElement.style.top = `${finalY}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add mouse move listener
|
||||||
|
document.addEventListener('mousemove', this.mouseMoveHandler)
|
||||||
|
|
||||||
|
// Set initial position
|
||||||
|
if (this.mouseMoveHandler) {
|
||||||
|
this.mouseMoveHandler({ clientX: 100, clientY: 100 } as MouseEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the tooltip after 5 seconds
|
||||||
|
this.tooltipTimeout = setTimeout(() => {
|
||||||
|
this.cleanupTooltip()
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
override onCancel = () => {
|
||||||
|
this.editor.setCurrentTool("select")
|
||||||
|
}
|
||||||
|
|
||||||
|
override onExit = () => {
|
||||||
|
this.cleanupTooltip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupTooltip = () => {
|
||||||
|
// Clear timeout
|
||||||
|
if (this.tooltipTimeout) {
|
||||||
|
clearTimeout(this.tooltipTimeout)
|
||||||
|
this.tooltipTimeout = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove mouse move listener
|
||||||
|
if (this.mouseMoveHandler) {
|
||||||
|
document.removeEventListener('mousemove', this.mouseMoveHandler)
|
||||||
|
this.mouseMoveHandler = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove tooltip element
|
||||||
|
if (this.tooltipElement) {
|
||||||
|
document.body.removeChild(this.tooltipElement)
|
||||||
|
this.tooltipElement = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawableShape = TLDrawShape | TLHighlightShape
|
||||||
|
|
||||||
|
export class Drawing extends StateNode {
|
||||||
|
static override id = "drawing"
|
||||||
|
|
||||||
|
info = {} as TLPointerEventInfo
|
||||||
|
|
||||||
|
initialShape?: DrawableShape
|
||||||
|
|
||||||
|
override shapeType =
|
||||||
|
this.parent.id === "highlight" ? ("highlight" as const) : ("draw" as const)
|
||||||
|
|
||||||
|
util = this.editor.getShapeUtil(this.shapeType)
|
||||||
|
|
||||||
|
isPen = false
|
||||||
|
isPenOrStylus = false
|
||||||
|
|
||||||
|
didJustShiftClickToExtendPreviousShapeLine = false
|
||||||
|
|
||||||
|
pagePointWhereCurrentSegmentChanged = {} as Vec
|
||||||
|
|
||||||
|
pagePointWhereNextSegmentChanged = null as Vec | null
|
||||||
|
|
||||||
|
lastRecordedPoint = {} as Vec
|
||||||
|
mergeNextPoint = false
|
||||||
|
currentLineLength = 0
|
||||||
|
|
||||||
|
canDraw = false
|
||||||
|
|
||||||
|
markId = null as null | string
|
||||||
|
|
||||||
|
override onEnter = (info: TLPointerEventInfo) => {
|
||||||
|
this.markId = null
|
||||||
|
this.info = info
|
||||||
|
this.canDraw = !this.editor.getIsMenuOpen()
|
||||||
|
this.lastRecordedPoint = this.editor.inputs.currentPagePoint.clone()
|
||||||
|
if (this.canDraw) {
|
||||||
|
this.startShape()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onGestureEnd = () => {
|
||||||
|
const shape = this.editor.getShape(this.initialShape?.id!) as TLDrawShape
|
||||||
|
if (!shape) return
|
||||||
|
|
||||||
|
const ps = shape.props.segments[0].points.map((s) => ({ x: s.x, y: s.y }))
|
||||||
|
const gesture = this.editor.inputs.shiftKey ? GestureTool.recognizerAlt.recognize(ps) : GestureTool.recognizer.recognize(ps)
|
||||||
|
const score_pass = gesture.score > 0.2
|
||||||
|
const score_confident = gesture.score > 0.65
|
||||||
|
let score_color: "green" | "red" | "yellow" = "green"
|
||||||
|
if (!score_pass) {
|
||||||
|
score_color = "red"
|
||||||
|
} else if (!score_confident) {
|
||||||
|
score_color = "yellow"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the gesture action if recognized
|
||||||
|
if (score_pass) {
|
||||||
|
gesture.onComplete?.(this.editor, shape)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the gesture shape immediately - it's just a command, not a persistent shape
|
||||||
|
this.editor.deleteShape(shape.id)
|
||||||
|
|
||||||
|
// Optionally show a temporary label with fade-out
|
||||||
|
if (SHOW_LABELS) {
|
||||||
|
const labelShape: TLShapePartial<TLTextShape> = {
|
||||||
|
id: createShapeId(),
|
||||||
|
type: "text",
|
||||||
|
x: this.editor.inputs.currentPagePoint.x + 20,
|
||||||
|
y: this.editor.inputs.currentPagePoint.y,
|
||||||
|
isLocked: false,
|
||||||
|
props: {
|
||||||
|
size: "xl",
|
||||||
|
richText: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: gesture.name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: "doc",
|
||||||
|
},
|
||||||
|
color: score_color,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.editor.createShape(labelShape)
|
||||||
|
|
||||||
|
// Fade out and delete the label
|
||||||
|
let opacity = 1
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
if (opacity > 0) {
|
||||||
|
this.editor.updateShape({
|
||||||
|
...labelShape,
|
||||||
|
opacity: opacity,
|
||||||
|
props: {
|
||||||
|
...labelShape.props,
|
||||||
|
color: score_color,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
opacity = Math.max(0, opacity - 0.025)
|
||||||
|
} else {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
this.editor.deleteShape(labelShape.id)
|
||||||
|
}
|
||||||
|
}, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override onPointerMove: TLEventHandlers["onPointerMove"] = () => {
|
||||||
|
const { inputs } = this.editor
|
||||||
|
|
||||||
|
if (this.isPen && !inputs.isPen) {
|
||||||
|
// The user made a palm gesture before starting a pen gesture;
|
||||||
|
// ideally we'd start the new shape here but we could also just bail
|
||||||
|
// as the next interaction will work correctly
|
||||||
|
if (this.markId) {
|
||||||
|
this.editor.bailToMark(this.markId)
|
||||||
|
this.startShape()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If we came in from a menu but have no started dragging...
|
||||||
|
if (!this.canDraw && inputs.isDragging) {
|
||||||
|
this.startShape()
|
||||||
|
this.canDraw = true // bad name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.canDraw) {
|
||||||
|
if (this.isPenOrStylus) {
|
||||||
|
// Don't update the shape if we haven't moved far enough from the last time we recorded a point
|
||||||
|
if (
|
||||||
|
Vec.Dist(inputs.currentPagePoint, this.lastRecordedPoint) >=
|
||||||
|
1 / this.editor.getZoomLevel()
|
||||||
|
) {
|
||||||
|
this.lastRecordedPoint = inputs.currentPagePoint.clone()
|
||||||
|
this.mergeNextPoint = false
|
||||||
|
} else {
|
||||||
|
this.mergeNextPoint = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.mergeNextPoint = false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateDrawingShape()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override onExit? = () => {
|
||||||
|
this.onGestureEnd()
|
||||||
|
this.editor.snaps.clearIndicators()
|
||||||
|
this.pagePointWhereCurrentSegmentChanged =
|
||||||
|
this.editor.inputs.currentPagePoint.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
canClose() {
|
||||||
|
return this.shapeType !== "highlight"
|
||||||
|
}
|
||||||
|
|
||||||
|
getIsClosed(segments: TLDrawShapeSegment[]) {
|
||||||
|
if (!this.canClose()) return false
|
||||||
|
|
||||||
|
const strokeWidth = STROKE_WIDTH
|
||||||
|
const firstPoint = segments[0].points[0]
|
||||||
|
const lastSegment = segments[segments.length - 1]
|
||||||
|
const lastPoint = lastSegment.points[lastSegment.points.length - 1]
|
||||||
|
|
||||||
|
return (
|
||||||
|
firstPoint !== lastPoint &&
|
||||||
|
this.currentLineLength > strokeWidth * 4 &&
|
||||||
|
Vec.DistMin(firstPoint, lastPoint, strokeWidth * 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private startShape() {
|
||||||
|
const {
|
||||||
|
inputs: { originPagePoint },
|
||||||
|
} = this.editor
|
||||||
|
|
||||||
|
this.markId = this.editor.markHistoryStoppingPoint()
|
||||||
|
|
||||||
|
this.didJustShiftClickToExtendPreviousShapeLine = false
|
||||||
|
|
||||||
|
this.lastRecordedPoint = originPagePoint.clone()
|
||||||
|
|
||||||
|
this.pagePointWhereCurrentSegmentChanged = originPagePoint.clone()
|
||||||
|
const id = createShapeId()
|
||||||
|
|
||||||
|
this.editor.createShapes<DrawableShape>([
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type: this.shapeType,
|
||||||
|
x: originPagePoint.x,
|
||||||
|
y: originPagePoint.y,
|
||||||
|
opacity: 0.5,
|
||||||
|
isLocked: false,
|
||||||
|
props: {
|
||||||
|
isPen: this.isPenOrStylus,
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "free",
|
||||||
|
points: [
|
||||||
|
{
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
z: PRESSURE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
this.currentLineLength = 0
|
||||||
|
this.initialShape = this.editor.getShape<DrawableShape>(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDrawingShape() {
|
||||||
|
const { initialShape } = this
|
||||||
|
const { inputs } = this.editor
|
||||||
|
|
||||||
|
if (!initialShape) return
|
||||||
|
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
} = initialShape
|
||||||
|
|
||||||
|
const shape = this.editor.getShape<DrawableShape>(id)!
|
||||||
|
|
||||||
|
if (!shape) return
|
||||||
|
|
||||||
|
const { segments } = shape.props
|
||||||
|
|
||||||
|
const { x, y, z } = this.editor
|
||||||
|
.getPointInShapeSpace(shape, inputs.currentPagePoint)
|
||||||
|
.toFixed()
|
||||||
|
|
||||||
|
const newPoint = {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z: this.isPenOrStylus ? +(z! * 1.25).toFixed(2) : 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSegments = segments.slice()
|
||||||
|
const newSegment = newSegments[newSegments.length - 1]
|
||||||
|
const newPoints = [...newSegment.points]
|
||||||
|
|
||||||
|
if (newPoints.length && this.mergeNextPoint) {
|
||||||
|
const { z } = newPoints[newPoints.length - 1]
|
||||||
|
newPoints[newPoints.length - 1] = {
|
||||||
|
x: newPoint.x,
|
||||||
|
y: newPoint.y,
|
||||||
|
z: z ? Math.max(z, newPoint.z) : newPoint.z,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.currentLineLength += Vec.Dist(
|
||||||
|
newPoints[newPoints.length - 1],
|
||||||
|
newPoint,
|
||||||
|
)
|
||||||
|
newPoints.push(newPoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
newSegments[newSegments.length - 1] = {
|
||||||
|
...newSegment,
|
||||||
|
points: newPoints,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentLineLength < STROKE_WIDTH * 4) {
|
||||||
|
this.currentLineLength = this.getLineLength(newSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shapePartial: TLShapePartial<DrawableShape> = {
|
||||||
|
id,
|
||||||
|
type: this.shapeType,
|
||||||
|
props: {
|
||||||
|
segments: newSegments,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.canClose()) {
|
||||||
|
; (shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed =
|
||||||
|
this.getIsClosed(newSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editor.updateShapes([shapePartial])
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLineLength(segments: TLDrawShapeSegment[]) {
|
||||||
|
let length = 0
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
for (let i = 0; i < segment.points.length - 1; i++) {
|
||||||
|
const A = segment.points[i]
|
||||||
|
const B = segment.points[i + 1]
|
||||||
|
length += Vec.Dist2(B, A)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.sqrt(length)
|
||||||
|
}
|
||||||
|
|
||||||
|
override onPointerUp: TLEventHandlers["onPointerUp"] = () => {
|
||||||
|
this.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
override onCancel: TLEventHandlers["onCancel"] = () => {
|
||||||
|
this.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override onComplete: TLEventHandlers["onComplete"] = () => {
|
||||||
|
this.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
override onInterrupt: TLEventHandlers["onInterrupt"] = () => {
|
||||||
|
if (this.editor.inputs.isDragging) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.markId) {
|
||||||
|
this.editor.bailToMark(this.markId)
|
||||||
|
}
|
||||||
|
this.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
complete() {
|
||||||
|
if (!this.canDraw) {
|
||||||
|
this.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { initialShape } = this
|
||||||
|
if (!initialShape) return
|
||||||
|
this.editor.updateShapes([
|
||||||
|
{
|
||||||
|
id: initialShape.id,
|
||||||
|
type: initialShape.type,
|
||||||
|
props: { isComplete: true },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
this.parent.transition("idle")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.parent.transition("idle", this.info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,843 @@
|
||||||
|
import { TLRecord, RecordId, TLStore } from "@tldraw/tldraw"
|
||||||
|
import * as Automerge from "@automerge/automerge"
|
||||||
|
|
||||||
|
export function applyAutomergePatchesToTLStore(
|
||||||
|
patches: Automerge.Patch[],
|
||||||
|
store: TLStore
|
||||||
|
) {
|
||||||
|
const toRemove: TLRecord["id"][] = []
|
||||||
|
const updatedObjects: { [id: string]: TLRecord } = {}
|
||||||
|
|
||||||
|
patches.forEach((patch) => {
|
||||||
|
if (!isStorePatch(patch)) return
|
||||||
|
|
||||||
|
const id = pathToId(patch.path)
|
||||||
|
|
||||||
|
// Skip records with empty or invalid IDs
|
||||||
|
if (!id || id === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Skip custom record types that aren't TLDraw records
|
||||||
|
// These should only exist in Automerge, not in TLDraw store
|
||||||
|
// Components like ObsidianVaultBrowser read directly from Automerge
|
||||||
|
if (typeof id === 'string' && id.startsWith('obsidian_vault:')) {
|
||||||
|
return // Skip - not a TLDraw record, don't process
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRecord = getRecordFromStore(store, id)
|
||||||
|
|
||||||
|
// CRITICAL: For shapes, get coordinates from store's current state BEFORE any patch processing
|
||||||
|
// This ensures we preserve coordinates even if patches don't include them
|
||||||
|
// This is especially important when patches come back after store.put operations
|
||||||
|
let storeCoordinates: { x?: number; y?: number } = {}
|
||||||
|
if (existingRecord && existingRecord.typeName === 'shape') {
|
||||||
|
const storeX = (existingRecord as any).x
|
||||||
|
const storeY = (existingRecord as any).y
|
||||||
|
if (typeof storeX === 'number' && !isNaN(storeX) && storeX !== null && storeX !== undefined) {
|
||||||
|
storeCoordinates.x = storeX
|
||||||
|
}
|
||||||
|
if (typeof storeY === 'number' && !isNaN(storeY) && storeY !== null && storeY !== undefined) {
|
||||||
|
storeCoordinates.y = storeY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer typeName from ID pattern if record doesn't exist
|
||||||
|
let defaultTypeName = 'shape'
|
||||||
|
let defaultRecord: any = {
|
||||||
|
id,
|
||||||
|
typeName: 'shape',
|
||||||
|
type: 'geo', // Default shape type
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rotation: 0,
|
||||||
|
isLocked: false,
|
||||||
|
opacity: 1,
|
||||||
|
meta: {},
|
||||||
|
props: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ID pattern indicates a record type
|
||||||
|
// Note: obsidian_vault records are skipped above, so we don't need to handle them here
|
||||||
|
if (typeof id === 'string') {
|
||||||
|
if (id.startsWith('shape:')) {
|
||||||
|
defaultTypeName = 'shape'
|
||||||
|
// Keep default shape record structure
|
||||||
|
} else if (id.startsWith('page:')) {
|
||||||
|
defaultTypeName = 'page'
|
||||||
|
defaultRecord = {
|
||||||
|
id,
|
||||||
|
typeName: 'page',
|
||||||
|
name: '',
|
||||||
|
index: 'a0' as any,
|
||||||
|
meta: {}
|
||||||
|
}
|
||||||
|
} else if (id.startsWith('camera:')) {
|
||||||
|
defaultTypeName = 'camera'
|
||||||
|
defaultRecord = {
|
||||||
|
id,
|
||||||
|
typeName: 'camera',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
z: 1,
|
||||||
|
meta: {}
|
||||||
|
}
|
||||||
|
} else if (id.startsWith('instance:')) {
|
||||||
|
defaultTypeName = 'instance'
|
||||||
|
defaultRecord = {
|
||||||
|
id,
|
||||||
|
typeName: 'instance',
|
||||||
|
currentPageId: 'page:page' as any,
|
||||||
|
meta: {}
|
||||||
|
}
|
||||||
|
} else if (id.startsWith('pointer:')) {
|
||||||
|
defaultTypeName = 'pointer'
|
||||||
|
defaultRecord = {
|
||||||
|
id,
|
||||||
|
typeName: 'pointer',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
lastActivityTimestamp: 0,
|
||||||
|
meta: {}
|
||||||
|
}
|
||||||
|
} else if (id.startsWith('document:')) {
|
||||||
|
defaultTypeName = 'document'
|
||||||
|
defaultRecord = {
|
||||||
|
id,
|
||||||
|
typeName: 'document',
|
||||||
|
gridSize: 10,
|
||||||
|
name: '',
|
||||||
|
meta: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let record = updatedObjects[id] || (existingRecord ? JSON.parse(JSON.stringify(existingRecord)) : defaultRecord)
|
||||||
|
|
||||||
|
// CRITICAL: For shapes, ensure x and y are always present (even if record came from updatedObjects)
|
||||||
|
// This prevents coordinates from being lost when records are created from patches
|
||||||
|
if (record.typeName === 'shape') {
|
||||||
|
if (typeof record.x !== 'number' || record.x === null || isNaN(record.x)) {
|
||||||
|
record = { ...record, x: defaultRecord.x || 0 }
|
||||||
|
}
|
||||||
|
if (typeof record.y !== 'number' || record.y === null || isNaN(record.y)) {
|
||||||
|
record = { ...record, y: defaultRecord.y || 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Ensure typeName matches ID pattern (fixes misclassification)
|
||||||
|
// Note: obsidian_vault records are skipped above, so we don't need to handle them here
|
||||||
|
if (typeof id === 'string') {
|
||||||
|
let correctTypeName = record.typeName
|
||||||
|
if (id.startsWith('shape:') && record.typeName !== 'shape') {
|
||||||
|
correctTypeName = 'shape'
|
||||||
|
} else if (id.startsWith('page:') && record.typeName !== 'page') {
|
||||||
|
correctTypeName = 'page'
|
||||||
|
} else if (id.startsWith('camera:') && record.typeName !== 'camera') {
|
||||||
|
correctTypeName = 'camera'
|
||||||
|
} else if (id.startsWith('instance:') && record.typeName !== 'instance') {
|
||||||
|
correctTypeName = 'instance'
|
||||||
|
} else if (id.startsWith('pointer:') && record.typeName !== 'pointer') {
|
||||||
|
correctTypeName = 'pointer'
|
||||||
|
} else if (id.startsWith('document:') && record.typeName !== 'document') {
|
||||||
|
correctTypeName = 'document'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new object with correct typeName if it changed
|
||||||
|
if (correctTypeName !== record.typeName) {
|
||||||
|
record = { ...record, typeName: correctTypeName } as TLRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Store original x and y before patch application to preserve them
|
||||||
|
// Priority: Use coordinates from store's current state (most reliable), then from record, then undefined
|
||||||
|
// This ensures we preserve coordinates even when patches come back after store.put operations
|
||||||
|
const recordX = (record.typeName === 'shape' && typeof record.x === 'number' && !isNaN(record.x)) ? record.x : undefined
|
||||||
|
const recordY = (record.typeName === 'shape' && typeof record.y === 'number' && !isNaN(record.y)) ? record.y : undefined
|
||||||
|
const originalX = storeCoordinates.x !== undefined ? storeCoordinates.x : recordX
|
||||||
|
const originalY = storeCoordinates.y !== undefined ? storeCoordinates.y : recordY
|
||||||
|
const hadOriginalCoordinates = originalX !== undefined && originalY !== undefined
|
||||||
|
|
||||||
|
switch (patch.action) {
|
||||||
|
case "insert": {
|
||||||
|
updatedObjects[id] = applyInsertToObject(patch, record)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "put":
|
||||||
|
updatedObjects[id] = applyPutToObject(patch, record)
|
||||||
|
break
|
||||||
|
case "del": {
|
||||||
|
const id = pathToId(patch.path)
|
||||||
|
toRemove.push(id as TLRecord["id"])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "splice": {
|
||||||
|
updatedObjects[id] = applySpliceToObject(patch, record)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "inc": {
|
||||||
|
updatedObjects[id] = applyIncToObject(patch, record)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "mark":
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: After patch application, ensure x and y coordinates are preserved for shapes
|
||||||
|
// This prevents coordinates from being reset to 0,0 when patches don't include them
|
||||||
|
if (updatedObjects[id] && updatedObjects[id].typeName === 'shape') {
|
||||||
|
const patchedRecord = updatedObjects[id]
|
||||||
|
const patchedX = (patchedRecord as any).x
|
||||||
|
const patchedY = (patchedRecord as any).y
|
||||||
|
const patchedHasValidX = typeof patchedX === 'number' && !isNaN(patchedX) && patchedX !== null && patchedX !== undefined
|
||||||
|
const patchedHasValidY = typeof patchedY === 'number' && !isNaN(patchedY) && patchedY !== null && patchedY !== undefined
|
||||||
|
|
||||||
|
// CRITICAL: If we had original coordinates, preserve them unless patch explicitly set different valid coordinates
|
||||||
|
// This prevents coordinates from collapsing to 0,0 after bulk upload
|
||||||
|
if (hadOriginalCoordinates) {
|
||||||
|
// Only use patched coordinates if they're explicitly set and different from original
|
||||||
|
// Otherwise, preserve the original coordinates
|
||||||
|
if (patchedHasValidX && patchedX !== originalX) {
|
||||||
|
// Patch explicitly set a different X coordinate - use it
|
||||||
|
updatedObjects[id] = { ...patchedRecord, x: patchedX }
|
||||||
|
} else {
|
||||||
|
// Preserve original X coordinate
|
||||||
|
updatedObjects[id] = { ...patchedRecord, x: originalX }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patchedHasValidY && patchedY !== originalY) {
|
||||||
|
// Patch explicitly set a different Y coordinate - use it
|
||||||
|
updatedObjects[id] = { ...updatedObjects[id], y: patchedY } as TLRecord
|
||||||
|
} else {
|
||||||
|
// Preserve original Y coordinate
|
||||||
|
updatedObjects[id] = { ...updatedObjects[id], y: originalY } as TLRecord
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No original coordinates - use patched values or defaults
|
||||||
|
if (!patchedHasValidX) {
|
||||||
|
updatedObjects[id] = { ...patchedRecord, x: defaultRecord.x || 0 }
|
||||||
|
}
|
||||||
|
if (!patchedHasValidY) {
|
||||||
|
updatedObjects[id] = { ...updatedObjects[id], y: defaultRecord.y || 0 } as TLRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Re-check typeName after patch application to ensure it's still correct
|
||||||
|
// Note: obsidian_vault records are skipped above, so we don't need to handle them here
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sanitize records before putting them in the store
|
||||||
|
const toPut: TLRecord[] = []
|
||||||
|
const failedRecords: any[] = []
|
||||||
|
|
||||||
|
Object.values(updatedObjects).forEach(record => {
|
||||||
|
// Skip records with empty or invalid IDs
|
||||||
|
if (!record || !record.id || record.id === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Skip custom record types that aren't TLDraw records
|
||||||
|
// These should only exist in Automerge, not in TLDraw store
|
||||||
|
if (typeof record.id === 'string' && record.id.startsWith('obsidian_vault:')) {
|
||||||
|
return // Skip - not a TLDraw record
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sanitized = sanitizeRecord(record)
|
||||||
|
toPut.push(sanitized)
|
||||||
|
} catch (error) {
|
||||||
|
// If it's a missing typeName/id error, skip it
|
||||||
|
if (error instanceof Error &&
|
||||||
|
(error.message.includes('missing required typeName') ||
|
||||||
|
error.message.includes('missing required id'))) {
|
||||||
|
// Skip records with missing required fields
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error("Failed to sanitize record:", error, record)
|
||||||
|
failedRecords.push(record)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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`)
|
||||||
|
|
||||||
|
if (failedRecords.length > 0) {
|
||||||
|
console.log({ patches, toPut: toPut.length, failed: failedRecords.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedRecords.length > 0) {
|
||||||
|
console.error("Failed to sanitize records:", failedRecords)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Final safety check - ensure no geo shapes have w/h/geo at top level
|
||||||
|
// Also ensure text shapes don't have props.text (should use props.richText instead)
|
||||||
|
const finalSanitized = toPut.map(record => {
|
||||||
|
if (record.typeName === 'shape' && record.type === 'geo') {
|
||||||
|
// Store values before removing from top level
|
||||||
|
const wValue = 'w' in record ? (record as any).w : undefined
|
||||||
|
const hValue = 'h' in record ? (record as any).h : undefined
|
||||||
|
const geoValue = 'geo' in record ? (record as any).geo : undefined
|
||||||
|
|
||||||
|
// Create cleaned record without w/h/geo at top level
|
||||||
|
const cleaned: any = {}
|
||||||
|
for (const key in record) {
|
||||||
|
if (key !== 'w' && key !== 'h' && key !== 'geo') {
|
||||||
|
cleaned[key] = (record as any)[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure props exists and move values there if needed
|
||||||
|
if (!cleaned.props) cleaned.props = {}
|
||||||
|
if (wValue !== undefined && (!('w' in cleaned.props) || cleaned.props.w === undefined)) {
|
||||||
|
cleaned.props.w = wValue
|
||||||
|
}
|
||||||
|
if (hValue !== undefined && (!('h' in cleaned.props) || cleaned.props.h === undefined)) {
|
||||||
|
cleaned.props.h = hValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: props.geo is REQUIRED for geo shapes - TLDraw validation will fail without it
|
||||||
|
// Use geoValue if available, otherwise default to 'rectangle'
|
||||||
|
if (geoValue !== undefined) {
|
||||||
|
cleaned.props.geo = geoValue
|
||||||
|
} else if (!cleaned.props.geo || cleaned.props.geo === undefined || cleaned.props.geo === null) {
|
||||||
|
// Default to rectangle if geo is missing
|
||||||
|
cleaned.props.geo = 'rectangle'
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: props.dash is REQUIRED for geo shapes - TLDraw validation will fail without it
|
||||||
|
// Ensure it's always set, defaulting to 'draw' if missing
|
||||||
|
if (!cleaned.props.dash || cleaned.props.dash === undefined || cleaned.props.dash === null) {
|
||||||
|
cleaned.props.dash = 'draw'
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned as TLRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Remove props.text from text shapes (TLDraw schema doesn't allow it)
|
||||||
|
if (record.typeName === 'shape' && record.type === 'text' && (record as any).props && 'text' in (record as any).props) {
|
||||||
|
const cleaned = { ...record }
|
||||||
|
if (cleaned.props && 'text' in cleaned.props) {
|
||||||
|
delete (cleaned.props as any).text
|
||||||
|
}
|
||||||
|
return cleaned as TLRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
return record
|
||||||
|
})
|
||||||
|
|
||||||
|
store.mergeRemoteChanges(() => {
|
||||||
|
if (toRemove.length) store.remove(toRemove)
|
||||||
|
if (finalSanitized.length) store.put(finalSanitized)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to clean NaN values from richText content
|
||||||
|
// This prevents SVG export errors when TLDraw tries to render text with invalid coordinates
|
||||||
|
function cleanRichTextNaN(richText: any): any {
|
||||||
|
if (!richText || typeof richText !== 'object') {
|
||||||
|
return richText
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep clone to avoid mutating the original
|
||||||
|
const cleaned = JSON.parse(JSON.stringify(richText))
|
||||||
|
|
||||||
|
// Recursively clean content array
|
||||||
|
if (Array.isArray(cleaned.content)) {
|
||||||
|
cleaned.content = cleaned.content.map((item: any) => {
|
||||||
|
if (typeof item === 'object' && item !== null) {
|
||||||
|
// Remove any NaN values from the item
|
||||||
|
const cleanedItem: any = {}
|
||||||
|
for (const key in item) {
|
||||||
|
const value = item[key]
|
||||||
|
// Skip NaN values - they cause SVG export errors
|
||||||
|
if (typeof value === 'number' && isNaN(value)) {
|
||||||
|
// Skip NaN values
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Recursively clean nested objects
|
||||||
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||||
|
cleanedItem[key] = cleanRichTextNaN(value)
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
cleanedItem[key] = value.map((v: any) =>
|
||||||
|
typeof v === 'object' && v !== null ? cleanRichTextNaN(v) : v
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cleanedItem[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleanedItem
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal sanitization - only fix critical issues that break TLDraw
|
||||||
|
// EXPORTED: Use this same sanitization for patch-based loading (same as dev mode)
|
||||||
|
export function sanitizeRecord(record: any): TLRecord {
|
||||||
|
const sanitized = { ...record }
|
||||||
|
|
||||||
|
// CRITICAL FIXES ONLY - preserve all other properties
|
||||||
|
|
||||||
|
// Only fix critical structural issues
|
||||||
|
if (!sanitized.id || sanitized.id === '') {
|
||||||
|
throw new Error("Record missing required id field")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sanitized.typeName || sanitized.typeName === '') {
|
||||||
|
throw new Error("Record missing required typeName field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// For shapes, only ensure basic required fields exist
|
||||||
|
if (sanitized.typeName === 'shape') {
|
||||||
|
// Ensure required shape fields exist
|
||||||
|
// CRITICAL: Only set defaults if coordinates are truly missing or invalid
|
||||||
|
// DO NOT overwrite valid coordinates (including 0, which is a valid position)
|
||||||
|
// Only set to 0 if the value is undefined, null, or NaN
|
||||||
|
if (sanitized.x === undefined || sanitized.x === null || (typeof sanitized.x === 'number' && isNaN(sanitized.x))) {
|
||||||
|
sanitized.x = 0
|
||||||
|
}
|
||||||
|
if (sanitized.y === undefined || sanitized.y === null || (typeof sanitized.y === 'number' && isNaN(sanitized.y))) {
|
||||||
|
sanitized.y = 0
|
||||||
|
}
|
||||||
|
if (typeof sanitized.rotation !== 'number') sanitized.rotation = 0
|
||||||
|
if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false
|
||||||
|
if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1
|
||||||
|
// CRITICAL: Preserve all existing meta properties - only create empty object if meta doesn't exist
|
||||||
|
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
|
||||||
|
sanitized.meta = {}
|
||||||
|
} else {
|
||||||
|
// Ensure meta is a mutable copy to preserve all properties (including text for rectangles)
|
||||||
|
sanitized.meta = { ...sanitized.meta }
|
||||||
|
}
|
||||||
|
if (!sanitized.index) sanitized.index = 'a1'
|
||||||
|
if (!sanitized.parentId) sanitized.parentId = 'page:page'
|
||||||
|
if (!sanitized.props || typeof sanitized.props !== 'object') sanitized.props = {}
|
||||||
|
|
||||||
|
// CRITICAL: Ensure props is a deep mutable copy to preserve all nested properties
|
||||||
|
// This is essential for custom shapes like ObsNote and for preserving richText in geo shapes
|
||||||
|
// Use JSON parse/stringify to create a deep copy of nested objects (like richText.content)
|
||||||
|
sanitized.props = JSON.parse(JSON.stringify(sanitized.props))
|
||||||
|
|
||||||
|
// CRITICAL: Map old shape type names to new ones (migration support)
|
||||||
|
// This handles renamed shape types from old data
|
||||||
|
if (sanitized.type === 'Transcribe') {
|
||||||
|
sanitized.type = 'Transcription'
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Infer type from properties BEFORE defaulting to 'geo'
|
||||||
|
// This ensures arrows and other shapes are properly recognized
|
||||||
|
if (!sanitized.type || typeof sanitized.type !== 'string') {
|
||||||
|
// Check for arrow-specific properties first
|
||||||
|
if (sanitized.props?.start !== undefined ||
|
||||||
|
sanitized.props?.end !== undefined ||
|
||||||
|
sanitized.props?.arrowheadStart !== undefined ||
|
||||||
|
sanitized.props?.arrowheadEnd !== undefined ||
|
||||||
|
sanitized.props?.kind === 'line' ||
|
||||||
|
sanitized.props?.kind === 'curved' ||
|
||||||
|
sanitized.props?.kind === 'straight') {
|
||||||
|
sanitized.type = 'arrow'
|
||||||
|
}
|
||||||
|
// Check for line-specific properties
|
||||||
|
else if (sanitized.props?.points !== undefined) {
|
||||||
|
sanitized.type = 'line'
|
||||||
|
}
|
||||||
|
// Check for geo-specific properties (w/h/geo)
|
||||||
|
else if (sanitized.props?.geo !== undefined ||
|
||||||
|
('w' in sanitized && 'h' in sanitized) ||
|
||||||
|
('w' in sanitized.props && 'h' in sanitized.props)) {
|
||||||
|
sanitized.type = 'geo'
|
||||||
|
}
|
||||||
|
// Check for note-specific properties
|
||||||
|
else if (sanitized.props?.growY !== undefined ||
|
||||||
|
sanitized.props?.verticalAlign !== undefined) {
|
||||||
|
sanitized.type = 'note'
|
||||||
|
}
|
||||||
|
// Check for text-specific properties
|
||||||
|
else if (sanitized.props?.textAlign !== undefined ||
|
||||||
|
sanitized.props?.autoSize !== undefined) {
|
||||||
|
sanitized.type = 'text'
|
||||||
|
}
|
||||||
|
// Check for draw-specific properties
|
||||||
|
else if (sanitized.props?.segments !== undefined) {
|
||||||
|
sanitized.type = 'draw'
|
||||||
|
}
|
||||||
|
// Default to geo only if no other indicators found
|
||||||
|
else {
|
||||||
|
sanitized.type = 'geo'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: For geo shapes, move w/h/geo from top level to props (required by TLDraw schema)
|
||||||
|
if (sanitized.type === 'geo' || ('w' in sanitized && 'h' in sanitized && sanitized.type !== 'arrow')) {
|
||||||
|
// If type is missing but has w/h, assume it's a geo shape (but only if not already identified as arrow)
|
||||||
|
if (!sanitized.type || sanitized.type === 'geo') {
|
||||||
|
sanitized.type = 'geo'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure props exists
|
||||||
|
if (!sanitized.props) sanitized.props = {}
|
||||||
|
|
||||||
|
// Store values before removing from top level
|
||||||
|
const wValue = 'w' in sanitized ? (sanitized as any).w : undefined
|
||||||
|
const hValue = 'h' in sanitized ? (sanitized as any).h : undefined
|
||||||
|
const geoValue = 'geo' in sanitized ? (sanitized as any).geo : undefined
|
||||||
|
|
||||||
|
// Move w from top level to props (if present at top level)
|
||||||
|
if (wValue !== undefined) {
|
||||||
|
if (!('w' in sanitized.props) || sanitized.props.w === undefined) {
|
||||||
|
sanitized.props.w = wValue
|
||||||
|
}
|
||||||
|
delete (sanitized as any).w
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move h from top level to props (if present at top level)
|
||||||
|
if (hValue !== undefined) {
|
||||||
|
if (!('h' in sanitized.props) || sanitized.props.h === undefined) {
|
||||||
|
sanitized.props.h = hValue
|
||||||
|
}
|
||||||
|
delete (sanitized as any).h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move geo from top level to props (if present at top level)
|
||||||
|
if (geoValue !== undefined) {
|
||||||
|
if (!('geo' in sanitized.props) || sanitized.props.geo === undefined) {
|
||||||
|
sanitized.props.geo = geoValue
|
||||||
|
}
|
||||||
|
delete (sanitized as any).geo
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: props.geo is REQUIRED for geo shapes - TLDraw validation will fail without it
|
||||||
|
// Ensure it's always set, defaulting to 'rectangle' if missing
|
||||||
|
if (!sanitized.props.geo || sanitized.props.geo === undefined || sanitized.props.geo === null) {
|
||||||
|
sanitized.props.geo = 'rectangle'
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: props.dash is REQUIRED for geo shapes - TLDraw validation will fail without it
|
||||||
|
// Ensure it's always set, defaulting to 'draw' if missing
|
||||||
|
if (!sanitized.props.dash || sanitized.props.dash === undefined || sanitized.props.dash === null) {
|
||||||
|
sanitized.props.dash = 'draw'
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fix type if completely missing
|
||||||
|
if (!sanitized.type || typeof sanitized.type !== 'string') {
|
||||||
|
// Simple type inference - only if absolutely necessary
|
||||||
|
if (sanitized.props?.geo) {
|
||||||
|
sanitized.type = 'geo'
|
||||||
|
} else {
|
||||||
|
sanitized.type = 'geo' // Safe default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Fix crop structure for image/video shapes if it exists
|
||||||
|
if (sanitized.type === 'image' || sanitized.type === 'video') {
|
||||||
|
if (sanitized.props.crop !== null && sanitized.props.crop !== undefined) {
|
||||||
|
if (!sanitized.props.crop.topLeft || !sanitized.props.crop.bottomRight) {
|
||||||
|
if (sanitized.props.crop.x !== undefined && sanitized.props.crop.y !== undefined) {
|
||||||
|
// Convert old format to new format
|
||||||
|
sanitized.props.crop = {
|
||||||
|
topLeft: { x: sanitized.props.crop.x || 0, y: sanitized.props.crop.y || 0 },
|
||||||
|
bottomRight: {
|
||||||
|
x: (sanitized.props.crop.x || 0) + (sanitized.props.crop.w || 1),
|
||||||
|
y: (sanitized.props.crop.y || 0) + (sanitized.props.crop.h || 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sanitized.props.crop = {
|
||||||
|
topLeft: { x: 0, y: 0 },
|
||||||
|
bottomRight: { x: 1, y: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Fix line shapes - ensure valid points structure (required by schema)
|
||||||
|
if (sanitized.type === 'line') {
|
||||||
|
// Remove invalid w/h from props (they cause validation errors)
|
||||||
|
if ('w' in sanitized.props) delete sanitized.props.w
|
||||||
|
if ('h' in sanitized.props) delete sanitized.props.h
|
||||||
|
|
||||||
|
// Line shapes REQUIRE points property
|
||||||
|
if (!sanitized.props.points || typeof sanitized.props.points !== 'object' || Array.isArray(sanitized.props.points)) {
|
||||||
|
sanitized.props.points = {
|
||||||
|
'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 },
|
||||||
|
'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Fix group shapes - remove invalid w/h from props
|
||||||
|
if (sanitized.type === 'group') {
|
||||||
|
if ('w' in sanitized.props) delete sanitized.props.w
|
||||||
|
if ('h' in sanitized.props) delete sanitized.props.h
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Fix note shapes - ensure richText structure if it exists
|
||||||
|
if (sanitized.type === 'note') {
|
||||||
|
if (sanitized.props.richText) {
|
||||||
|
if (Array.isArray(sanitized.props.richText)) {
|
||||||
|
sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' }
|
||||||
|
} else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) {
|
||||||
|
if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' }
|
||||||
|
if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
|
||||||
|
if (sanitized.props.richText) {
|
||||||
|
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Fix richText structure for geo shapes (preserve content)
|
||||||
|
if (sanitized.type === 'geo' && sanitized.props.richText) {
|
||||||
|
if (Array.isArray(sanitized.props.richText)) {
|
||||||
|
sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' }
|
||||||
|
} else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) {
|
||||||
|
if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' }
|
||||||
|
if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] }
|
||||||
|
}
|
||||||
|
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
|
||||||
|
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Fix richText structure for text shapes - REQUIRED field
|
||||||
|
if (sanitized.type === 'text') {
|
||||||
|
// 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' }
|
||||||
|
} else if (Array.isArray(sanitized.props.richText)) {
|
||||||
|
sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' }
|
||||||
|
} else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) {
|
||||||
|
if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' }
|
||||||
|
if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] }
|
||||||
|
}
|
||||||
|
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
|
||||||
|
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Remove invalid 'text' property from text shapes (TLDraw schema doesn't allow props.text)
|
||||||
|
// Text shapes should only use props.richText, not props.text
|
||||||
|
if (sanitized.type === 'text' && 'text' in sanitized.props) {
|
||||||
|
delete sanitized.props.text
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Only convert unknown shapes with richText to text if they're truly unknown
|
||||||
|
// DO NOT convert geo/note shapes - they can legitimately have richText
|
||||||
|
if (sanitized.props?.richText && sanitized.type !== 'text' && sanitized.type !== 'geo' && sanitized.type !== 'note') {
|
||||||
|
// This is an unknown shape type with richText - convert to text shape
|
||||||
|
// But preserve all existing properties first
|
||||||
|
const existingProps = { ...sanitized.props }
|
||||||
|
sanitized.type = 'text'
|
||||||
|
sanitized.props = existingProps
|
||||||
|
|
||||||
|
// Fix richText structure if needed
|
||||||
|
if (Array.isArray(sanitized.props.richText)) {
|
||||||
|
sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' }
|
||||||
|
} else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) {
|
||||||
|
if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' }
|
||||||
|
if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] }
|
||||||
|
}
|
||||||
|
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
|
||||||
|
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
|
||||||
|
|
||||||
|
// Only remove properties that cause validation errors (not all "invalid" ones)
|
||||||
|
if ('h' in sanitized.props) delete sanitized.props.h
|
||||||
|
if ('geo' in sanitized.props) delete sanitized.props.geo
|
||||||
|
}
|
||||||
|
} else if (sanitized.typeName === 'instance') {
|
||||||
|
// CRITICAL: Handle instance records - ensure required fields exist
|
||||||
|
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
|
||||||
|
sanitized.meta = {}
|
||||||
|
} else {
|
||||||
|
sanitized.meta = { ...sanitized.meta }
|
||||||
|
}
|
||||||
|
// Only fix critical instance fields that cause validation errors
|
||||||
|
if ('brush' in sanitized && (sanitized.brush === null || sanitized.brush === undefined)) {
|
||||||
|
(sanitized as any).brush = { x: 0, y: 0, w: 0, h: 0 }
|
||||||
|
}
|
||||||
|
if ('zoomBrush' in sanitized && (sanitized.zoomBrush === null || sanitized.zoomBrush === undefined)) {
|
||||||
|
(sanitized as any).zoomBrush = { x: 0, y: 0, w: 0, h: 0 }
|
||||||
|
}
|
||||||
|
if ('insets' in sanitized && (sanitized.insets === undefined || !Array.isArray(sanitized.insets))) {
|
||||||
|
(sanitized as any).insets = [false, false, false, false]
|
||||||
|
}
|
||||||
|
if ('scribbles' in sanitized && (sanitized.scribbles === undefined || !Array.isArray(sanitized.scribbles))) {
|
||||||
|
(sanitized as any).scribbles = []
|
||||||
|
}
|
||||||
|
// CRITICAL: duplicateProps is REQUIRED for instance records - TLDraw validation will fail without it
|
||||||
|
if (!('duplicateProps' in sanitized) || sanitized.duplicateProps === undefined || typeof sanitized.duplicateProps !== 'object') {
|
||||||
|
(sanitized as any).duplicateProps = {
|
||||||
|
shapeIds: [],
|
||||||
|
offset: { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (sanitized.typeName === 'document') {
|
||||||
|
// CRITICAL: Preserve all existing meta properties
|
||||||
|
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
|
||||||
|
sanitized.meta = {}
|
||||||
|
} else {
|
||||||
|
sanitized.meta = { ...sanitized.meta }
|
||||||
|
}
|
||||||
|
} else if (sanitized.typeName === 'page') {
|
||||||
|
// CRITICAL: Preserve all existing meta properties
|
||||||
|
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
|
||||||
|
sanitized.meta = {}
|
||||||
|
} else {
|
||||||
|
sanitized.meta = { ...sanitized.meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStorePatch = (patch: Automerge.Patch): boolean => {
|
||||||
|
return patch.path[0] === "store" && patch.path.length > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to safely get a record from the store
|
||||||
|
const getRecordFromStore = (store: TLStore, id: string): TLRecord | null => {
|
||||||
|
try {
|
||||||
|
return store.get(id as any) as TLRecord | null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// path: ["store", "camera:page:page", "x"] => "camera:page:page"
|
||||||
|
const pathToId = (path: Automerge.Prop[]): RecordId<any> => {
|
||||||
|
return path[1] as RecordId<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyInsertToObject = (patch: Automerge.InsertPatch, object: any): TLRecord => {
|
||||||
|
const { path, values } = patch
|
||||||
|
let current = object
|
||||||
|
const insertionPoint = path[path.length - 1] as number
|
||||||
|
const pathEnd = path[path.length - 2] as string
|
||||||
|
const parts = path.slice(2, -2)
|
||||||
|
|
||||||
|
// Create missing properties as we navigate
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current[part] === undefined || current[part] === null) {
|
||||||
|
// Create missing property - use array for numeric indices
|
||||||
|
if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) {
|
||||||
|
current[part] = []
|
||||||
|
} else {
|
||||||
|
current[part] = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure pathEnd exists and is an array
|
||||||
|
if (current[pathEnd] === undefined || current[pathEnd] === null) {
|
||||||
|
current[pathEnd] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// splice is a mutator... yay.
|
||||||
|
const clone = Array.isArray(current[pathEnd]) ? current[pathEnd].slice(0) : []
|
||||||
|
clone.splice(insertionPoint, 0, ...values)
|
||||||
|
current[pathEnd] = clone
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPutToObject = (patch: Automerge.PutPatch, object: any): TLRecord => {
|
||||||
|
const { path, value } = patch
|
||||||
|
let current = object
|
||||||
|
// special case
|
||||||
|
if (path.length === 2) {
|
||||||
|
// this would be creating the object, but we have done
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = path.slice(2, -2)
|
||||||
|
const property = path[path.length - 1] as string
|
||||||
|
const target = path[path.length - 2] as string
|
||||||
|
|
||||||
|
if (path.length === 3) {
|
||||||
|
return { ...object, [property]: value }
|
||||||
|
}
|
||||||
|
|
||||||
|
// default case - create missing properties as we navigate
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current[part] === undefined || current[part] === null) {
|
||||||
|
// Create missing property - use object for named properties, array for numeric indices
|
||||||
|
if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) {
|
||||||
|
current[part] = []
|
||||||
|
} else {
|
||||||
|
current[part] = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure target exists
|
||||||
|
if (current[target] === undefined || current[target] === null) {
|
||||||
|
current[target] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
current[target] = { ...current[target], [property]: value }
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
const applySpliceToObject = (patch: Automerge.SpliceTextPatch, object: any): TLRecord => {
|
||||||
|
const { path, value } = patch
|
||||||
|
let current = object
|
||||||
|
const insertionPoint = path[path.length - 1] as number
|
||||||
|
const pathEnd = path[path.length - 2] as string
|
||||||
|
const parts = path.slice(2, -2)
|
||||||
|
|
||||||
|
// Create missing properties as we navigate
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current[part] === undefined || current[part] === null) {
|
||||||
|
// Create missing property - use array for numeric indices or when splicing
|
||||||
|
if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) {
|
||||||
|
current[part] = []
|
||||||
|
} else {
|
||||||
|
current[part] = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure pathEnd exists and is an array for splicing
|
||||||
|
if (current[pathEnd] === undefined || current[pathEnd] === null) {
|
||||||
|
current[pathEnd] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: we're not supporting actual splices yet because TLDraw won't generate them natively
|
||||||
|
if (insertionPoint !== 0) {
|
||||||
|
throw new Error("Splices are not supported yet")
|
||||||
|
}
|
||||||
|
current[pathEnd] = value // .splice(insertionPoint, 0, value)
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyIncToObject = (patch: Automerge.IncPatch, object: any): TLRecord => {
|
||||||
|
const { path, value } = patch
|
||||||
|
let current = object
|
||||||
|
const parts = path.slice(2, -1)
|
||||||
|
const pathEnd = path[path.length - 1] as string
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current[part] === undefined) {
|
||||||
|
throw new Error("NO WAY")
|
||||||
|
}
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
current[pathEnd] = (current[pathEnd] || 0) + value
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,476 @@
|
||||||
|
import { Repo, DocHandle, NetworkAdapter, PeerId, PeerMetadata, Message } from "@automerge/automerge-repo"
|
||||||
|
import { TLStoreSnapshot } from "@tldraw/tldraw"
|
||||||
|
import { init } from "./index"
|
||||||
|
|
||||||
|
export class CloudflareAdapter {
|
||||||
|
private repo: Repo
|
||||||
|
private handles: Map<string, DocHandle<TLStoreSnapshot>> = new Map()
|
||||||
|
private workerUrl: string
|
||||||
|
private networkAdapter: CloudflareNetworkAdapter
|
||||||
|
// Track last persisted state to detect changes
|
||||||
|
private lastPersistedState: Map<string, string> = new Map()
|
||||||
|
|
||||||
|
constructor(workerUrl: string, roomId?: string) {
|
||||||
|
this.workerUrl = workerUrl
|
||||||
|
this.networkAdapter = new CloudflareNetworkAdapter(workerUrl, roomId)
|
||||||
|
|
||||||
|
// Create repo with network adapter
|
||||||
|
this.repo = new Repo({
|
||||||
|
sharePolicy: async () => true, // Allow sharing with all peers
|
||||||
|
network: [this.networkAdapter],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a simple hash of the document state for change detection
|
||||||
|
private generateDocHash(doc: any): string {
|
||||||
|
// Create a stable string representation of the document
|
||||||
|
// Focus on the store data which is what actually changes
|
||||||
|
const storeData = doc.store || {}
|
||||||
|
const storeKeys = Object.keys(storeData).sort()
|
||||||
|
const storeString = JSON.stringify(storeData, storeKeys)
|
||||||
|
|
||||||
|
// Simple hash function (you could use a more sophisticated one if needed)
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < storeString.length; i++) {
|
||||||
|
const char = storeString.charCodeAt(i)
|
||||||
|
hash = ((hash << 5) - hash) + char
|
||||||
|
hash = hash & hash // Convert to 32-bit integer
|
||||||
|
}
|
||||||
|
const hashString = hash.toString()
|
||||||
|
return hashString
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate hash of current document state
|
||||||
|
const currentHash = this.generateDocHash(doc)
|
||||||
|
const lastHash = this.lastPersistedState.get(roomId)
|
||||||
|
|
||||||
|
|
||||||
|
// Skip save if document hasn't changed
|
||||||
|
if (currentHash === lastHash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.workerUrl}/room/${roomId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(doc),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to save to Cloudflare: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last persisted state only after successful save
|
||||||
|
this.lastPersistedState.set(roomId, currentHash)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving to Cloudflare:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFromCloudflare(roomId: string): Promise<TLStoreSnapshot | null> {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Add retry logic for connection issues
|
||||||
|
let response: Response;
|
||||||
|
let retries = 3;
|
||||||
|
while (retries > 0) {
|
||||||
|
try {
|
||||||
|
response = await fetch(`${this.workerUrl}/room/${roomId}`)
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
retries--;
|
||||||
|
if (retries > 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response!.ok) {
|
||||||
|
if (response!.status === 404) {
|
||||||
|
return null // Room doesn't exist yet
|
||||||
|
}
|
||||||
|
console.error(`Failed to load from Cloudflare: ${response!.status} ${response!.statusText}`)
|
||||||
|
throw new Error(`Failed to load from Cloudflare: ${response!.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
this.lastPersistedState.set(roomId, docHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading from Cloudflare:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CloudflareNetworkAdapter extends NetworkAdapter {
|
||||||
|
private workerUrl: string
|
||||||
|
private websocket: WebSocket | null = null
|
||||||
|
private roomId: string | null = null
|
||||||
|
public peerId: PeerId | undefined = undefined
|
||||||
|
private readyPromise: Promise<void>
|
||||||
|
private readyResolve: (() => void) | null = null
|
||||||
|
private keepAliveInterval: NodeJS.Timeout | null = null
|
||||||
|
private reconnectTimeout: NodeJS.Timeout | null = null
|
||||||
|
private reconnectAttempts: number = 0
|
||||||
|
private maxReconnectAttempts: number = 5
|
||||||
|
private reconnectDelay: number = 1000
|
||||||
|
private isConnecting: boolean = false
|
||||||
|
private onJsonSyncData?: (data: any) => void
|
||||||
|
|
||||||
|
constructor(workerUrl: string, roomId?: string, onJsonSyncData?: (data: any) => void) {
|
||||||
|
super()
|
||||||
|
this.workerUrl = workerUrl
|
||||||
|
this.roomId = roomId || 'default-room'
|
||||||
|
this.onJsonSyncData = onJsonSyncData
|
||||||
|
this.readyPromise = new Promise((resolve) => {
|
||||||
|
this.readyResolve = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
isReady(): boolean {
|
||||||
|
return this.websocket?.readyState === WebSocket.OPEN
|
||||||
|
}
|
||||||
|
|
||||||
|
whenReady(): Promise<void> {
|
||||||
|
return this.readyPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(peerId: PeerId, peerMetadata?: PeerMetadata): void {
|
||||||
|
if (this.isConnecting) {
|
||||||
|
console.log('🔌 CloudflareAdapter: Connection already in progress, skipping')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store peerId
|
||||||
|
this.peerId = peerId
|
||||||
|
|
||||||
|
// Clean up existing connection
|
||||||
|
this.cleanup()
|
||||||
|
|
||||||
|
// Use the room ID from constructor or default
|
||||||
|
// Add sessionId as a query parameter as required by AutomergeDurableObject
|
||||||
|
const sessionId = peerId || `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
// Convert https:// to wss:// or http:// to ws://
|
||||||
|
const protocol = this.workerUrl.startsWith('https://') ? 'wss://' : 'ws://'
|
||||||
|
const baseUrl = this.workerUrl.replace(/^https?:\/\//, '')
|
||||||
|
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.readyResolve?.()
|
||||||
|
this.startKeepAlive()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.websocket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
// 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)')
|
||||||
|
// Handle binary Automerge sync messages - convert ArrayBuffer to Uint8Array
|
||||||
|
// Automerge Repo expects binary sync messages as Uint8Array
|
||||||
|
const message: Message = {
|
||||||
|
type: 'sync',
|
||||||
|
data: new Uint8Array(event.data),
|
||||||
|
senderId: this.peerId || ('unknown' as PeerId),
|
||||||
|
targetId: this.peerId || ('unknown' as PeerId)
|
||||||
|
}
|
||||||
|
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')
|
||||||
|
const message: Message = {
|
||||||
|
type: 'sync',
|
||||||
|
data: new Uint8Array(buffer),
|
||||||
|
senderId: this.peerId || ('unknown' as PeerId),
|
||||||
|
targetId: this.peerId || ('unknown' as PeerId)
|
||||||
|
}
|
||||||
|
this.emit('message', message)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Handle text messages (our custom protocol for backward compatibility)
|
||||||
|
const message = JSON.parse(event.data)
|
||||||
|
console.log('🔌 CloudflareAdapter: Received WebSocket message:', message.type)
|
||||||
|
|
||||||
|
// Handle ping/pong messages for keep-alive
|
||||||
|
if (message.type === 'ping') {
|
||||||
|
this.sendPong()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle test messages
|
||||||
|
if (message.type === 'test') {
|
||||||
|
console.log('🔌 CloudflareAdapter: Received test message:', message.message)
|
||||||
|
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 is deprecated - all data flows through Automerge sync protocol
|
||||||
|
// Old format content is converted server-side and saved to R2 in Automerge format
|
||||||
|
// Skip JSON sync messages - they should not be sent anymore
|
||||||
|
const isJsonDocumentData = message.data && typeof message.data === 'object' && message.data.store
|
||||||
|
|
||||||
|
if (isJsonDocumentData) {
|
||||||
|
console.warn('⚠️ CloudflareAdapter: Received JSON sync message (deprecated). Ignoring - all data should flow through Automerge sync protocol.')
|
||||||
|
return // Don't process JSON sync messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
targetId: message.targetId || this.peerId || ('unknown' as PeerId),
|
||||||
|
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('❌ 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()
|
||||||
|
|
||||||
|
// 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)')
|
||||||
|
return // Don't reconnect on normal closure
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('close')
|
||||||
|
|
||||||
|
// Attempt to reconnect with exponential backoff
|
||||||
|
this.scheduleReconnect(peerId, peerMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
console.error('Failed to create WebSocket:', error)
|
||||||
|
this.isConnecting = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: Message): void {
|
||||||
|
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)')
|
||||||
|
// Send binary data directly for Automerge's native sync protocol
|
||||||
|
this.websocket.send((message as any).data)
|
||||||
|
} else if (message.type === 'sync' && (message as any).data instanceof Uint8Array) {
|
||||||
|
console.log('🔌 CloudflareAdapter: Sending Uint8Array sync message (Automerge protocol)')
|
||||||
|
// Convert Uint8Array to ArrayBuffer and send
|
||||||
|
this.websocket.send((message as any).data.buffer)
|
||||||
|
} else {
|
||||||
|
// Handle text-based messages (backward compatibility and control messages)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(message: Message): void {
|
||||||
|
// For WebSocket-based adapters, broadcast is the same as send
|
||||||
|
// since we're connected to a single server that handles broadcasting
|
||||||
|
this.send(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.cleanup()
|
||||||
|
this.roomId = null
|
||||||
|
this.emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
this.stopKeepAlive()
|
||||||
|
this.clearReconnectTimeout()
|
||||||
|
|
||||||
|
if (this.websocket) {
|
||||||
|
this.websocket.close(1000, 'Client disconnecting')
|
||||||
|
this.websocket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startKeepAlive(): void {
|
||||||
|
// 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) // 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopKeepAlive(): void {
|
||||||
|
if (this.keepAliveInterval) {
|
||||||
|
clearInterval(this.keepAliveInterval)
|
||||||
|
this.keepAliveInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendPong(): void {
|
||||||
|
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||||
|
this.websocket.send(JSON.stringify({
|
||||||
|
type: 'pong',
|
||||||
|
timestamp: Date.now()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) // 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearReconnectTimeout(): void {
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout)
|
||||||
|
this.reconnectTimeout = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
// Minimal sanitization - only fix critical issues that break TLDraw
|
||||||
|
function minimalSanitizeRecord(record: any): any {
|
||||||
|
const sanitized = { ...record }
|
||||||
|
|
||||||
|
// Only fix critical structural issues
|
||||||
|
if (!sanitized.id) {
|
||||||
|
throw new Error("Record missing required id field")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sanitized.typeName) {
|
||||||
|
throw new Error("Record missing required typeName field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// For shapes, only ensure basic required fields exist
|
||||||
|
if (sanitized.typeName === 'shape') {
|
||||||
|
// Ensure required shape fields exist with defaults
|
||||||
|
if (typeof sanitized.x !== 'number') sanitized.x = 0
|
||||||
|
if (typeof sanitized.y !== 'number') sanitized.y = 0
|
||||||
|
if (typeof sanitized.rotation !== 'number') sanitized.rotation = 0
|
||||||
|
if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false
|
||||||
|
if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1
|
||||||
|
if (!sanitized.meta || typeof sanitized.meta !== 'object') sanitized.meta = {}
|
||||||
|
if (!sanitized.index) sanitized.index = 'a1'
|
||||||
|
if (!sanitized.parentId) sanitized.parentId = 'page:page'
|
||||||
|
|
||||||
|
// Ensure props object exists
|
||||||
|
if (!sanitized.props || typeof sanitized.props !== 'object') {
|
||||||
|
sanitized.props = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fix type if completely missing
|
||||||
|
if (!sanitized.type || typeof sanitized.type !== 'string') {
|
||||||
|
// Simple type inference - check for obvious indicators
|
||||||
|
// CRITICAL: Don't infer text type just because richText exists - geo and note shapes can have richText
|
||||||
|
// Only infer text if there's no geo property and richText exists
|
||||||
|
if ((sanitized.props?.richText || sanitized.props?.text) && !sanitized.props?.geo) {
|
||||||
|
sanitized.type = 'text'
|
||||||
|
} else if (sanitized.props?.geo) {
|
||||||
|
sanitized.type = 'geo'
|
||||||
|
} else {
|
||||||
|
sanitized.type = 'geo' // Safe default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Automerge Integration for TLdraw
|
||||||
|
|
||||||
|
This directory contains the Automerge-based sync implementation that replaces the TLdraw sync system.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `AutomergeToTLStore.ts` - Converts Automerge patches to TLdraw store updates
|
||||||
|
- `TLStoreToAutomerge.ts` - Converts TLdraw store changes to Automerge document updates
|
||||||
|
- `useAutomergeStoreV2.ts` - Core React hook for managing Automerge document state with TLdraw
|
||||||
|
- `useAutomergeSync.ts` - Main sync hook that replaces `useSync` from TLdraw (uses V2 internally)
|
||||||
|
- `CloudflareAdapter.ts` - Adapter for Cloudflare Durable Objects and R2 storage
|
||||||
|
- `default_store.ts` - Default TLdraw store structure for new documents
|
||||||
|
- `index.ts` - Main exports
|
||||||
|
|
||||||
|
## Benefits over TLdraw Sync
|
||||||
|
|
||||||
|
1. **Better Conflict Resolution**: Automerge's CRDT nature handles concurrent edits more elegantly
|
||||||
|
2. **Offline-First**: Works seamlessly offline and syncs when reconnected
|
||||||
|
3. **Smaller Sync Payloads**: Only sends changes (patches) rather than full state
|
||||||
|
4. **Cross-Session Persistence**: Better handling of data across different devices/sessions
|
||||||
|
5. **Automatic Merging**: No manual conflict resolution needed
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Replace the TLdraw sync import:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
import { useSync } from "@tldraw/sync"
|
||||||
|
|
||||||
|
// New
|
||||||
|
import { useAutomergeSync } from "@/automerge/useAutomergeSync"
|
||||||
|
```
|
||||||
|
|
||||||
|
The API is identical, so no other changes are needed in your components.
|
||||||
|
|
||||||
|
## Cloudflare Integration
|
||||||
|
|
||||||
|
The system uses:
|
||||||
|
- **Durable Objects**: For real-time WebSocket connections and document state management
|
||||||
|
- **R2 Storage**: For persistent document storage
|
||||||
|
- **Automerge Network Adapter**: Custom adapter for Cloudflare's infrastructure
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
To switch from TLdraw sync to Automerge sync:
|
||||||
|
|
||||||
|
1. Update the Board component to use `useAutomergeSync`
|
||||||
|
2. Deploy the new worker with Automerge Durable Object
|
||||||
|
3. Update the URI to use `/automerge/connect/` instead of `/connect/`
|
||||||
|
|
||||||
|
The migration is backward compatible - existing TLdraw sync will continue to work while you test the new system.
|
||||||
|
|
@ -0,0 +1,529 @@
|
||||||
|
import { RecordsDiff, TLRecord } from "@tldraw/tldraw"
|
||||||
|
|
||||||
|
// Helper function to clean NaN values from richText content
|
||||||
|
// This prevents SVG export errors when TLDraw tries to render text with invalid coordinates
|
||||||
|
function cleanRichTextNaN(richText: any): any {
|
||||||
|
if (!richText || typeof richText !== 'object') {
|
||||||
|
return richText
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep clone to avoid mutating the original
|
||||||
|
const cleaned = JSON.parse(JSON.stringify(richText))
|
||||||
|
|
||||||
|
// Recursively clean content array
|
||||||
|
if (Array.isArray(cleaned.content)) {
|
||||||
|
cleaned.content = cleaned.content.map((item: any) => {
|
||||||
|
if (typeof item === 'object' && item !== null) {
|
||||||
|
// Remove any NaN values from the item
|
||||||
|
const cleanedItem: any = {}
|
||||||
|
for (const key in item) {
|
||||||
|
const value = item[key]
|
||||||
|
// Skip NaN values - they cause SVG export errors
|
||||||
|
if (typeof value === 'number' && isNaN(value)) {
|
||||||
|
// Skip NaN values
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Recursively clean nested objects
|
||||||
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||||
|
cleanedItem[key] = cleanRichTextNaN(value)
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
cleanedItem[key] = value.map((v: any) =>
|
||||||
|
typeof v === 'object' && v !== null ? cleanRichTextNaN(v) : v
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cleanedItem[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleanedItem
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRecord(record: TLRecord): TLRecord {
|
||||||
|
const sanitized = { ...record }
|
||||||
|
|
||||||
|
// CRITICAL FIXES ONLY - preserve all other properties
|
||||||
|
// This function preserves ALL shape types (native and custom):
|
||||||
|
// - Geo shapes (rectangles, ellipses, etc.) - handled below
|
||||||
|
// - Arrow shapes - handled below
|
||||||
|
// - Custom shapes (ObsNote, Holon, etc.) - all props preserved via deep copy
|
||||||
|
// - All other native shapes (text, note, draw, line, group, image, video, etc.)
|
||||||
|
|
||||||
|
// Ensure required top-level fields exist
|
||||||
|
if (sanitized.typeName === 'shape') {
|
||||||
|
// CRITICAL: Only set defaults if coordinates are truly missing or invalid
|
||||||
|
// DO NOT overwrite valid coordinates (including 0, which is a valid position)
|
||||||
|
// Only set to 0 if the value is undefined, null, or NaN
|
||||||
|
if (sanitized.x === undefined || sanitized.x === null || (typeof sanitized.x === 'number' && isNaN(sanitized.x))) {
|
||||||
|
sanitized.x = 0
|
||||||
|
}
|
||||||
|
if (sanitized.y === undefined || sanitized.y === null || (typeof sanitized.y === 'number' && isNaN(sanitized.y))) {
|
||||||
|
sanitized.y = 0
|
||||||
|
}
|
||||||
|
if (typeof sanitized.rotation !== 'number') sanitized.rotation = 0
|
||||||
|
if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false
|
||||||
|
if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1
|
||||||
|
// CRITICAL: Preserve all existing meta properties - only create empty object if meta doesn't exist
|
||||||
|
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
|
||||||
|
sanitized.meta = {}
|
||||||
|
} else {
|
||||||
|
// Ensure meta is a mutable copy to preserve all properties (including text for rectangles)
|
||||||
|
sanitized.meta = { ...sanitized.meta }
|
||||||
|
}
|
||||||
|
if (!sanitized.props || typeof sanitized.props !== 'object') sanitized.props = {}
|
||||||
|
|
||||||
|
// CRITICAL: Extract richText BEFORE deep copy to handle TLDraw RichText instances properly
|
||||||
|
// TLDraw RichText objects may have methods/getters that don't serialize well
|
||||||
|
let richTextValue: any = undefined
|
||||||
|
try {
|
||||||
|
// Safely check if richText exists using 'in' operator to avoid triggering getters
|
||||||
|
const props = sanitized.props || {}
|
||||||
|
if ('richText' in props) {
|
||||||
|
try {
|
||||||
|
// Use Object.getOwnPropertyDescriptor to safely check if it's a getter
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(props, 'richText')
|
||||||
|
let rt: any = undefined
|
||||||
|
|
||||||
|
if (descriptor && descriptor.get) {
|
||||||
|
// It's a getter - try to call it safely
|
||||||
|
try {
|
||||||
|
rt = descriptor.get.call(props)
|
||||||
|
} catch (getterError) {
|
||||||
|
console.warn(`🔧 TLStoreToAutomerge: Error calling richText getter for shape ${sanitized.id}:`, getterError)
|
||||||
|
rt = undefined
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// It's a regular property - access it directly
|
||||||
|
rt = (props as any).richText
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now process the value
|
||||||
|
if (rt !== undefined && rt !== null) {
|
||||||
|
// Check if it's a function (shouldn't happen, but be safe)
|
||||||
|
if (typeof rt === 'function') {
|
||||||
|
console.warn(`🔧 TLStoreToAutomerge: richText is a function for shape ${sanitized.id}, skipping`)
|
||||||
|
richTextValue = { content: [], type: 'doc' }
|
||||||
|
}
|
||||||
|
// Check if it's an array
|
||||||
|
else if (Array.isArray(rt)) {
|
||||||
|
richTextValue = { content: JSON.parse(JSON.stringify(rt)), type: 'doc' }
|
||||||
|
}
|
||||||
|
// Check if it's an object
|
||||||
|
else if (typeof rt === 'object') {
|
||||||
|
// Extract plain object representation - use JSON to ensure it's serializable
|
||||||
|
try {
|
||||||
|
const serialized = JSON.parse(JSON.stringify(rt))
|
||||||
|
richTextValue = {
|
||||||
|
type: serialized.type || 'doc',
|
||||||
|
content: serialized.content !== undefined ? serialized.content : []
|
||||||
|
}
|
||||||
|
} catch (serializeError) {
|
||||||
|
// If serialization fails, try to extract manually
|
||||||
|
richTextValue = {
|
||||||
|
type: (rt as any).type || 'doc',
|
||||||
|
content: (rt as any).content !== undefined ? (rt as any).content : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Invalid type
|
||||||
|
else {
|
||||||
|
console.warn(`🔧 TLStoreToAutomerge: Invalid richText type for shape ${sanitized.id}:`, typeof rt)
|
||||||
|
richTextValue = { content: [], type: 'doc' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`🔧 TLStoreToAutomerge: Error extracting richText for shape ${sanitized.id}:`, e)
|
||||||
|
richTextValue = { content: [], type: 'doc' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`🔧 TLStoreToAutomerge: Error checking richText for shape ${sanitized.id}:`, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: For all shapes, ensure props is a deep mutable copy to preserve all properties
|
||||||
|
// This is essential for custom shapes like ObsNote and for preserving richText in geo shapes
|
||||||
|
// Use JSON parse/stringify to create a deep copy of nested objects (like richText.content)
|
||||||
|
// Remove richText temporarily to avoid serialization issues
|
||||||
|
try {
|
||||||
|
const propsWithoutRichText: any = {}
|
||||||
|
// Copy all props except richText
|
||||||
|
for (const key in sanitized.props) {
|
||||||
|
if (key !== 'richText') {
|
||||||
|
propsWithoutRichText[key] = (sanitized.props as any)[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sanitized.props = JSON.parse(JSON.stringify(propsWithoutRichText))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`🔧 TLStoreToAutomerge: Error deep copying props for shape ${sanitized.id}:`, e)
|
||||||
|
// Fallback: just copy props without deep copy
|
||||||
|
sanitized.props = { ...sanitized.props }
|
||||||
|
if (richTextValue !== undefined) {
|
||||||
|
delete (sanitized.props as any).richText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: For geo shapes, move w/h/geo from top-level to props (required by TLDraw schema)
|
||||||
|
if (sanitized.type === 'geo') {
|
||||||
|
|
||||||
|
// Move w from top-level to props if needed
|
||||||
|
if ('w' in sanitized && sanitized.w !== undefined) {
|
||||||
|
if ((sanitized.props as any).w === undefined) {
|
||||||
|
(sanitized.props as any).w = (sanitized as any).w
|
||||||
|
}
|
||||||
|
delete (sanitized as any).w
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move h from top-level to props if needed
|
||||||
|
if ('h' in sanitized && sanitized.h !== undefined) {
|
||||||
|
if ((sanitized.props as any).h === undefined) {
|
||||||
|
(sanitized.props as any).h = (sanitized as any).h
|
||||||
|
}
|
||||||
|
delete (sanitized as any).h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move geo from top-level to props if needed
|
||||||
|
if ('geo' in sanitized && sanitized.geo !== undefined) {
|
||||||
|
if ((sanitized.props as any).geo === undefined) {
|
||||||
|
(sanitized.props as any).geo = (sanitized as any).geo
|
||||||
|
}
|
||||||
|
delete (sanitized as any).geo
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Restore richText for geo shapes after deep copy
|
||||||
|
// Fix richText structure if it exists (preserve content, ensure proper format)
|
||||||
|
if (richTextValue !== undefined) {
|
||||||
|
// Clean NaN values to prevent SVG export errors
|
||||||
|
(sanitized.props as any).richText = cleanRichTextNaN(richTextValue)
|
||||||
|
}
|
||||||
|
// CRITICAL: Preserve meta.text for geo shapes - it's used by runLLMprompt for backwards compatibility
|
||||||
|
// Ensure meta.text is preserved if it exists
|
||||||
|
if ((sanitized.meta as any)?.text !== undefined) {
|
||||||
|
// meta.text is already preserved since we copied meta above
|
||||||
|
// Just ensure it's not accidentally deleted
|
||||||
|
}
|
||||||
|
// Note: We don't delete richText if it's missing - it's optional for geo shapes
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: For arrow shapes, preserve text property
|
||||||
|
if (sanitized.type === 'arrow') {
|
||||||
|
// CRITICAL: Preserve text property - only set default if truly missing (preserve empty strings and all other values)
|
||||||
|
if ((sanitized.props as any).text === undefined || (sanitized.props as any).text === null) {
|
||||||
|
(sanitized.props as any).text = ''
|
||||||
|
}
|
||||||
|
// Note: We preserve text even if it's an empty string - that's a valid value
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: For note shapes, preserve richText property (required for note shapes)
|
||||||
|
if (sanitized.type === 'note') {
|
||||||
|
// CRITICAL: Use the extracted richText value if available, otherwise create default
|
||||||
|
if (richTextValue !== undefined) {
|
||||||
|
// Clean NaN values to prevent SVG export errors
|
||||||
|
(sanitized.props as any).richText = cleanRichTextNaN(richTextValue)
|
||||||
|
} else {
|
||||||
|
// Note shapes require richText - create default if missing
|
||||||
|
(sanitized.props as any).richText = { content: [], type: 'doc' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: For ObsNote shapes, ensure all props are preserved (title, content, tags, etc.)
|
||||||
|
if (sanitized.type === 'ObsNote') {
|
||||||
|
// Props are already a mutable copy from above, so all properties are preserved
|
||||||
|
// No special handling needed - just ensure props exists (which we did above)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: For image/video shapes, fix crop structure if it exists
|
||||||
|
if (sanitized.type === 'image' || sanitized.type === 'video') {
|
||||||
|
const props = (sanitized.props as any)
|
||||||
|
|
||||||
|
if (props.crop !== null && props.crop !== undefined) {
|
||||||
|
// Fix crop structure if it has wrong format
|
||||||
|
if (!props.crop.topLeft || !props.crop.bottomRight) {
|
||||||
|
if (props.crop.x !== undefined && props.crop.y !== undefined) {
|
||||||
|
// Convert old format { x, y, w, h } to new format
|
||||||
|
props.crop = {
|
||||||
|
topLeft: { x: props.crop.x || 0, y: props.crop.y || 0 },
|
||||||
|
bottomRight: {
|
||||||
|
x: (props.crop.x || 0) + (props.crop.w || 1),
|
||||||
|
y: (props.crop.y || 0) + (props.crop.h || 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Invalid structure: set to default
|
||||||
|
props.crop = {
|
||||||
|
topLeft: { x: 0, y: 0 },
|
||||||
|
bottomRight: { x: 1, y: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: For group shapes, remove w/h from props (they cause validation errors)
|
||||||
|
if (sanitized.type === 'group') {
|
||||||
|
if ('w' in sanitized.props) delete (sanitized.props as any).w
|
||||||
|
if ('h' in sanitized.props) delete (sanitized.props as any).h
|
||||||
|
}
|
||||||
|
} else if (sanitized.typeName === 'document') {
|
||||||
|
// CRITICAL: Preserve all existing meta properties
|
||||||
|
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
|
||||||
|
sanitized.meta = {}
|
||||||
|
} else {
|
||||||
|
sanitized.meta = { ...sanitized.meta }
|
||||||
|
}
|
||||||
|
} else if (sanitized.typeName === 'instance') {
|
||||||
|
// CRITICAL: Preserve all existing meta properties
|
||||||
|
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
|
||||||
|
sanitized.meta = {}
|
||||||
|
} else {
|
||||||
|
sanitized.meta = { ...sanitized.meta }
|
||||||
|
}
|
||||||
|
// Only fix critical instance fields that cause validation errors
|
||||||
|
if ('brush' in sanitized && (sanitized.brush === null || sanitized.brush === undefined)) {
|
||||||
|
(sanitized as any).brush = { x: 0, y: 0, w: 0, h: 0 }
|
||||||
|
}
|
||||||
|
if ('zoomBrush' in sanitized && (sanitized.zoomBrush === null || sanitized.zoomBrush === undefined)) {
|
||||||
|
(sanitized as any).zoomBrush = { x: 0, y: 0, w: 0, h: 0 }
|
||||||
|
}
|
||||||
|
if ('insets' in sanitized && (sanitized.insets === undefined || !Array.isArray(sanitized.insets))) {
|
||||||
|
(sanitized as any).insets = [false, false, false, false]
|
||||||
|
}
|
||||||
|
if ('scribbles' in sanitized && (sanitized.scribbles === undefined || !Array.isArray(sanitized.scribbles))) {
|
||||||
|
(sanitized as any).scribbles = []
|
||||||
|
}
|
||||||
|
if ('duplicateProps' in sanitized && (sanitized.duplicateProps === undefined || typeof sanitized.duplicateProps !== 'object')) {
|
||||||
|
(sanitized as any).duplicateProps = {
|
||||||
|
shapeIds: [],
|
||||||
|
offset: { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTLStoreChangesToAutomerge(
|
||||||
|
doc: any,
|
||||||
|
changes: RecordsDiff<TLRecord>
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Ensure doc.store exists
|
||||||
|
if (!doc.store) {
|
||||||
|
doc.store = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle added records
|
||||||
|
if (changes.added) {
|
||||||
|
Object.values(changes.added).forEach((record) => {
|
||||||
|
// CRITICAL: For shapes, preserve x and y coordinates before sanitization
|
||||||
|
// This ensures coordinates aren't lost when saving to Automerge
|
||||||
|
let originalX: number | undefined = undefined
|
||||||
|
let originalY: number | undefined = undefined
|
||||||
|
if (record.typeName === 'shape') {
|
||||||
|
originalX = (record as any).x
|
||||||
|
originalY = (record as any).y
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize record before saving to ensure all required fields are present
|
||||||
|
const sanitizedRecord = sanitizeRecord(record)
|
||||||
|
|
||||||
|
// CRITICAL: Restore original coordinates if they were valid
|
||||||
|
// This prevents coordinates from being reset to 0,0 when saving to Automerge
|
||||||
|
if (record.typeName === 'shape' && originalX !== undefined && originalY !== undefined) {
|
||||||
|
if (typeof originalX === 'number' && !isNaN(originalX) && originalX !== null) {
|
||||||
|
(sanitizedRecord as any).x = originalX
|
||||||
|
}
|
||||||
|
if (typeof originalY === 'number' && !isNaN(originalY) && originalY !== null) {
|
||||||
|
(sanitizedRecord as any).y = originalY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const recordToSave = JSON.parse(JSON.stringify(sanitizedRecord))
|
||||||
|
// Let Automerge handle the assignment - it will merge automatically
|
||||||
|
doc.store[record.id] = recordToSave
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle updated records
|
||||||
|
// Simplified: Replace entire record and let Automerge handle merging
|
||||||
|
// This is simpler than deep comparison and leverages Automerge's conflict resolution
|
||||||
|
if (changes.updated) {
|
||||||
|
Object.values(changes.updated).forEach(([_, record]) => {
|
||||||
|
// CRITICAL: For shapes, preserve x and y coordinates before sanitization
|
||||||
|
// This ensures coordinates aren't lost when updating records in Automerge
|
||||||
|
let originalX: number | undefined = undefined
|
||||||
|
let originalY: number | undefined = undefined
|
||||||
|
if (record.typeName === 'shape') {
|
||||||
|
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
|
||||||
|
// This prevents coordinates from being reset to 0,0 when updating records in Automerge
|
||||||
|
if (record.typeName === 'shape' && originalX !== undefined && originalY !== undefined) {
|
||||||
|
if (typeof originalX === 'number' && !isNaN(originalX) && originalX !== null) {
|
||||||
|
(sanitizedRecord as any).x = originalX
|
||||||
|
}
|
||||||
|
if (typeof originalY === 'number' && !isNaN(originalY) && originalY !== null) {
|
||||||
|
(sanitizedRecord as any).y = originalY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle removed records
|
||||||
|
if (changes.removed) {
|
||||||
|
Object.values(changes.removed).forEach((record) => {
|
||||||
|
delete doc.store[record.id]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed deepCompareAndUpdate - we now replace entire records and let Automerge handle merging
|
||||||
|
// This simplifies the code and leverages Automerge's built-in conflict resolution
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
export const DEFAULT_STORE = {
|
||||||
|
store: {
|
||||||
|
"document:document": {
|
||||||
|
gridSize: 10,
|
||||||
|
name: "",
|
||||||
|
meta: {},
|
||||||
|
id: "document:document",
|
||||||
|
typeName: "document",
|
||||||
|
},
|
||||||
|
"pointer:pointer": {
|
||||||
|
id: "pointer:pointer",
|
||||||
|
typeName: "pointer",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
lastActivityTimestamp: 0,
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
"page:page": {
|
||||||
|
meta: {},
|
||||||
|
id: "page:page",
|
||||||
|
name: "Page 1",
|
||||||
|
index: "a1",
|
||||||
|
typeName: "page",
|
||||||
|
},
|
||||||
|
"camera:page:page": {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
z: 1,
|
||||||
|
meta: {},
|
||||||
|
id: "camera:page:page",
|
||||||
|
typeName: "camera",
|
||||||
|
},
|
||||||
|
"instance_page_state:page:page": {
|
||||||
|
editingShapeId: null,
|
||||||
|
croppingShapeId: null,
|
||||||
|
selectedShapeIds: [],
|
||||||
|
hoveredShapeId: null,
|
||||||
|
erasingShapeIds: [],
|
||||||
|
hintingShapeIds: [],
|
||||||
|
focusedGroupId: null,
|
||||||
|
meta: {},
|
||||||
|
id: "instance_page_state:page:page",
|
||||||
|
pageId: "page:page",
|
||||||
|
typeName: "instance_page_state",
|
||||||
|
},
|
||||||
|
"instance:instance": {
|
||||||
|
followingUserId: null,
|
||||||
|
opacityForNextShape: 1,
|
||||||
|
stylesForNextShape: {},
|
||||||
|
brush: { x: 0, y: 0, w: 0, h: 0 },
|
||||||
|
zoomBrush: { x: 0, y: 0, w: 0, h: 0 },
|
||||||
|
scribbles: [],
|
||||||
|
cursor: {
|
||||||
|
type: "default",
|
||||||
|
rotation: 0,
|
||||||
|
},
|
||||||
|
isFocusMode: false,
|
||||||
|
exportBackground: true,
|
||||||
|
isDebugMode: false,
|
||||||
|
isToolLocked: false,
|
||||||
|
screenBounds: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: 720,
|
||||||
|
h: 400,
|
||||||
|
},
|
||||||
|
isGridMode: false,
|
||||||
|
isPenMode: false,
|
||||||
|
chatMessage: "",
|
||||||
|
isChatting: false,
|
||||||
|
highlightedUserIds: [],
|
||||||
|
isFocused: true,
|
||||||
|
devicePixelRatio: 2,
|
||||||
|
insets: [false, false, false, false],
|
||||||
|
isCoarsePointer: false,
|
||||||
|
isHoveringCanvas: false,
|
||||||
|
openMenus: [],
|
||||||
|
isChangingStyle: false,
|
||||||
|
isReadonly: false,
|
||||||
|
meta: {},
|
||||||
|
id: "instance:instance",
|
||||||
|
currentPageId: "page:page",
|
||||||
|
typeName: "instance",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
schemaVersion: 2,
|
||||||
|
sequences: {
|
||||||
|
"com.tldraw.store": 4,
|
||||||
|
"com.tldraw.asset": 1,
|
||||||
|
"com.tldraw.camera": 1,
|
||||||
|
"com.tldraw.document": 2,
|
||||||
|
"com.tldraw.instance": 25,
|
||||||
|
"com.tldraw.instance_page_state": 5,
|
||||||
|
"com.tldraw.page": 1,
|
||||||
|
"com.tldraw.instance_presence": 5,
|
||||||
|
"com.tldraw.pointer": 1,
|
||||||
|
"com.tldraw.shape": 4,
|
||||||
|
"com.tldraw.asset.bookmark": 2,
|
||||||
|
"com.tldraw.asset.image": 4,
|
||||||
|
"com.tldraw.asset.video": 4,
|
||||||
|
"com.tldraw.shape.group": 0,
|
||||||
|
"com.tldraw.shape.text": 2,
|
||||||
|
"com.tldraw.shape.bookmark": 2,
|
||||||
|
"com.tldraw.shape.draw": 2,
|
||||||
|
"com.tldraw.shape.geo": 9,
|
||||||
|
"com.tldraw.shape.note": 7,
|
||||||
|
"com.tldraw.shape.line": 5,
|
||||||
|
"com.tldraw.shape.frame": 0,
|
||||||
|
"com.tldraw.shape.arrow": 5,
|
||||||
|
"com.tldraw.shape.highlight": 1,
|
||||||
|
"com.tldraw.shape.embed": 4,
|
||||||
|
"com.tldraw.shape.image": 3,
|
||||||
|
"com.tldraw.shape.video": 2,
|
||||||
|
"com.tldraw.shape.container": 0,
|
||||||
|
"com.tldraw.shape.element": 0,
|
||||||
|
"com.tldraw.binding.arrow": 0,
|
||||||
|
"com.tldraw.binding.layout": 0,
|
||||||
|
"obsidian_vault": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { TLStoreSnapshot } from "@tldraw/tldraw"
|
||||||
|
import { DEFAULT_STORE } from "./default_store"
|
||||||
|
|
||||||
|
/* a similar pattern to other automerge init functions */
|
||||||
|
export function init(doc: TLStoreSnapshot) {
|
||||||
|
Object.assign(doc, DEFAULT_STORE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the V2 implementation
|
||||||
|
export * from "./useAutomergeStoreV2"
|
||||||
|
export * from "./useAutomergeSync"
|
||||||
|
|
@ -0,0 +1,613 @@
|
||||||
|
import {
|
||||||
|
TLRecord,
|
||||||
|
TLStoreWithStatus,
|
||||||
|
createTLStore,
|
||||||
|
TLStoreSnapshot,
|
||||||
|
} from "@tldraw/tldraw"
|
||||||
|
import { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } from "@tldraw/tlschema"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { DocHandle, DocHandleChangePayload } from "@automerge/automerge-repo"
|
||||||
|
import {
|
||||||
|
useLocalAwareness,
|
||||||
|
useRemoteAwareness,
|
||||||
|
} from "@automerge/automerge-repo-react-hooks"
|
||||||
|
|
||||||
|
import { applyAutomergePatchesToTLStore, sanitizeRecord } from "./AutomergeToTLStore.js"
|
||||||
|
import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js"
|
||||||
|
|
||||||
|
// Import custom shape utilities
|
||||||
|
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
|
||||||
|
import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
|
||||||
|
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
|
||||||
|
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
|
||||||
|
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
|
||||||
|
import { SlideShape } from "@/shapes/SlideShapeUtil"
|
||||||
|
import { PromptShape } from "@/shapes/PromptShapeUtil"
|
||||||
|
import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil"
|
||||||
|
import { TranscriptionShape } from "@/shapes/TranscriptionShapeUtil"
|
||||||
|
import { ObsNoteShape } from "@/shapes/ObsNoteShapeUtil"
|
||||||
|
import { FathomTranscriptShape } from "@/shapes/FathomTranscriptShapeUtil"
|
||||||
|
import { HolonShape } from "@/shapes/HolonShapeUtil"
|
||||||
|
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
|
||||||
|
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
|
||||||
|
import { LocationShareShape } from "@/shapes/LocationShareShapeUtil"
|
||||||
|
|
||||||
|
export function useAutomergeStoreV2({
|
||||||
|
handle,
|
||||||
|
userId: _userId,
|
||||||
|
}: {
|
||||||
|
handle: DocHandle<any>
|
||||||
|
userId: string
|
||||||
|
}): TLStoreWithStatus {
|
||||||
|
console.log("useAutomergeStoreV2 called with handle:", !!handle)
|
||||||
|
|
||||||
|
// Create a custom schema that includes all the custom shapes
|
||||||
|
const customSchema = createTLSchema({
|
||||||
|
shapes: {
|
||||||
|
...defaultShapeSchemas,
|
||||||
|
ChatBox: {} as any,
|
||||||
|
VideoChat: {} as any,
|
||||||
|
Embed: {} as any,
|
||||||
|
Markdown: {} as any,
|
||||||
|
MycrozineTemplate: {} as any,
|
||||||
|
Slide: {} as any,
|
||||||
|
Prompt: {} as any,
|
||||||
|
SharedPiano: {} as any,
|
||||||
|
Transcription: {} as any,
|
||||||
|
ObsNote: {} as any,
|
||||||
|
FathomTranscript: {} as any,
|
||||||
|
Holon: {} as any,
|
||||||
|
ObsidianBrowser: {} as any,
|
||||||
|
FathomMeetingsBrowser: {} as any,
|
||||||
|
LocationShare: {} as any,
|
||||||
|
},
|
||||||
|
bindings: defaultBindingSchemas,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [store] = useState(() => {
|
||||||
|
const store = createTLStore({
|
||||||
|
schema: customSchema,
|
||||||
|
shapeUtils: [
|
||||||
|
ChatBoxShape,
|
||||||
|
VideoChatShape,
|
||||||
|
EmbedShape,
|
||||||
|
MarkdownShape,
|
||||||
|
MycrozineTemplateShape,
|
||||||
|
SlideShape,
|
||||||
|
PromptShape,
|
||||||
|
SharedPianoShape,
|
||||||
|
TranscriptionShape,
|
||||||
|
ObsNoteShape,
|
||||||
|
FathomTranscriptShape,
|
||||||
|
HolonShape,
|
||||||
|
ObsidianBrowserShape,
|
||||||
|
FathomMeetingsBrowserShape,
|
||||||
|
LocationShareShape,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return store
|
||||||
|
})
|
||||||
|
|
||||||
|
const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({
|
||||||
|
status: "loading",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Debug: Log store status when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (storeWithStatus.status === "synced-remote" && storeWithStatus.store) {
|
||||||
|
const allRecords = storeWithStatus.store.allRecords()
|
||||||
|
const shapes = allRecords.filter(r => r.typeName === 'shape')
|
||||||
|
const pages = allRecords.filter(r => r.typeName === 'page')
|
||||||
|
console.log(`📊 useAutomergeStoreV2: Store synced with ${allRecords.length} total records, ${shapes.length} shapes, ${pages.length} pages`)
|
||||||
|
}
|
||||||
|
}, [storeWithStatus.status, storeWithStatus.store])
|
||||||
|
|
||||||
|
/* -------------------- TLDraw <--> Automerge -------------------- */
|
||||||
|
useEffect(() => {
|
||||||
|
// Early return if handle is not available
|
||||||
|
if (!handle) {
|
||||||
|
setStoreWithStatus({ status: "loading" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubs: (() => void)[] = []
|
||||||
|
|
||||||
|
// A hacky workaround to prevent local changes from being applied twice
|
||||||
|
// once into the automerge doc and then back again.
|
||||||
|
let isLocalChange = false
|
||||||
|
|
||||||
|
// Listen for changes from Automerge and apply them to TLDraw
|
||||||
|
const automergeChangeHandler = (payload: DocHandleChangePayload<any>) => {
|
||||||
|
if (isLocalChange) {
|
||||||
|
isLocalChange = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Apply patches from Automerge to TLDraw store
|
||||||
|
if (payload.patches && payload.patches.length > 0) {
|
||||||
|
// Debug: Check if patches contain shapes
|
||||||
|
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 patches contain ${shapePatches.length} shape patches out of ${payload.patches.length} total patches`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recordsBefore = store.allRecords()
|
||||||
|
const shapesBefore = recordsBefore.filter((r: any) => r.typeName === 'shape')
|
||||||
|
|
||||||
|
applyAutomergePatchesToTLStore(payload.patches, store)
|
||||||
|
|
||||||
|
const recordsAfter = store.allRecords()
|
||||||
|
const shapesAfter = recordsAfter.filter((r: any) => r.typeName === 'shape')
|
||||||
|
|
||||||
|
if (shapesAfter.length !== shapesBefore.length) {
|
||||||
|
console.log(`✅ Applied ${payload.patches.length} patches: shapes changed from ${shapesBefore.length} to ${shapesAfter.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only log if there are many patches or if debugging is needed
|
||||||
|
if (payload.patches.length > 5) {
|
||||||
|
console.log(`✅ Successfully applied ${payload.patches.length} patches`)
|
||||||
|
}
|
||||||
|
} catch (patchError) {
|
||||||
|
console.error("Error applying patches batch, attempting individual patch application:", patchError)
|
||||||
|
// Try applying patches one by one to identify problematic ones
|
||||||
|
// This is a fallback - ideally we should fix the data at the source
|
||||||
|
let successCount = 0
|
||||||
|
let failedPatches: any[] = []
|
||||||
|
for (const patch of payload.patches) {
|
||||||
|
try {
|
||||||
|
applyAutomergePatchesToTLStore([patch], store)
|
||||||
|
successCount++
|
||||||
|
} catch (individualPatchError) {
|
||||||
|
failedPatches.push({ patch, error: individualPatchError })
|
||||||
|
console.error(`Failed to apply individual patch:`, individualPatchError)
|
||||||
|
|
||||||
|
// Log the problematic patch for debugging
|
||||||
|
const recordId = patch.path[1] as string
|
||||||
|
console.error("Problematic patch details:", {
|
||||||
|
action: patch.action,
|
||||||
|
path: patch.path,
|
||||||
|
recordId: recordId,
|
||||||
|
value: 'value' in patch ? patch.value : undefined,
|
||||||
|
errorMessage: individualPatchError instanceof Error ? individualPatchError.message : String(individualPatchError)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try to get more context about the failing record
|
||||||
|
try {
|
||||||
|
const existingRecord = store.get(recordId as any)
|
||||||
|
console.error("Existing record that failed:", existingRecord)
|
||||||
|
|
||||||
|
// If it's a geo shape missing props.geo, try to fix it
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Could not retrieve existing record:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log summary
|
||||||
|
if (failedPatches.length > 0) {
|
||||||
|
console.error(`❌ Failed to apply ${failedPatches.length} out of ${payload.patches.length} patches`)
|
||||||
|
// Most common issue: geo shapes missing props.geo - this should be fixed in sanitization
|
||||||
|
const geoShapeErrors = failedPatches.filter(p =>
|
||||||
|
p.error instanceof Error && p.error.message.includes('props.geo')
|
||||||
|
)
|
||||||
|
if (geoShapeErrors.length > 0) {
|
||||||
|
console.error(`⚠️ ${geoShapeErrors.length} failures due to missing props.geo - this should be fixed in AutomergeToTLStore sanitization`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount < payload.patches.length || payload.patches.length > 5) {
|
||||||
|
console.log(`✅ Successfully applied ${successCount} out of ${payload.patches.length} patches`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStoreWithStatus({
|
||||||
|
store,
|
||||||
|
status: "synced-remote",
|
||||||
|
connectionStatus: "online",
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error applying Automerge patches to TLDraw:", error)
|
||||||
|
setStoreWithStatus({
|
||||||
|
store,
|
||||||
|
status: "synced-remote",
|
||||||
|
connectionStatus: "offline",
|
||||||
|
error: error instanceof Error ? error : new Error("Unknown error") as any,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up handler BEFORE initializeStore to catch patches from initial data load
|
||||||
|
handle.on("change", automergeChangeHandler)
|
||||||
|
|
||||||
|
// Listen for changes from TLDraw and apply them to Automerge
|
||||||
|
// CRITICAL: Listen to ALL sources, not just "user", to catch richText/text changes
|
||||||
|
const unsubscribeTLDraw = store.listen(({ changes, source }) => {
|
||||||
|
// 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
|
||||||
|
|
||||||
|
if (totalChanges > 0) {
|
||||||
|
console.log(`🔍 TLDraw store changes detected (source: ${source}):`, {
|
||||||
|
added: Object.keys(changes.added || {}).length,
|
||||||
|
updated: Object.keys(changes.updated || {}).length,
|
||||||
|
removed: Object.keys(changes.removed || {}).length,
|
||||||
|
source: source
|
||||||
|
})
|
||||||
|
|
||||||
|
// DEBUG: Check for richText/text changes in updated records
|
||||||
|
if (changes.updated) {
|
||||||
|
Object.values(changes.updated).forEach(([_, record]) => {
|
||||||
|
if (record.typeName === 'shape') {
|
||||||
|
if (record.type === 'geo' && (record.props as any)?.richText) {
|
||||||
|
console.log(`🔍 Geo shape ${record.id} richText change detected:`, {
|
||||||
|
hasRichText: !!(record.props as any).richText,
|
||||||
|
richTextType: typeof (record.props as any).richText,
|
||||||
|
source: source
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (record.type === 'note' && (record.props as any)?.richText) {
|
||||||
|
console.log(`🔍 Note shape ${record.id} richText change detected:`, {
|
||||||
|
hasRichText: !!(record.props as any).richText,
|
||||||
|
richTextType: typeof (record.props as any).richText,
|
||||||
|
richTextContentLength: Array.isArray((record.props as any).richText?.content)
|
||||||
|
? (record.props as any).richText.content.length
|
||||||
|
: 'not array',
|
||||||
|
source: source
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (record.type === 'arrow' && (record.props as any)?.text !== undefined) {
|
||||||
|
console.log(`🔍 Arrow shape ${record.id} text change detected:`, {
|
||||||
|
hasText: !!(record.props as any).text,
|
||||||
|
textValue: (record.props as any).text,
|
||||||
|
source: source
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (record.type === 'text' && (record.props as any)?.richText) {
|
||||||
|
console.log(`🔍 Text shape ${record.id} richText change detected:`, {
|
||||||
|
hasRichText: !!(record.props as any).richText,
|
||||||
|
richTextType: typeof (record.props as any).richText,
|
||||||
|
source: source
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEBUG: Log added shapes to track what's being created
|
||||||
|
if (changes.added) {
|
||||||
|
Object.values(changes.added).forEach((record) => {
|
||||||
|
if (record.typeName === 'shape') {
|
||||||
|
console.log(`🔍 Shape added: ${record.type} (${record.id})`, {
|
||||||
|
type: record.type,
|
||||||
|
id: record.id,
|
||||||
|
hasRichText: !!(record.props as any)?.richText,
|
||||||
|
hasText: !!(record.props as any)?.text,
|
||||||
|
source: source
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Don't skip changes - always save them to ensure consistency
|
||||||
|
// The isLocalChange flag is only used to prevent feedback loops from Automerge changes
|
||||||
|
// We should always save TLDraw changes, even if they came from Automerge sync
|
||||||
|
// This ensures that all shapes (notes, rectangles, etc.) are consistently persisted
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set flag to prevent feedback loop when this change comes back from Automerge
|
||||||
|
isLocalChange = true
|
||||||
|
|
||||||
|
handle.change((doc) => {
|
||||||
|
applyTLStoreChangesToAutomerge(doc, changes)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset flag after a short delay to allow Automerge change handler to process
|
||||||
|
// This prevents feedback loops while ensuring all changes are saved
|
||||||
|
setTimeout(() => {
|
||||||
|
isLocalChange = false
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
// Only log if there are many changes or if debugging is needed
|
||||||
|
if (totalChanges > 3) {
|
||||||
|
console.log(`✅ Applied ${totalChanges} TLDraw changes to Automerge document`)
|
||||||
|
} else if (totalChanges > 0) {
|
||||||
|
console.log(`✅ Applied ${totalChanges} TLDraw change(s) to Automerge document`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the document actually changed
|
||||||
|
const docAfter = handle.doc()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error applying TLDraw changes to Automerge:", error)
|
||||||
|
// Reset flag on error to prevent getting stuck
|
||||||
|
isLocalChange = false
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
// CRITICAL: Don't filter by source - listen to ALL changes
|
||||||
|
// This ensures we catch richText/text changes regardless of their source
|
||||||
|
// (TLDraw might emit these changes with a different source than "user")
|
||||||
|
scope: "document",
|
||||||
|
})
|
||||||
|
|
||||||
|
unsubs.push(
|
||||||
|
() => handle.off("change", automergeChangeHandler),
|
||||||
|
unsubscribeTLDraw
|
||||||
|
)
|
||||||
|
|
||||||
|
// CRITICAL: Use patch-based loading exclusively (same as dev)
|
||||||
|
// No bulk loading - all data flows through patches via automergeChangeHandler
|
||||||
|
// This ensures production works exactly like dev
|
||||||
|
const initializeStore = async () => {
|
||||||
|
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')
|
||||||
|
|
||||||
|
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: "online",
|
||||||
|
})
|
||||||
|
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: "online",
|
||||||
|
})
|
||||||
|
resolve()
|
||||||
|
} else if (attempts < maxAttempts) {
|
||||||
|
setTimeout(checkForPatches, 200)
|
||||||
|
} else {
|
||||||
|
// Patches didn't come through - handler may have missed them if data was written before handler was set up
|
||||||
|
// This happens when Automerge doc is initialized with server data before the change handler is ready
|
||||||
|
console.warn(`⚠️ No patches received after ${maxAttempts} attempts. Using fallback: loading records directly from Automerge doc.`)
|
||||||
|
console.warn(`⚠️ This is expected when Automerge doc is initialized with server data before handler is ready.`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read all records from Automerge doc and apply them directly to store
|
||||||
|
// CRITICAL: This fallback preserves coordinates properly
|
||||||
|
const allRecords: TLRecord[] = []
|
||||||
|
Object.entries(doc.store).forEach(([id, record]: [string, any]) => {
|
||||||
|
// Skip invalid records and custom record types (same as patch processing)
|
||||||
|
if (!record || !record.typeName || !record.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip obsidian_vault records - they're not TLDraw records
|
||||||
|
if (record.typeName === 'obsidian_vault' ||
|
||||||
|
(typeof record.id === 'string' && record.id.startsWith('obsidian_vault:'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a clean copy of the record
|
||||||
|
const cleanRecord = JSON.parse(JSON.stringify(record))
|
||||||
|
|
||||||
|
// CRITICAL: For shapes, preserve x and y coordinates
|
||||||
|
// We MUST preserve coordinates - they should never be reset to 0,0 unless truly missing
|
||||||
|
if (cleanRecord.typeName === 'shape') {
|
||||||
|
// Store original coordinates BEFORE any processing
|
||||||
|
const originalX = cleanRecord.x
|
||||||
|
const originalY = cleanRecord.y
|
||||||
|
const hadValidX = typeof originalX === 'number' && !isNaN(originalX) && originalX !== null && originalX !== undefined
|
||||||
|
const hadValidY = typeof originalY === 'number' && !isNaN(originalY) && originalY !== null && originalY !== undefined
|
||||||
|
|
||||||
|
// Use the same sanitizeRecord function that patches use
|
||||||
|
// This ensures consistency between dev and production
|
||||||
|
const sanitized = sanitizeRecord(cleanRecord)
|
||||||
|
|
||||||
|
// CRITICAL: ALWAYS restore original coordinates if they were valid
|
||||||
|
// Even if sanitizeRecord preserved them, we ensure they're correct
|
||||||
|
// This prevents any possibility of coordinates being reset
|
||||||
|
if (hadValidX) {
|
||||||
|
const beforeX = (sanitized as any).x
|
||||||
|
(sanitized as any).x = originalX
|
||||||
|
// Log if coordinates were changed during sanitization (for debugging)
|
||||||
|
if (beforeX !== originalX) {
|
||||||
|
console.warn(`⚠️ Coordinate X was changed during sanitization for shape ${cleanRecord.id}: ${originalX} -> ${beforeX}. Restored to ${originalX}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hadValidY) {
|
||||||
|
const beforeY = (sanitized as any).y
|
||||||
|
(sanitized as any).y = originalY
|
||||||
|
// Log if coordinates were changed during sanitization (for debugging)
|
||||||
|
if (beforeY !== originalY) {
|
||||||
|
console.warn(`⚠️ Coordinate Y was changed during sanitization for shape ${cleanRecord.id}: ${originalY} -> ${beforeY}. Restored to ${originalY}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allRecords.push(sanitized)
|
||||||
|
} else {
|
||||||
|
// For non-shapes, just sanitize normally
|
||||||
|
const sanitized = sanitizeRecord(cleanRecord)
|
||||||
|
allRecords.push(sanitized)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`⚠️ Could not serialize/sanitize record ${id}:`, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (allRecords.length > 0) {
|
||||||
|
// Apply records directly to store using mergeRemoteChanges
|
||||||
|
// This bypasses patches but ensures data is loaded (works for both dev and production)
|
||||||
|
// Use mergeRemoteChanges to mark as remote changes (prevents feedback loop)
|
||||||
|
store.mergeRemoteChanges(() => {
|
||||||
|
// Separate pages, shapes, and other records to ensure proper loading order
|
||||||
|
const pageRecords = allRecords.filter(r => r.typeName === 'page')
|
||||||
|
const shapeRecords = allRecords.filter(r => r.typeName === 'shape')
|
||||||
|
const otherRecords = allRecords.filter(r => r.typeName !== 'page' && r.typeName !== 'shape')
|
||||||
|
|
||||||
|
// Put pages first, then other records, then shapes (ensures pages exist before shapes reference them)
|
||||||
|
const recordsToAdd = [...pageRecords, ...otherRecords, ...shapeRecords]
|
||||||
|
store.put(recordsToAdd)
|
||||||
|
})
|
||||||
|
console.log(`✅ Applied ${allRecords.length} records directly to store (fallback for missed patches - coordinates preserved)`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error applying records directly:`, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStoreWithStatus({
|
||||||
|
store,
|
||||||
|
status: "synced-remote",
|
||||||
|
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: "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: "online",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in patch-based initialization:", error)
|
||||||
|
setStoreWithStatus({
|
||||||
|
store,
|
||||||
|
status: "synced-remote",
|
||||||
|
connectionStatus: "online",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeStore()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubs.forEach((unsub) => unsub())
|
||||||
|
}
|
||||||
|
}, [handle, store])
|
||||||
|
|
||||||
|
/* -------------------- Presence -------------------- */
|
||||||
|
// Create a safe handle that won't cause null errors
|
||||||
|
const safeHandle = handle || {
|
||||||
|
on: () => {},
|
||||||
|
off: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
whenReady: () => Promise.resolve(),
|
||||||
|
doc: () => null,
|
||||||
|
change: () => {},
|
||||||
|
broadcast: () => {},
|
||||||
|
} as any
|
||||||
|
|
||||||
|
const [, updateLocalState] = useLocalAwareness({
|
||||||
|
handle: safeHandle,
|
||||||
|
userId: _userId,
|
||||||
|
initialState: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [peerStates] = useRemoteAwareness({
|
||||||
|
handle: safeHandle,
|
||||||
|
localUserId: _userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...storeWithStatus,
|
||||||
|
store,
|
||||||
|
} as TLStoreWithStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presence hook (simplified version)
|
||||||
|
export function useAutomergePresence(params: {
|
||||||
|
handle: DocHandle<any> | null
|
||||||
|
store: any
|
||||||
|
userMetadata: {
|
||||||
|
userId: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
const { handle, store, userMetadata } = params
|
||||||
|
|
||||||
|
// Simple presence implementation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!handle || !store) return
|
||||||
|
|
||||||
|
const updatePresence = () => {
|
||||||
|
// Basic presence update logic
|
||||||
|
console.log("Updating presence for user:", userMetadata.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePresence()
|
||||||
|
}, [handle, store, userMetadata])
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatePresence: () => {},
|
||||||
|
presence: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { useAutomergeSync } from "./useAutomergeSyncRepo"
|
||||||
|
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
import { useMemo, useEffect, useState, useCallback, useRef } from "react"
|
||||||
|
import { TLStoreSnapshot } from "@tldraw/tldraw"
|
||||||
|
import { CloudflareNetworkAdapter } from "./CloudflareAdapter"
|
||||||
|
import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2"
|
||||||
|
import { TLStoreWithStatus } from "@tldraw/tldraw"
|
||||||
|
import { Repo } from "@automerge/automerge-repo"
|
||||||
|
import { DocHandle } from "@automerge/automerge-repo"
|
||||||
|
|
||||||
|
interface AutomergeSyncConfig {
|
||||||
|
uri: string
|
||||||
|
assets?: any
|
||||||
|
shapeUtils?: any[]
|
||||||
|
bindingUtils?: any[]
|
||||||
|
user?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus & { handle: DocHandle<any> | null; presence: ReturnType<typeof useAutomergePresence> } {
|
||||||
|
const { uri, user } = config
|
||||||
|
|
||||||
|
// Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123")
|
||||||
|
const roomId = useMemo(() => {
|
||||||
|
const match = uri.match(/\/connect\/([^\/]+)$/)
|
||||||
|
return match ? match[1] : "default-room"
|
||||||
|
}, [uri])
|
||||||
|
|
||||||
|
// Extract worker URL from URI (remove /connect/roomId part)
|
||||||
|
const workerUrl = useMemo(() => {
|
||||||
|
return uri.replace(/\/connect\/.*$/, '')
|
||||||
|
}, [uri])
|
||||||
|
|
||||||
|
const [handle, setHandle] = useState<any>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const handleRef = useRef<any>(null)
|
||||||
|
const storeRef = useRef<any>(null)
|
||||||
|
|
||||||
|
// Update refs when handle/store changes
|
||||||
|
useEffect(() => {
|
||||||
|
handleRef.current = handle
|
||||||
|
}, [handle])
|
||||||
|
|
||||||
|
// JSON sync is deprecated - all data now flows through Automerge sync protocol
|
||||||
|
// Old format content is converted server-side and saved to R2 in Automerge format
|
||||||
|
// This callback is kept for backwards compatibility but should not be used
|
||||||
|
const applyJsonSyncData = useCallback((_data: TLStoreSnapshot) => {
|
||||||
|
console.warn('⚠️ JSON sync callback called but JSON sync is deprecated. All data should flow through Automerge sync protocol.')
|
||||||
|
// Don't apply JSON sync - let Automerge sync handle everything
|
||||||
|
return
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const [repo] = useState(() => {
|
||||||
|
const adapter = new CloudflareNetworkAdapter(workerUrl, roomId, applyJsonSyncData)
|
||||||
|
return new Repo({
|
||||||
|
network: [adapter]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize Automerge document handle
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true
|
||||||
|
|
||||||
|
const initializeHandle = async () => {
|
||||||
|
try {
|
||||||
|
console.log("🔌 Initializing Automerge Repo with NetworkAdapter for room:", roomId)
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
// CRITICAL: Create a new Automerge document (repo.create() generates a proper document ID)
|
||||||
|
// We can't use repo.find() with a custom ID because Automerge requires specific document ID formats
|
||||||
|
// Instead, we'll create a new document and load initial data from the server
|
||||||
|
const handle = repo.create()
|
||||||
|
|
||||||
|
console.log("Created Automerge handle via Repo:", {
|
||||||
|
handleId: handle.documentId,
|
||||||
|
isReady: handle.isReady()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for the handle to be ready
|
||||||
|
await handle.whenReady()
|
||||||
|
|
||||||
|
// CRITICAL: Always load initial data from the server
|
||||||
|
// The server stores documents in R2 as JSON, so we need to load and initialize the Automerge document
|
||||||
|
console.log("📥 Loading initial data from server...")
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${workerUrl}/room/${roomId}`)
|
||||||
|
if (response.ok) {
|
||||||
|
const serverDoc = await response.json() as TLStoreSnapshot
|
||||||
|
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||||
|
const serverRecordCount = Object.keys(serverDoc.store || {}).length
|
||||||
|
|
||||||
|
console.log(`📥 Loaded document from server: ${serverRecordCount} records, ${serverShapeCount} shapes`)
|
||||||
|
|
||||||
|
// Initialize the Automerge document with server data
|
||||||
|
if (serverDoc.store && serverRecordCount > 0) {
|
||||||
|
handle.change((doc: any) => {
|
||||||
|
// Initialize store if it doesn't exist
|
||||||
|
if (!doc.store) {
|
||||||
|
doc.store = {}
|
||||||
|
}
|
||||||
|
// Copy all records from server document
|
||||||
|
Object.entries(serverDoc.store).forEach(([id, record]) => {
|
||||||
|
doc.store[id] = record
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`✅ Initialized Automerge document with ${serverRecordCount} records from server`)
|
||||||
|
} else {
|
||||||
|
console.log("📥 Server document is empty - starting with empty Automerge document")
|
||||||
|
}
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
console.log("📥 No document found on server (404) - starting with empty document")
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ Failed to load document from server: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error loading initial document from server:", error)
|
||||||
|
// Continue anyway - user can still create new content
|
||||||
|
}
|
||||||
|
|
||||||
|
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 initialized:", {
|
||||||
|
hasDoc: !!finalDoc,
|
||||||
|
storeKeys: finalStoreKeys,
|
||||||
|
shapeCount: finalShapeCount
|
||||||
|
})
|
||||||
|
|
||||||
|
setHandle(handle)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing Automerge handle:", error)
|
||||||
|
if (mounted) {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeHandle()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false
|
||||||
|
}
|
||||||
|
}, [repo, roomId])
|
||||||
|
|
||||||
|
// Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls)
|
||||||
|
// CRITICAL: This ensures new shapes are persisted to R2
|
||||||
|
useEffect(() => {
|
||||||
|
if (!handle) return
|
||||||
|
|
||||||
|
let saveTimeout: NodeJS.Timeout
|
||||||
|
|
||||||
|
const saveDocumentToWorker = async () => {
|
||||||
|
try {
|
||||||
|
const doc = handle.doc()
|
||||||
|
if (!doc || !doc.store) {
|
||||||
|
console.log("🔍 No document to save yet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||||
|
const storeKeys = Object.keys(doc.store).length
|
||||||
|
|
||||||
|
// Track shape types being persisted
|
||||||
|
const shapeTypeCounts = Object.values(doc.store)
|
||||||
|
.filter((r: any) => r?.typeName === 'shape')
|
||||||
|
.reduce((acc: any, r: any) => {
|
||||||
|
const type = r?.type || 'unknown'
|
||||||
|
acc[type] = (acc[type] || 0) + 1
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
console.log(`💾 Persisting document to worker for R2 storage: ${storeKeys} records, ${shapeCount} shapes`)
|
||||||
|
console.log(`💾 Shape type breakdown being persisted:`, shapeTypeCounts)
|
||||||
|
|
||||||
|
// Send document state to worker via POST /room/:roomId
|
||||||
|
// This updates the worker's currentDoc so it can be persisted to R2
|
||||||
|
const response = await fetch(`${workerUrl}/room/${roomId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(doc),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to save to worker: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Successfully sent document state to worker for persistence (${shapeCount} shapes)`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error saving document to worker:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleSave = () => {
|
||||||
|
// Clear existing timeout
|
||||||
|
if (saveTimeout) clearTimeout(saveTimeout)
|
||||||
|
|
||||||
|
// Schedule save with a debounce (2 seconds) to batch rapid changes
|
||||||
|
// This matches the worker's persistence throttle
|
||||||
|
saveTimeout = setTimeout(saveDocumentToWorker, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for changes to the Automerge document
|
||||||
|
const changeHandler = (payload: any) => {
|
||||||
|
const patchCount = payload.patches?.length || 0
|
||||||
|
|
||||||
|
// Check if patches contain shape changes
|
||||||
|
const hasShapeChanges = payload.patches?.some((p: any) => {
|
||||||
|
const id = p.path?.[1]
|
||||||
|
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasShapeChanges) {
|
||||||
|
console.log('🔍 Automerge document changed with shape patches:', {
|
||||||
|
patchCount: patchCount,
|
||||||
|
shapePatches: payload.patches.filter((p: any) => {
|
||||||
|
const id = p.path?.[1]
|
||||||
|
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||||
|
}).length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule save to worker for persistence
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.on('change', changeHandler)
|
||||||
|
|
||||||
|
// Also save immediately on mount to ensure initial state is persisted
|
||||||
|
setTimeout(saveDocumentToWorker, 3000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handle.off('change', changeHandler)
|
||||||
|
if (saveTimeout) clearTimeout(saveTimeout)
|
||||||
|
}
|
||||||
|
}, [handle, roomId, workerUrl])
|
||||||
|
|
||||||
|
// Get user metadata for presence
|
||||||
|
const userMetadata: { userId: string; name: string; color: string } = (() => {
|
||||||
|
if (user && 'userId' in user) {
|
||||||
|
return {
|
||||||
|
userId: (user as { userId: string; name: string; color?: string }).userId,
|
||||||
|
name: (user as { userId: string; name: string; color?: string }).name,
|
||||||
|
color: (user as { userId: string; name: string; color?: string }).color || '#000000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: user?.id || 'anonymous',
|
||||||
|
name: user?.name || 'Anonymous',
|
||||||
|
color: '#000000'
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Use useAutomergeStoreV2 to create a proper TLStore instance that syncs with Automerge
|
||||||
|
const storeWithStatus = useAutomergeStoreV2({
|
||||||
|
handle: handle || null as any,
|
||||||
|
userId: userMetadata.userId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update store ref when store is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (storeWithStatus.store) {
|
||||||
|
storeRef.current = storeWithStatus.store
|
||||||
|
}
|
||||||
|
}, [storeWithStatus.store])
|
||||||
|
|
||||||
|
// Get presence data (only when handle is ready)
|
||||||
|
const presence = useAutomergePresence({
|
||||||
|
handle: handle || null,
|
||||||
|
store: storeWithStatus.store || null,
|
||||||
|
userMetadata
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...storeWithStatus,
|
||||||
|
handle,
|
||||||
|
presence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { Editor, TLShape, TLShapeId } from '@tldraw/tldraw';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A PoC abstract collections class for @tldraw.
|
||||||
|
*/
|
||||||
|
export abstract class BaseCollection {
|
||||||
|
/** A unique identifier for the collection. */
|
||||||
|
abstract id: string;
|
||||||
|
/** A map containing the shapes that belong to this collection, keyed by their IDs. */
|
||||||
|
protected shapes: Map<TLShapeId, TLShape> = new Map();
|
||||||
|
/** A reference to the \@tldraw Editor instance. */
|
||||||
|
protected editor: Editor;
|
||||||
|
/** A set of listeners to be notified when the collection changes. */
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
// TODO: Maybe pass callback to replace updateShape so only CollectionProvider can call it
|
||||||
|
public constructor(editor: Editor) {
|
||||||
|
this.editor = editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when shapes are added to the collection.
|
||||||
|
* @param shapes The shapes being added to the collection.
|
||||||
|
*/
|
||||||
|
protected onAdd(_shapes: TLShape[]): void { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when shapes are removed from the collection.
|
||||||
|
* @param shapes The shapes being removed from the collection.
|
||||||
|
*/
|
||||||
|
protected onRemove(_shapes: TLShape[]) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the membership of the collection changes (i.e., when shapes are added or removed).
|
||||||
|
*/
|
||||||
|
protected onMembershipChange() { }
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the properties of a shape belonging to the collection change.
|
||||||
|
* @param prev The previous version of the shape before the change.
|
||||||
|
* @param next The updated version of the shape after the change.
|
||||||
|
*/
|
||||||
|
protected onShapeChange(_prev: TLShape, _next: TLShape) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the specified shapes to the collection.
|
||||||
|
* @param shapes The shapes to add to the collection.
|
||||||
|
*/
|
||||||
|
public add(shapes: TLShape[]) {
|
||||||
|
shapes.forEach(shape => {
|
||||||
|
this.shapes.set(shape.id, shape)
|
||||||
|
});
|
||||||
|
this.onAdd(shapes);
|
||||||
|
this.onMembershipChange();
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the specified shapes from the collection.
|
||||||
|
* @param shapes The shapes to remove from the collection.
|
||||||
|
*/
|
||||||
|
public remove(shapes: TLShape[]) {
|
||||||
|
shapes.forEach(shape => {
|
||||||
|
this.shapes.delete(shape.id);
|
||||||
|
});
|
||||||
|
this.onRemove(shapes);
|
||||||
|
this.onMembershipChange();
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all shapes from the collection.
|
||||||
|
*/
|
||||||
|
public clear() {
|
||||||
|
this.remove([...this.shapes.values()])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the map of shapes in the collection.
|
||||||
|
* @returns The map of shapes in the collection, keyed by their IDs.
|
||||||
|
*/
|
||||||
|
public getShapes(): Map<TLShapeId, TLShape> {
|
||||||
|
return this.shapes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get size(): number {
|
||||||
|
return this.shapes.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public _onShapeChange(prev: TLShape, next: TLShape) {
|
||||||
|
this.shapes.set(next.id, next)
|
||||||
|
this.onShapeChange(prev, next)
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyListeners() {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe(listener: () => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { TLShape, Editor } from '@tldraw/tldraw';
|
||||||
|
import { BaseCollection } from './BaseCollection';
|
||||||
|
|
||||||
|
interface CollectionContextValue {
|
||||||
|
get: (id: string) => BaseCollection | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Collection = (new (editor: Editor) => BaseCollection)
|
||||||
|
|
||||||
|
interface CollectionContextWrapperProps {
|
||||||
|
editor: Editor | null;
|
||||||
|
collections: Collection[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionContext = createContext<CollectionContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export const CollectionContextWrapper: React.FC<CollectionContextWrapperProps> = ({
|
||||||
|
editor,
|
||||||
|
collections: collectionClasses,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const [collections, setCollections] = useState<Map<string, BaseCollection> | null>(null);
|
||||||
|
|
||||||
|
// Handle shape property changes
|
||||||
|
const handleShapeChange = (prev: TLShape, next: TLShape) => {
|
||||||
|
if (!collections) return;
|
||||||
|
for (const collection of collections.values()) {
|
||||||
|
if (collection.getShapes().has(next.id)) {
|
||||||
|
collection._onShapeChange(prev, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle shape deletions
|
||||||
|
const handleShapeDelete = (shape: TLShape) => {
|
||||||
|
if (!collections) return;
|
||||||
|
for (const collection of collections.values()) {
|
||||||
|
collection.remove([shape]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor) {
|
||||||
|
const initializedCollections = new Map<string, BaseCollection>();
|
||||||
|
for (const ColClass of collectionClasses) {
|
||||||
|
const instance = new ColClass(editor);
|
||||||
|
initializedCollections.set(instance.id, instance);
|
||||||
|
}
|
||||||
|
setCollections(initializedCollections);
|
||||||
|
}
|
||||||
|
}, [editor, collectionClasses]);
|
||||||
|
|
||||||
|
// Subscribe to shape changes in the editor
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && collections) {
|
||||||
|
editor.sideEffects.registerAfterChangeHandler('shape', (prev, next) => {
|
||||||
|
handleShapeChange(prev, next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [editor, collections]);
|
||||||
|
|
||||||
|
// Subscribe to shape deletions in the editor
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && collections) {
|
||||||
|
editor.sideEffects.registerAfterDeleteHandler('shape', (prev) => {
|
||||||
|
handleShapeDelete(prev);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [editor, collections]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
get: (id: string) => collections?.get(id),
|
||||||
|
}), [collections]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollectionContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</CollectionContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook to use collection context within the wrapper
|
||||||
|
export const useCollectionContext = <T extends BaseCollection = BaseCollection>(
|
||||||
|
collectionId: string
|
||||||
|
): { collection: T | null; size: number } => {
|
||||||
|
const context = useContext(CollectionContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
return { collection: null, size: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection = context.get(collectionId);
|
||||||
|
if (!collection) {
|
||||||
|
return { collection: null, size: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [size, setSize] = useState<number>(collection.size);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = collection.subscribe(() => {
|
||||||
|
setSize(collection.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
setSize(collection.size);
|
||||||
|
return unsubscribe;
|
||||||
|
}, [collection]);
|
||||||
|
|
||||||
|
return { collection: collection as T, size };
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import React, { createContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { TLShape, TLRecord, Editor, useEditor } from '@tldraw/tldraw';
|
||||||
|
import { BaseCollection } from './BaseCollection';
|
||||||
|
|
||||||
|
interface CollectionContextValue {
|
||||||
|
get: (id: string) => BaseCollection | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Collection = (new (editor: Editor) => BaseCollection)
|
||||||
|
|
||||||
|
interface CollectionProviderProps {
|
||||||
|
editor: Editor | null;
|
||||||
|
collections: Collection[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionContext = createContext<CollectionContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
const CollectionProvider: React.FC<CollectionProviderProps> = ({ editor, collections: collectionClasses, children }) => {
|
||||||
|
const [collections, setCollections] = useState<Map<string, BaseCollection> | null>(null);
|
||||||
|
|
||||||
|
// Handle shape property changes
|
||||||
|
const handleShapeChange = (prev: TLShape, next: TLShape) => {
|
||||||
|
if (!collections) return; // Ensure collections is not null
|
||||||
|
for (const collection of collections.values()) {
|
||||||
|
if (collection.getShapes().has(next.id)) {
|
||||||
|
collection._onShapeChange(prev, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle shape deletions
|
||||||
|
const handleShapeDelete = (shape: TLShape) => {
|
||||||
|
if (!collections) return; // Ensure collections is not null
|
||||||
|
for (const collection of collections.values()) {
|
||||||
|
collection.remove([shape]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor) {
|
||||||
|
const initializedCollections = new Map<string, BaseCollection>();
|
||||||
|
for (const ColClass of collectionClasses) {
|
||||||
|
const instance = new ColClass(editor);
|
||||||
|
initializedCollections.set(instance.id, instance);
|
||||||
|
}
|
||||||
|
setCollections(initializedCollections);
|
||||||
|
}
|
||||||
|
}, [editor, collectionClasses]);
|
||||||
|
|
||||||
|
// Subscribe to shape changes in the editor
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && collections) {
|
||||||
|
editor.sideEffects.registerAfterChangeHandler('shape', (prev, next) => {
|
||||||
|
handleShapeChange(prev, next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [editor, collections]);
|
||||||
|
|
||||||
|
// Subscribe to shape deletions in the editor
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && collections) {
|
||||||
|
editor.sideEffects.registerAfterDeleteHandler('shape', (prev) => {
|
||||||
|
handleShapeDelete(prev);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [editor, collections]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
get: (id: string) => collections?.get(id),
|
||||||
|
}), [collections]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollectionContext.Provider value={value}>
|
||||||
|
{collections ? children : null}
|
||||||
|
</CollectionContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { CollectionContext, CollectionProvider, type Collection };
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Editor, TLShape } from '@tldraw/tldraw';
|
||||||
|
import { BaseCollection } from './BaseCollection';
|
||||||
|
|
||||||
|
type Collection = (new (editor: Editor) => BaseCollection)
|
||||||
|
|
||||||
|
class GlobalCollectionManager {
|
||||||
|
private static instance: GlobalCollectionManager;
|
||||||
|
private collections: Map<string, BaseCollection> = new Map();
|
||||||
|
private editor: Editor | null = null;
|
||||||
|
private listeners: Set<() => void> = new Set();
|
||||||
|
|
||||||
|
static getInstance(): GlobalCollectionManager {
|
||||||
|
if (!GlobalCollectionManager.instance) {
|
||||||
|
GlobalCollectionManager.instance = new GlobalCollectionManager();
|
||||||
|
}
|
||||||
|
return GlobalCollectionManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize(editor: Editor, collectionClasses: Collection[]) {
|
||||||
|
this.editor = editor;
|
||||||
|
this.collections.clear();
|
||||||
|
|
||||||
|
for (const ColClass of collectionClasses) {
|
||||||
|
const instance = new ColClass(editor);
|
||||||
|
this.collections.set(instance.id, instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to shape changes
|
||||||
|
editor.sideEffects.registerAfterChangeHandler('shape', (prev, next) => {
|
||||||
|
this.handleShapeChange(prev, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to shape deletions
|
||||||
|
editor.sideEffects.registerAfterDeleteHandler('shape', (prev) => {
|
||||||
|
this.handleShapeDelete(prev);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleShapeChange(prev: TLShape, next: TLShape) {
|
||||||
|
for (const collection of this.collections.values()) {
|
||||||
|
if (collection.getShapes().has(next.id)) {
|
||||||
|
collection._onShapeChange(prev, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleShapeDelete(shape: TLShape) {
|
||||||
|
for (const collection of this.collections.values()) {
|
||||||
|
collection.remove([shape]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCollection(id: string): BaseCollection | undefined {
|
||||||
|
return this.collections.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: () => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyListeners() {
|
||||||
|
this.listeners.forEach(listener => listener());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook to use the global collection manager
|
||||||
|
export const useGlobalCollection = (collectionId: string) => {
|
||||||
|
const [collection, setCollection] = useState<BaseCollection | null>(null);
|
||||||
|
const [size, setSize] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const manager = GlobalCollectionManager.getInstance();
|
||||||
|
|
||||||
|
const unsubscribe = manager.subscribe(() => {
|
||||||
|
const newCollection = manager.getCollection(collectionId);
|
||||||
|
setCollection(newCollection || null);
|
||||||
|
setSize(newCollection?.size || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial setup
|
||||||
|
const initialCollection = manager.getCollection(collectionId);
|
||||||
|
setCollection(initialCollection || null);
|
||||||
|
setSize(initialCollection?.size || 0);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [collectionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (collection) {
|
||||||
|
const unsubscribe = collection.subscribe(() => {
|
||||||
|
setSize(collection.size);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}
|
||||||
|
}, [collection]);
|
||||||
|
|
||||||
|
return { collection, size };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to initialize the global collection manager
|
||||||
|
export const initializeGlobalCollections = (editor: Editor, collectionClasses: Collection[]) => {
|
||||||
|
const manager = GlobalCollectionManager.getInstance();
|
||||||
|
manager.initialize(editor, collectionClasses);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
# Collections System
|
||||||
|
|
||||||
|
This directory contains a proof-of-concept collections system for @tldraw that allows you to group and track shapes with custom logic.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The collections system provides a way to:
|
||||||
|
- Group shapes together with custom logic
|
||||||
|
- React to shape additions, removals, and changes
|
||||||
|
- Subscribe to collection changes in React components
|
||||||
|
- Maintain collections across shape modifications
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `BaseCollection.ts` - Abstract base class for all collections
|
||||||
|
- `CollectionProvider.tsx` - React context provider for collections
|
||||||
|
- `useCollection.ts` - React hook for accessing collections
|
||||||
|
- `ExampleCollection.ts` - Example collection implementation
|
||||||
|
- `ExampleCollectionComponent.tsx` - Example React component using collections
|
||||||
|
- `index.ts` - Exports all collection-related modules
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### 1. Create a Collection
|
||||||
|
|
||||||
|
Extend `BaseCollection` to create your own collection:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BaseCollection } from '@/collections';
|
||||||
|
import { TLShape } from '@tldraw/tldraw';
|
||||||
|
|
||||||
|
export class MyCollection extends BaseCollection {
|
||||||
|
id = 'my-collection';
|
||||||
|
|
||||||
|
protected onAdd(shapes: TLShape[]): void {
|
||||||
|
console.log(`Added ${shapes.length} shapes to my collection`);
|
||||||
|
// Add your custom logic here
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onRemove(shapes: TLShape[]): void {
|
||||||
|
console.log(`Removed ${shapes.length} shapes from my collection`);
|
||||||
|
// Add your custom logic here
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onShapeChange(prev: TLShape, next: TLShape): void {
|
||||||
|
console.log('Shape changed in my collection:', { prev, next });
|
||||||
|
// Add your custom logic here
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onMembershipChange(): void {
|
||||||
|
console.log(`My collection membership changed. Total shapes: ${this.size}`);
|
||||||
|
// Add your custom logic here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set up the CollectionProvider
|
||||||
|
|
||||||
|
Wrap your Tldraw component with the CollectionProvider:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CollectionProvider } from '@/collections';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const [editor, setEditor] = useState<Editor | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{editor && (
|
||||||
|
<CollectionProvider editor={editor} collections={[MyCollection]}>
|
||||||
|
<Tldraw
|
||||||
|
onMount={(editor) => setEditor(editor)}
|
||||||
|
// ... other props
|
||||||
|
/>
|
||||||
|
</CollectionProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use Collections in React Components
|
||||||
|
|
||||||
|
Use the `useCollection` hook to access collections:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useCollection } from '@/collections';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { collection, size } = useCollection<MyCollection>('my-collection');
|
||||||
|
|
||||||
|
const handleAddShapes = () => {
|
||||||
|
const selectedShapes = collection.editor.getSelectedShapes();
|
||||||
|
if (selectedShapes.length > 0) {
|
||||||
|
collection.add(selectedShapes);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Collection size: {size}</p>
|
||||||
|
<button onClick={handleAddShapes}>Add Selected Shapes</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### BaseCollection
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
- `add(shapes: TLShape[])` - Add shapes to the collection
|
||||||
|
- `remove(shapes: TLShape[])` - Remove shapes from the collection
|
||||||
|
- `clear()` - Remove all shapes from the collection
|
||||||
|
- `getShapes(): Map<TLShapeId, TLShape>` - Get all shapes in the collection
|
||||||
|
- `subscribe(listener: () => void): () => void` - Subscribe to collection changes
|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
|
||||||
|
- `size: number` - Number of shapes in the collection
|
||||||
|
- `editor: Editor` - Reference to the tldraw editor
|
||||||
|
|
||||||
|
#### Protected Methods (Override these)
|
||||||
|
|
||||||
|
- `onAdd(shapes: TLShape[])` - Called when shapes are added
|
||||||
|
- `onRemove(shapes: TLShape[])` - Called when shapes are removed
|
||||||
|
- `onShapeChange(prev: TLShape, next: TLShape)` - Called when a shape changes
|
||||||
|
- `onMembershipChange()` - Called when collection membership changes
|
||||||
|
|
||||||
|
### useCollection Hook
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { collection, size } = useCollection<T extends BaseCollection>(collectionId: string)
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- `collection: T` - The collection instance
|
||||||
|
- `size: number` - Current number of shapes in the collection
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
See `ExampleCollection.ts` and `ExampleCollectionComponent.tsx` for a complete working example that demonstrates:
|
||||||
|
|
||||||
|
- Creating a custom collection
|
||||||
|
- Setting up the CollectionProvider
|
||||||
|
- Using the useCollection hook
|
||||||
|
- Adding/removing shapes from collections
|
||||||
|
- Reacting to collection changes
|
||||||
|
|
||||||
|
The example is integrated into the Board component and provides a UI for testing the collection functionality.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './BaseCollection';
|
||||||
|
export * from './CollectionProvider';
|
||||||
|
export * from './CollectionContextWrapper';
|
||||||
|
export * from './GlobalCollectionManager';
|
||||||
|
export * from './useCollection';
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useContext, useEffect, useState } from "react";
|
||||||
|
import { CollectionContext } from "./CollectionProvider";
|
||||||
|
import { BaseCollection } from "./BaseCollection";
|
||||||
|
|
||||||
|
export const useCollection = <T extends BaseCollection = BaseCollection>(collectionId: string): { collection: T | null; size: number } => {
|
||||||
|
const context = useContext(CollectionContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
return { collection: null, size: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection = context.get(collectionId);
|
||||||
|
if (!collection) {
|
||||||
|
return { collection: null, size: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [size, setSize] = useState<number>(collection.size);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Subscribe to collection changes
|
||||||
|
const unsubscribe = collection.subscribe(() => {
|
||||||
|
setSize(collection.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial size
|
||||||
|
setSize(collection.size);
|
||||||
|
|
||||||
|
return unsubscribe; // Cleanup on unmount
|
||||||
|
}, [collection]);
|
||||||
|
|
||||||
|
return { collection: collection as T, size };
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return this.props.fallback || (
|
||||||
|
<div style={{
|
||||||
|
padding: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#dc3545',
|
||||||
|
background: '#f8d7da',
|
||||||
|
border: '1px solid #f5c6cb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
margin: '20px'
|
||||||
|
}}>
|
||||||
|
<h2>Something went wrong</h2>
|
||||||
|
<p>An error occurred while loading the application.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => this.setState({ hasError: false, error: undefined })}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#007acc',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,479 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useEditor } from 'tldraw'
|
||||||
|
import { createShapeId } from 'tldraw'
|
||||||
|
import { WORKER_URL, LOCAL_WORKER_URL } from '../constants/workerUrl'
|
||||||
|
|
||||||
|
interface FathomMeeting {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
created_at: string
|
||||||
|
duration: number
|
||||||
|
summary?: {
|
||||||
|
markdown_formatted: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FathomMeetingsPanelProps {
|
||||||
|
onClose: () => void
|
||||||
|
shapeMode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetingsPanelProps) {
|
||||||
|
const editor = useEditor()
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [showApiKeyInput, setShowApiKeyInput] = useState(false)
|
||||||
|
const [meetings, setMeetings] = useState<FathomMeeting[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if API key is already stored
|
||||||
|
const storedApiKey = localStorage.getItem('fathom_api_key')
|
||||||
|
if (storedApiKey) {
|
||||||
|
setApiKey(storedApiKey)
|
||||||
|
fetchMeetings()
|
||||||
|
} else {
|
||||||
|
setShowApiKeyInput(true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchMeetings = async () => {
|
||||||
|
if (!apiKey) {
|
||||||
|
setError('Please enter your Fathom API key')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try production worker first, fallback to local if needed
|
||||||
|
let response
|
||||||
|
try {
|
||||||
|
response = await fetch(`${WORKER_URL}/fathom/meetings`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Production worker failed, trying local worker...')
|
||||||
|
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Check if response is JSON
|
||||||
|
const contentType = response.headers.get('content-type')
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
const errorData = await response.json() as { error?: string }
|
||||||
|
setError(errorData.error || `HTTP ${response.status}: ${response.statusText}`)
|
||||||
|
} else {
|
||||||
|
setError(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { data?: FathomMeeting[] }
|
||||||
|
setMeetings(data.data || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching meetings:', error)
|
||||||
|
setError(`Failed to fetch meetings: ${(error as Error).message}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveApiKey = () => {
|
||||||
|
if (apiKey) {
|
||||||
|
localStorage.setItem('fathom_api_key', apiKey)
|
||||||
|
setShowApiKeyInput(false)
|
||||||
|
fetchMeetings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addMeetingToCanvas = async (meeting: FathomMeeting) => {
|
||||||
|
try {
|
||||||
|
// Fetch full meeting details
|
||||||
|
let response
|
||||||
|
try {
|
||||||
|
response = await fetch(`${WORKER_URL}/fathom/meetings/${meeting.id}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Production worker failed, trying local worker...')
|
||||||
|
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings/${meeting.id}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(`Failed to fetch meeting details: ${response.status}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullMeeting = await response.json() as any
|
||||||
|
|
||||||
|
// Create Fathom transcript shape
|
||||||
|
const shapeId = createShapeId()
|
||||||
|
editor.createShape({
|
||||||
|
id: shapeId,
|
||||||
|
type: 'FathomTranscript',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
props: {
|
||||||
|
meetingId: fullMeeting.id || '',
|
||||||
|
meetingTitle: fullMeeting.title || '',
|
||||||
|
meetingUrl: fullMeeting.url || '',
|
||||||
|
summary: fullMeeting.default_summary?.markdown_formatted || '',
|
||||||
|
transcript: fullMeeting.transcript?.map((entry: any) => ({
|
||||||
|
speaker: entry.speaker?.display_name || 'Unknown',
|
||||||
|
text: entry.text,
|
||||||
|
timestamp: entry.timestamp
|
||||||
|
})) || [],
|
||||||
|
actionItems: fullMeeting.action_items?.map((item: any) => ({
|
||||||
|
text: item.text,
|
||||||
|
assignee: item.assignee,
|
||||||
|
dueDate: item.due_date
|
||||||
|
})) || [],
|
||||||
|
isExpanded: false,
|
||||||
|
showTranscript: true,
|
||||||
|
showActionItems: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onClose()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding meeting to canvas:', error)
|
||||||
|
setError(`Failed to add meeting: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const remainingSeconds = seconds % 60
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// If in shape mode, don't use modal overlay
|
||||||
|
const contentStyle: React.CSSProperties = shapeMode ? {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '20px',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
position: 'relative',
|
||||||
|
userSelect: 'text',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
} : {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '20px',
|
||||||
|
maxWidth: '600px',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
width: '90%',
|
||||||
|
overflow: 'auto',
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10001,
|
||||||
|
userSelect: 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div style={contentStyle} onClick={(e) => shapeMode ? undefined : e.stopPropagation()}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '20px',
|
||||||
|
paddingBottom: '10px',
|
||||||
|
borderBottom: '1px solid #eee'
|
||||||
|
}}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 'bold' }}>
|
||||||
|
🎥 Fathom Meetings
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '5px',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10002,
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showApiKeyInput ? (
|
||||||
|
<div>
|
||||||
|
<p style={{
|
||||||
|
marginBottom: '10px',
|
||||||
|
fontSize: '14px',
|
||||||
|
userSelect: 'text',
|
||||||
|
cursor: 'text'
|
||||||
|
}}>
|
||||||
|
Enter your Fathom API key to access your meetings:
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder="Your Fathom API key"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10002,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
userSelect: 'text',
|
||||||
|
cursor: 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<button
|
||||||
|
onClick={saveApiKey}
|
||||||
|
disabled={!apiKey}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: apiKey ? '#007bff' : '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: apiKey ? 'pointer' : 'not-allowed',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10002,
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save & Load Meetings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10002,
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}>
|
||||||
|
<button
|
||||||
|
onClick={fetchMeetings}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: loading ? '#6c757d' : '#007bff',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10002,
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Loading...' : 'Refresh Meetings'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem('fathom_api_key')
|
||||||
|
setApiKey('')
|
||||||
|
setShowApiKeyInput(true)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10002,
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Change API Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#f8d7da',
|
||||||
|
color: '#721c24',
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
border: '1px solid #f5c6cb',
|
||||||
|
userSelect: 'text',
|
||||||
|
cursor: 'text'
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||||
|
{meetings.length === 0 ? (
|
||||||
|
<p style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#666',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
userSelect: 'text',
|
||||||
|
cursor: 'text'
|
||||||
|
}}>
|
||||||
|
No meetings found. Click "Refresh Meetings" to load your Fathom meetings.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
meetings.map((meeting) => (
|
||||||
|
<div
|
||||||
|
key={meeting.id}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
userSelect: 'text',
|
||||||
|
cursor: 'text'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flex: 1, userSelect: 'text', cursor: 'text' }}>
|
||||||
|
<h3 style={{
|
||||||
|
margin: '0 0 8px 0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
userSelect: 'text',
|
||||||
|
cursor: 'text'
|
||||||
|
}}>
|
||||||
|
{meeting.title}
|
||||||
|
</h3>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: '8px',
|
||||||
|
userSelect: 'text',
|
||||||
|
cursor: 'text'
|
||||||
|
}}>
|
||||||
|
<div>📅 {formatDate(meeting.created_at)}</div>
|
||||||
|
<div>⏱️ Duration: {formatDuration(meeting.duration)}</div>
|
||||||
|
</div>
|
||||||
|
{meeting.summary && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: '8px',
|
||||||
|
userSelect: 'text',
|
||||||
|
cursor: 'text'
|
||||||
|
}}>
|
||||||
|
<strong>Summary:</strong> {meeting.summary.markdown_formatted.substring(0, 100)}...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => addMeetingToCanvas(meeting)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
backgroundColor: '#28a745',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
marginLeft: '10px',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10002,
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add to Canvas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// If in shape mode, return content directly
|
||||||
|
if (shapeMode) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, return with modal overlay
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 10000
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,370 @@
|
||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { holosphereService, HoloSphereService, HolonData, HolonLens } from '@/lib/HoloSphereService'
|
||||||
|
import * as h3 from 'h3-js'
|
||||||
|
|
||||||
|
interface HolonBrowserProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSelectHolon: (holonData: HolonData) => void
|
||||||
|
shapeMode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HolonInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
resolution: number
|
||||||
|
resolutionName: string
|
||||||
|
data: Record<string, any>
|
||||||
|
lastUpdated: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false }: HolonBrowserProps) {
|
||||||
|
const [holonId, setHolonId] = useState('')
|
||||||
|
const [holonInfo, setHolonInfo] = useState<HolonInfo | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [lenses, setLenses] = useState<string[]>([])
|
||||||
|
const [selectedLens, setSelectedLens] = useState<string>('')
|
||||||
|
const [lensData, setLensData] = useState<any>(null)
|
||||||
|
const [isLoadingData, setIsLoadingData] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleSearchHolon = async () => {
|
||||||
|
if (!holonId.trim()) {
|
||||||
|
setError('Please enter a Holon ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setHolonInfo(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate that the holonId is a valid H3 index
|
||||||
|
if (!h3.isValidCell(holonId)) {
|
||||||
|
throw new Error('Invalid H3 cell ID')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get holon information
|
||||||
|
const resolution = h3.getResolution(holonId)
|
||||||
|
const [lat, lng] = h3.cellToLatLng(holonId)
|
||||||
|
|
||||||
|
// Try to get metadata from the holon
|
||||||
|
let metadata = null
|
||||||
|
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
|
||||||
|
// Use the improved categories from HolonShapeUtil
|
||||||
|
const commonLenses = [
|
||||||
|
'active_users', 'users', 'rankings', 'stats', 'tasks', 'progress',
|
||||||
|
'events', 'activities', 'items', 'shopping', 'active_items',
|
||||||
|
'proposals', 'offers', 'requests', 'checklists', 'roles',
|
||||||
|
'general', 'metadata', 'environment', 'social', 'economic', 'cultural', 'data'
|
||||||
|
]
|
||||||
|
const availableLenses: string[] = []
|
||||||
|
|
||||||
|
for (const lens of commonLenses) {
|
||||||
|
try {
|
||||||
|
// Use getDataWithWait for better Gun data retrieval (shorter timeout for browser)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no lenses found, add 'general' as default
|
||||||
|
if (availableLenses.length === 0) {
|
||||||
|
availableLenses.push('general')
|
||||||
|
}
|
||||||
|
|
||||||
|
const holonData: HolonInfo = {
|
||||||
|
id: holonId,
|
||||||
|
name: metadata?.name || `Holon ${holonId.slice(-8)}`,
|
||||||
|
description: metadata?.description || '',
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lng,
|
||||||
|
resolution: resolution,
|
||||||
|
resolutionName: HoloSphereService.getResolutionName(resolution),
|
||||||
|
data: {},
|
||||||
|
lastUpdated: metadata?.lastUpdated || Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
setHolonInfo(holonData)
|
||||||
|
setLenses(availableLenses)
|
||||||
|
setSelectedLens(availableLenses[0])
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching holon:', error)
|
||||||
|
setError(`Failed to load holon: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadLensData = async (lens: string) => {
|
||||||
|
if (!holonInfo) return
|
||||||
|
|
||||||
|
setIsLoadingData(true)
|
||||||
|
try {
|
||||||
|
// 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)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedLens && holonInfo) {
|
||||||
|
handleLoadLensData(selectedLens)
|
||||||
|
}
|
||||||
|
}, [selectedLens, holonInfo])
|
||||||
|
|
||||||
|
const handleSelectHolon = () => {
|
||||||
|
if (holonInfo) {
|
||||||
|
const holonData: HolonData = {
|
||||||
|
id: holonInfo.id,
|
||||||
|
name: holonInfo.name,
|
||||||
|
description: holonInfo.description,
|
||||||
|
latitude: holonInfo.latitude,
|
||||||
|
longitude: holonInfo.longitude,
|
||||||
|
resolution: holonInfo.resolution,
|
||||||
|
data: holonInfo.data,
|
||||||
|
timestamp: holonInfo.lastUpdated
|
||||||
|
}
|
||||||
|
onSelectHolon(holonData)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearchHolon()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const contentStyle: React.CSSProperties = shapeMode ? {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '20px',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
} : {}
|
||||||
|
|
||||||
|
const renderContent = () => (
|
||||||
|
<>
|
||||||
|
{!shapeMode && (
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">🌐 Holon Browser</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 text-2xl"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
|
Enter a Holon ID to browse its data and import it to your canvas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={shapeMode ? { display: 'flex', flexDirection: 'column', gap: '24px', flex: 1, overflow: 'auto' } : { padding: '24px', display: 'flex', flexDirection: 'column', gap: '24px', maxHeight: 'calc(90vh - 120px)', overflowY: 'auto' }}>
|
||||||
|
{/* Holon ID Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Holon ID
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={holonId}
|
||||||
|
onChange={(e) => setHolonId(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="e.g., 1002848305066"
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 z-[10001] relative"
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{ zIndex: 10001 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSearchHolon}
|
||||||
|
disabled={isLoading || !holonId.trim()}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed z-[10001] relative"
|
||||||
|
style={{ zIndex: 10001 }}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Searching...' : 'Search'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-600 text-sm mt-2">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Holon Information */}
|
||||||
|
{holonInfo && (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||||
|
📍 {holonInfo.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Coordinates</p>
|
||||||
|
<p className="font-mono text-sm">
|
||||||
|
{holonInfo.latitude.toFixed(6)}, {holonInfo.longitude.toFixed(6)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Resolution</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{holonInfo.resolutionName} (Level {holonInfo.resolution})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Holon ID</p>
|
||||||
|
<p className="font-mono text-xs break-all">{holonInfo.id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Last Updated</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{new Date(holonInfo.lastUpdated).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{holonInfo.description && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm text-gray-600">Description</p>
|
||||||
|
<p className="text-sm text-gray-800">{holonInfo.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Available Lenses */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm text-gray-600 mb-2">Available Data Categories</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{lenses.map((lens) => (
|
||||||
|
<button
|
||||||
|
key={lens}
|
||||||
|
onClick={() => setSelectedLens(lens)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm z-[10001] relative ${
|
||||||
|
selectedLens === lens
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
style={{ zIndex: 10001 }}
|
||||||
|
>
|
||||||
|
{lens}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lens Data */}
|
||||||
|
{selectedLens && (
|
||||||
|
<div className="border-t border-gray-200 pt-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="text-md font-medium text-gray-900">
|
||||||
|
Data: {selectedLens}
|
||||||
|
</h4>
|
||||||
|
{isLoadingData && (
|
||||||
|
<span className="text-sm text-gray-500">Loading...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lensData && (
|
||||||
|
<div className="bg-gray-50 rounded-md p-3 max-h-48 overflow-y-auto">
|
||||||
|
<pre className="text-xs text-gray-800 whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(lensData, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!lensData && !isLoadingData && (
|
||||||
|
<p className="text-sm text-gray-500 italic">
|
||||||
|
No data available for this category
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-3 mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={handleSelectHolon}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 z-[10001] relative"
|
||||||
|
style={{ zIndex: 10001 }}
|
||||||
|
>
|
||||||
|
Import to Canvas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setHolonInfo(null)
|
||||||
|
setHolonId('')
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 z-[10001] relative"
|
||||||
|
style={{ zIndex: 10001 }}
|
||||||
|
>
|
||||||
|
Search Another
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
// If in shape mode, return content without modal overlay
|
||||||
|
if (shapeMode) {
|
||||||
|
return (
|
||||||
|
<div style={contentStyle}>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, return with modal overlay
|
||||||
|
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-4xl w-full mx-4 max-h-[90vh] overflow-hidden z-[10000]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNotifications, Notification } from '../context/NotificationContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to display a single notification
|
||||||
|
*/
|
||||||
|
const NotificationItem: React.FC<{
|
||||||
|
notification: Notification;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
}> = ({ notification, onClose }) => {
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
const exitDuration = 300; // ms for exit animation
|
||||||
|
|
||||||
|
// Set up automatic dismissal based on notification timeout
|
||||||
|
useEffect(() => {
|
||||||
|
if (notification.timeout > 0) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsExiting(true);
|
||||||
|
|
||||||
|
// Wait for exit animation before removing
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose(notification.id);
|
||||||
|
}, exitDuration);
|
||||||
|
}, notification.timeout);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [notification, onClose]);
|
||||||
|
|
||||||
|
// Handle manual close
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsExiting(true);
|
||||||
|
|
||||||
|
// Wait for exit animation before removing
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose(notification.id);
|
||||||
|
}, exitDuration);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine icon based on notification type
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (notification.type) {
|
||||||
|
case 'success':
|
||||||
|
return '✓';
|
||||||
|
case 'error':
|
||||||
|
return '✕';
|
||||||
|
case 'warning':
|
||||||
|
return '⚠';
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return 'ℹ';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`notification ${notification.type} ${isExiting ? 'exiting' : ''}`}
|
||||||
|
style={{
|
||||||
|
animationDuration: `${exitDuration}ms`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="notification-icon">
|
||||||
|
{getIcon()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="notification-content">
|
||||||
|
{notification.msg}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="notification-close"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Close notification"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays all active notifications
|
||||||
|
*/
|
||||||
|
const NotificationsDisplay: React.FC = () => {
|
||||||
|
const { notifications, removeNotification } = useNotifications();
|
||||||
|
|
||||||
|
// Don't render anything if there are no notifications
|
||||||
|
if (notifications.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="notifications-container">
|
||||||
|
{notifications.map((notification) => (
|
||||||
|
<NotificationItem
|
||||||
|
key={notification.id}
|
||||||
|
notification={notification}
|
||||||
|
onClose={removeNotification}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationsDisplay;
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Editor } from 'tldraw'
|
||||||
|
|
||||||
|
interface ObsidianToolbarButtonProps {
|
||||||
|
editor: Editor
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ObsidianToolbarButton: React.FC<ObsidianToolbarButtonProps> = ({
|
||||||
|
editor: _editor,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const handleOpenBrowser = () => {
|
||||||
|
// Dispatch event to open the centralized vault browser in CustomToolbar
|
||||||
|
const event = new CustomEvent('open-obsidian-browser')
|
||||||
|
window.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleOpenBrowser}
|
||||||
|
className={`obsidian-toolbar-button ${className}`}
|
||||||
|
title="Import from Obsidian Vault"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M3 5C3 3.89543 3.89543 3 5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 8H16M8 12H16M8 16H12"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="18" cy="6" r="2" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<span>Obsidian</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ObsidianToolbarButton
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,253 @@
|
||||||
|
import React, { useState, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface StandardizedToolWrapperProps {
|
||||||
|
/** The title to display in the header */
|
||||||
|
title: string
|
||||||
|
/** The primary color for this tool (used for header and accents) */
|
||||||
|
primaryColor: string
|
||||||
|
/** The content to render inside the wrapper */
|
||||||
|
children: ReactNode
|
||||||
|
/** Whether the shape is currently selected */
|
||||||
|
isSelected: boolean
|
||||||
|
/** Width of the tool */
|
||||||
|
width: number
|
||||||
|
/** Height of the tool */
|
||||||
|
height: number
|
||||||
|
/** Callback when close button is clicked */
|
||||||
|
onClose: () => void
|
||||||
|
/** Callback when minimize button is clicked */
|
||||||
|
onMinimize?: () => void
|
||||||
|
/** Whether the tool is minimized */
|
||||||
|
isMinimized?: boolean
|
||||||
|
/** Optional custom header content */
|
||||||
|
headerContent?: ReactNode
|
||||||
|
/** Editor instance for shape selection */
|
||||||
|
editor?: any
|
||||||
|
/** Shape ID for selection handling */
|
||||||
|
shapeId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardized wrapper component for all custom tools on the canvas.
|
||||||
|
* Provides consistent header bar with close/minimize buttons, sizing, and color theming.
|
||||||
|
*/
|
||||||
|
export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = ({
|
||||||
|
title,
|
||||||
|
primaryColor,
|
||||||
|
children,
|
||||||
|
isSelected,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
onClose,
|
||||||
|
onMinimize,
|
||||||
|
isMinimized = false,
|
||||||
|
headerContent,
|
||||||
|
editor,
|
||||||
|
shapeId,
|
||||||
|
}) => {
|
||||||
|
const [isHoveringHeader, setIsHoveringHeader] = useState(false)
|
||||||
|
|
||||||
|
|
||||||
|
// Calculate header background color (lighter shade of primary color)
|
||||||
|
const headerBgColor = isSelected
|
||||||
|
? primaryColor
|
||||||
|
: isHoveringHeader
|
||||||
|
? `${primaryColor}15` // 15% opacity
|
||||||
|
: `${primaryColor}10` // 10% opacity
|
||||||
|
|
||||||
|
const wrapperStyle: React.CSSProperties = {
|
||||||
|
width: typeof width === 'number' ? `${width}px` : width,
|
||||||
|
height: isMinimized ? 40 : (typeof height === 'number' ? `${height}px` : height), // Minimized height is just the header
|
||||||
|
backgroundColor: "white",
|
||||||
|
border: isSelected ? `2px solid ${primaryColor}` : `1px solid ${primaryColor}40`,
|
||||||
|
borderRadius: "8px",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: isSelected
|
||||||
|
? `0 0 0 2px ${primaryColor}40, 0 4px 8px rgba(0,0,0,0.15)`
|
||||||
|
: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
fontFamily: "Inter, sans-serif",
|
||||||
|
position: 'relative',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
transition: 'height 0.2s ease, box-shadow 0.2s ease',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerStyle: React.CSSProperties = {
|
||||||
|
height: '40px',
|
||||||
|
backgroundColor: headerBgColor,
|
||||||
|
borderBottom: `1px solid ${primaryColor}30`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 12px',
|
||||||
|
cursor: 'move',
|
||||||
|
userSelect: 'none',
|
||||||
|
flexShrink: 0,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
transition: 'background-color 0.2s ease',
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleStyle: React.CSSProperties = {
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: isSelected ? 'white' : primaryColor,
|
||||||
|
flex: 1,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transition: 'color 0.2s ease',
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonContainerStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'center',
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonBaseStyle: React.CSSProperties = {
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'background-color 0.15s ease, color 0.15s ease',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
flexShrink: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimizeButtonStyle: React.CSSProperties = {
|
||||||
|
...buttonBaseStyle,
|
||||||
|
backgroundColor: isSelected ? 'rgba(255,255,255,0.2)' : `${primaryColor}20`,
|
||||||
|
color: isSelected ? 'white' : primaryColor,
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeButtonStyle: React.CSSProperties = {
|
||||||
|
...buttonBaseStyle,
|
||||||
|
backgroundColor: isSelected ? 'rgba(255,255,255,0.2)' : `${primaryColor}20`,
|
||||||
|
color: isSelected ? 'white' : primaryColor,
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
height: isMinimized ? 0 : 'calc(100% - 40px)',
|
||||||
|
overflow: 'auto',
|
||||||
|
position: 'relative',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
transition: 'height 0.2s ease',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHeaderPointerDown = (e: React.PointerEvent) => {
|
||||||
|
// Check if this is an interactive element (button)
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
const isInteractive =
|
||||||
|
target.tagName === 'BUTTON' ||
|
||||||
|
target.closest('button') ||
|
||||||
|
target.closest('[role="button"]')
|
||||||
|
|
||||||
|
if (isInteractive) {
|
||||||
|
// Buttons handle their own behavior and stop propagation
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't stop the event - let tldraw handle it naturally
|
||||||
|
// The hand tool override will detect shapes and handle dragging
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleButtonClick = (e: React.MouseEvent, action: () => void) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContentPointerDown = (e: React.PointerEvent) => {
|
||||||
|
// Only stop propagation for interactive elements to allow tldraw to handle dragging on white space
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
const isInteractive =
|
||||||
|
target.tagName === 'BUTTON' ||
|
||||||
|
target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.tagName === 'SELECT' ||
|
||||||
|
target.closest('button') ||
|
||||||
|
target.closest('input') ||
|
||||||
|
target.closest('textarea') ||
|
||||||
|
target.closest('select') ||
|
||||||
|
target.closest('[role="button"]') ||
|
||||||
|
target.closest('a') ||
|
||||||
|
target.closest('[data-interactive]') // Allow components to mark interactive areas
|
||||||
|
|
||||||
|
if (isInteractive) {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
// Don't stop propagation for non-interactive elements - let tldraw handle dragging
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
{/* Header Bar */}
|
||||||
|
<div
|
||||||
|
style={headerStyle}
|
||||||
|
onPointerDown={handleHeaderPointerDown}
|
||||||
|
onMouseEnter={() => setIsHoveringHeader(true)}
|
||||||
|
onMouseLeave={() => setIsHoveringHeader(false)}
|
||||||
|
onMouseDown={(_e) => {
|
||||||
|
// Ensure selection happens on mouse down for immediate visual feedback
|
||||||
|
if (editor && shapeId && !isSelected) {
|
||||||
|
editor.setSelectedShapes([shapeId])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-draggable="true"
|
||||||
|
>
|
||||||
|
<div style={titleStyle}>
|
||||||
|
{headerContent || title}
|
||||||
|
</div>
|
||||||
|
<div style={buttonContainerStyle}>
|
||||||
|
<button
|
||||||
|
style={minimizeButtonStyle}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (onMinimize) {
|
||||||
|
handleButtonClick(e, onMinimize)
|
||||||
|
} else {
|
||||||
|
// Default minimize behavior if no handler provided
|
||||||
|
console.warn('Minimize button clicked but no onMinimize handler provided')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
title="Minimize"
|
||||||
|
aria-label="Minimize"
|
||||||
|
disabled={!onMinimize}
|
||||||
|
>
|
||||||
|
_
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={closeButtonStyle}
|
||||||
|
onClick={(e) => handleButtonClick(e, onClose)}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
title="Close"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
{!isMinimized && (
|
||||||
|
<div
|
||||||
|
style={contentStyle}
|
||||||
|
onPointerDown={handleContentPointerDown}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useNotifications } from '../context/NotificationContext';
|
||||||
|
import { starBoard, unstarBoard, isBoardStarred } from '../lib/starredBoards';
|
||||||
|
|
||||||
|
interface StarBoardButtonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) => {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const { session } = useAuth();
|
||||||
|
const { addNotification } = useNotifications();
|
||||||
|
const [isStarred, setIsStarred] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showPopup, setShowPopup] = useState(false);
|
||||||
|
const [popupMessage, setPopupMessage] = useState('');
|
||||||
|
const [popupType, setPopupType] = useState<'success' | 'error' | 'info'>('success');
|
||||||
|
|
||||||
|
// Check if board is starred on mount and when session changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (session.authed && session.username && slug) {
|
||||||
|
const starred = isBoardStarred(session.username, slug);
|
||||||
|
setIsStarred(starred);
|
||||||
|
} else {
|
||||||
|
setIsStarred(false);
|
||||||
|
}
|
||||||
|
}, [session.authed, session.username, slug]);
|
||||||
|
|
||||||
|
const showPopupMessage = (message: string, type: 'success' | 'error' | 'info') => {
|
||||||
|
setPopupMessage(message);
|
||||||
|
setPopupType(type);
|
||||||
|
setShowPopup(true);
|
||||||
|
|
||||||
|
// Auto-hide after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowPopup(false);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStarToggle = async () => {
|
||||||
|
if (!session.authed || !session.username || !slug) {
|
||||||
|
addNotification('Please log in to star boards', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isStarred) {
|
||||||
|
// Unstar the board
|
||||||
|
const success = unstarBoard(session.username, slug);
|
||||||
|
if (success) {
|
||||||
|
setIsStarred(false);
|
||||||
|
showPopupMessage('Board removed from starred boards', 'success');
|
||||||
|
} else {
|
||||||
|
showPopupMessage('Failed to remove board from starred boards', 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Star the board
|
||||||
|
const success = starBoard(session.username, slug, slug);
|
||||||
|
if (success) {
|
||||||
|
setIsStarred(true);
|
||||||
|
showPopupMessage('Board added to starred boards', 'success');
|
||||||
|
} else {
|
||||||
|
showPopupMessage('Board is already starred', 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling star:', error);
|
||||||
|
showPopupMessage('Failed to update starred boards', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't show the button if user is not authenticated
|
||||||
|
if (!session.authed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleStarToggle}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`star-board-button ${className} ${isStarred ? 'starred' : ''}`}
|
||||||
|
title={isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="loading-spinner">⏳</span>
|
||||||
|
) : isStarred ? (
|
||||||
|
<span className="star-icon starred">⭐</span>
|
||||||
|
) : (
|
||||||
|
<span className="star-icon">☆</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Custom popup notification */}
|
||||||
|
{showPopup && (
|
||||||
|
<div
|
||||||
|
className={`star-popup star-popup-${popupType}`}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '40px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 100001,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{popupMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StarBoardButton;
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
// Component for linking Web Crypto account with Ethereum wallet
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useBlockchain } from '../../context/BlockchainContext';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useNotifications } from '../../context/NotificationContext';
|
||||||
|
|
||||||
|
export const BlockchainLink: React.FC = () => {
|
||||||
|
const { wallet, linkedAccount, isConnecting, connect, linkWebCryptoAccount } = useBlockchain();
|
||||||
|
const { session } = useAuth();
|
||||||
|
const { addNotification } = useNotifications();
|
||||||
|
const [isLinking, setIsLinking] = useState(false);
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
try {
|
||||||
|
await connect();
|
||||||
|
addNotification('Wallet connected successfully', 'success');
|
||||||
|
} catch (error: any) {
|
||||||
|
addNotification(error.message || 'Failed to connect wallet', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLink = async () => {
|
||||||
|
if (!session.username) {
|
||||||
|
addNotification('Please log in first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wallet) {
|
||||||
|
addNotification('Please connect your wallet first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLinking(true);
|
||||||
|
try {
|
||||||
|
const result = await linkWebCryptoAccount(session.username);
|
||||||
|
if (result.success) {
|
||||||
|
addNotification('Account linked successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
addNotification(result.error || 'Failed to link account', 'error');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
addNotification(error.message || 'Failed to link account', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsLinking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!session.authed) {
|
||||||
|
return (
|
||||||
|
<div className="blockchain-link">
|
||||||
|
<p>Please log in to link your Web Crypto account with a blockchain wallet.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="blockchain-link">
|
||||||
|
<h3>Link Blockchain Wallet</h3>
|
||||||
|
|
||||||
|
{!wallet ? (
|
||||||
|
<div>
|
||||||
|
<p>Connect your Ethereum wallet (MetaMask, etc.) to link it with your Web Crypto account.</p>
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={isConnecting}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{isConnecting ? 'Connecting...' : 'Connect Wallet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="wallet-info">
|
||||||
|
<p><strong>Connected Wallet:</strong> {wallet.address}</p>
|
||||||
|
<p><strong>Chain ID:</strong> {wallet.chainId}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkedAccount ? (
|
||||||
|
<div className="linked-account">
|
||||||
|
<p className="success">✓ Account linked successfully!</p>
|
||||||
|
<p><strong>Ethereum Address:</strong> {linkedAccount.ethereumAddress}</p>
|
||||||
|
{linkedAccount.proxyContractAddress && (
|
||||||
|
<p><strong>Proxy Contract:</strong> {linkedAccount.proxyContractAddress}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p>Link your Web Crypto account ({session.username}) with this wallet.</p>
|
||||||
|
<button
|
||||||
|
onClick={handleLink}
|
||||||
|
disabled={isLinking}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{isLinking ? 'Linking...' : 'Link Account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { CryptoAuthService } from '../../lib/auth/cryptoAuthService';
|
||||||
|
import * as crypto from '../../lib/auth/crypto';
|
||||||
|
|
||||||
|
const CryptoDebug: React.FC = () => {
|
||||||
|
const [testResults, setTestResults] = useState<string[]>([]);
|
||||||
|
const [testUsername, setTestUsername] = useState('testuser123');
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
|
||||||
|
const addResult = (message: string) => {
|
||||||
|
setTestResults(prev => [...prev, `${new Date().toLocaleTimeString()}: ${message}`]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCryptoTest = async () => {
|
||||||
|
setIsRunning(true);
|
||||||
|
setTestResults([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
addResult('Starting cryptographic authentication test...');
|
||||||
|
|
||||||
|
// Test 1: Key Generation
|
||||||
|
addResult('Testing key pair generation...');
|
||||||
|
const keyPair = await crypto.generateKeyPair();
|
||||||
|
if (keyPair) {
|
||||||
|
addResult('✓ Key pair generated successfully');
|
||||||
|
} else {
|
||||||
|
addResult('❌ Key pair generation failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Public Key Export
|
||||||
|
addResult('Testing public key export...');
|
||||||
|
const publicKeyBase64 = await crypto.exportPublicKey(keyPair.publicKey);
|
||||||
|
if (publicKeyBase64) {
|
||||||
|
addResult('✓ Public key exported successfully');
|
||||||
|
} else {
|
||||||
|
addResult('❌ Public key export failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Public Key Import
|
||||||
|
addResult('Testing public key import...');
|
||||||
|
const importedPublicKey = await crypto.importPublicKey(publicKeyBase64);
|
||||||
|
if (importedPublicKey) {
|
||||||
|
addResult('✓ Public key imported successfully');
|
||||||
|
} else {
|
||||||
|
addResult('❌ Public key import failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Data Signing
|
||||||
|
addResult('Testing data signing...');
|
||||||
|
const testData = 'Hello, WebCryptoAPI!';
|
||||||
|
const signature = await crypto.signData(keyPair.privateKey, testData);
|
||||||
|
if (signature) {
|
||||||
|
addResult('✓ Data signed successfully');
|
||||||
|
} else {
|
||||||
|
addResult('❌ Data signing failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Signature Verification
|
||||||
|
addResult('Testing signature verification...');
|
||||||
|
const isValid = await crypto.verifySignature(importedPublicKey, signature, testData);
|
||||||
|
if (isValid) {
|
||||||
|
addResult('✓ Signature verified successfully');
|
||||||
|
} else {
|
||||||
|
addResult('❌ Signature verification failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: User Registration
|
||||||
|
addResult(`Testing user registration for: ${testUsername}`);
|
||||||
|
const registerResult = await CryptoAuthService.register(testUsername);
|
||||||
|
if (registerResult.success) {
|
||||||
|
addResult('✓ User registration successful');
|
||||||
|
} else {
|
||||||
|
addResult(`❌ User registration failed: ${registerResult.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: User Login
|
||||||
|
addResult(`Testing user login for: ${testUsername}`);
|
||||||
|
const loginResult = await CryptoAuthService.login(testUsername);
|
||||||
|
if (loginResult.success) {
|
||||||
|
addResult('✓ User login successful');
|
||||||
|
} else {
|
||||||
|
addResult(`❌ User login failed: ${loginResult.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: Verify stored data integrity
|
||||||
|
addResult('Testing stored data integrity...');
|
||||||
|
const storedData = localStorage.getItem(`${testUsername}_authData`);
|
||||||
|
if (storedData) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(storedData);
|
||||||
|
addResult(` - Challenge length: ${parsed.challenge?.length || 0}`);
|
||||||
|
addResult(` - Signature length: ${parsed.signature?.length || 0}`);
|
||||||
|
addResult(` - Timestamp: ${parsed.timestamp || 'missing'}`);
|
||||||
|
} catch (e) {
|
||||||
|
addResult(` - Data parse error: ${e}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addResult(' - No stored auth data found');
|
||||||
|
}
|
||||||
|
|
||||||
|
addResult('🎉 All cryptographic tests passed!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
addResult(`❌ Test error: ${error}`);
|
||||||
|
} finally {
|
||||||
|
setIsRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearResults = () => {
|
||||||
|
setTestResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkStoredUsers = () => {
|
||||||
|
const users = crypto.getRegisteredUsers();
|
||||||
|
addResult(`Stored users: ${JSON.stringify(users)}`);
|
||||||
|
|
||||||
|
users.forEach(user => {
|
||||||
|
const publicKey = crypto.getPublicKey(user);
|
||||||
|
const authData = localStorage.getItem(`${user}_authData`);
|
||||||
|
addResult(`User: ${user}, Public Key: ${publicKey ? '✓' : '✗'}, Auth Data: ${authData ? '✓' : '✗'}`);
|
||||||
|
|
||||||
|
if (authData) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(authData);
|
||||||
|
addResult(` - Challenge: ${parsed.challenge ? '✓' : '✗'}`);
|
||||||
|
addResult(` - Signature: ${parsed.signature ? '✓' : '✗'}`);
|
||||||
|
addResult(` - Timestamp: ${parsed.timestamp || '✗'}`);
|
||||||
|
} catch (e) {
|
||||||
|
addResult(` - Auth data parse error: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test the login popup functionality
|
||||||
|
addResult('Testing login popup user detection...');
|
||||||
|
try {
|
||||||
|
const storedUsers = JSON.parse(localStorage.getItem('registeredUsers') || '[]');
|
||||||
|
addResult(`All registered users: ${JSON.stringify(storedUsers)}`);
|
||||||
|
|
||||||
|
// Filter for users with valid keys (same logic as CryptoLogin)
|
||||||
|
const validUsers = storedUsers.filter((user: string) => {
|
||||||
|
const publicKey = localStorage.getItem(`${user}_publicKey`);
|
||||||
|
if (!publicKey) return false;
|
||||||
|
|
||||||
|
const authData = localStorage.getItem(`${user}_authData`);
|
||||||
|
if (!authData) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(authData);
|
||||||
|
return parsed.challenge && parsed.signature && parsed.timestamp;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addResult(`Users with valid keys: ${JSON.stringify(validUsers)}`);
|
||||||
|
addResult(`Valid users count: ${validUsers.length}/${storedUsers.length}`);
|
||||||
|
|
||||||
|
if (validUsers.length > 0) {
|
||||||
|
addResult(`Login popup would suggest: ${validUsers[0]}`);
|
||||||
|
} else {
|
||||||
|
addResult('No valid users found - would default to registration mode');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
addResult(`Error reading stored users: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupInvalidUsers = () => {
|
||||||
|
try {
|
||||||
|
const storedUsers = JSON.parse(localStorage.getItem('registeredUsers') || '[]');
|
||||||
|
const validUsers = storedUsers.filter((user: string) => {
|
||||||
|
const publicKey = localStorage.getItem(`${user}_publicKey`);
|
||||||
|
const authData = localStorage.getItem(`${user}_authData`);
|
||||||
|
|
||||||
|
if (!publicKey || !authData) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(authData);
|
||||||
|
return parsed.challenge && parsed.signature && parsed.timestamp;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the registered users list to only include valid users
|
||||||
|
localStorage.setItem('registeredUsers', JSON.stringify(validUsers));
|
||||||
|
|
||||||
|
addResult(`Cleaned up invalid users. Removed ${storedUsers.length - validUsers.length} invalid entries.`);
|
||||||
|
addResult(`Remaining valid users: ${JSON.stringify(validUsers)}`);
|
||||||
|
} catch (e) {
|
||||||
|
addResult(`Error cleaning up users: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="crypto-debug-container">
|
||||||
|
<h2>Cryptographic Authentication Debug</h2>
|
||||||
|
|
||||||
|
<div className="debug-controls">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={testUsername}
|
||||||
|
onChange={(e) => setTestUsername(e.target.value)}
|
||||||
|
placeholder="Test username"
|
||||||
|
className="debug-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={runCryptoTest}
|
||||||
|
disabled={isRunning}
|
||||||
|
className="debug-button"
|
||||||
|
>
|
||||||
|
{isRunning ? 'Running Tests...' : 'Run Crypto Test'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={checkStoredUsers}
|
||||||
|
className="debug-button"
|
||||||
|
>
|
||||||
|
Check Stored Users
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={cleanupInvalidUsers}
|
||||||
|
className="debug-button"
|
||||||
|
>
|
||||||
|
Cleanup Invalid Users
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={clearResults}
|
||||||
|
disabled={isRunning}
|
||||||
|
className="debug-button"
|
||||||
|
>
|
||||||
|
Clear Results
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="debug-results">
|
||||||
|
<h3>Debug Results:</h3>
|
||||||
|
{testResults.length === 0 ? (
|
||||||
|
<p>No test results yet. Click "Run Crypto Test" to start.</p>
|
||||||
|
) : (
|
||||||
|
<div className="results-list">
|
||||||
|
{testResults.map((result, index) => (
|
||||||
|
<div key={index} className="result-item">
|
||||||
|
{result}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CryptoDebug;
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { CryptoAuthService } from '../../lib/auth/cryptoAuthService';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useNotifications } from '../../context/NotificationContext';
|
||||||
|
import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser';
|
||||||
|
|
||||||
|
interface CryptoLoginProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebCryptoAPI-based authentication component
|
||||||
|
*/
|
||||||
|
const CryptoLogin: React.FC<CryptoLoginProps> = ({ onSuccess, onCancel }) => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [existingUsers, setExistingUsers] = useState<string[]>([]);
|
||||||
|
const [suggestedUsername, setSuggestedUsername] = useState<string>('');
|
||||||
|
const [browserSupport, setBrowserSupport] = useState<{
|
||||||
|
supported: boolean;
|
||||||
|
secure: boolean;
|
||||||
|
webcrypto: boolean;
|
||||||
|
}>({ supported: false, secure: false, webcrypto: false });
|
||||||
|
|
||||||
|
const { setSession } = useAuth();
|
||||||
|
const { addNotification } = useNotifications();
|
||||||
|
|
||||||
|
// Check browser support and existing users on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkSupport = () => {
|
||||||
|
const supported = checkBrowserSupport();
|
||||||
|
const secure = isSecureContext();
|
||||||
|
const webcrypto = typeof window !== 'undefined' &&
|
||||||
|
typeof window.crypto !== 'undefined' &&
|
||||||
|
typeof window.crypto.subtle !== 'undefined';
|
||||||
|
|
||||||
|
setBrowserSupport({ supported, secure, webcrypto });
|
||||||
|
|
||||||
|
if (!supported) {
|
||||||
|
setError('Your browser does not support the required features for cryptographic authentication.');
|
||||||
|
addNotification('Browser not supported for cryptographic authentication', 'warning');
|
||||||
|
} else if (!secure) {
|
||||||
|
setError('Cryptographic authentication requires a secure context (HTTPS).');
|
||||||
|
addNotification('Secure context required for cryptographic authentication', 'warning');
|
||||||
|
} else if (!webcrypto) {
|
||||||
|
setError('WebCryptoAPI is not available in your browser.');
|
||||||
|
addNotification('WebCryptoAPI not available', 'warning');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkExistingUsers = () => {
|
||||||
|
try {
|
||||||
|
// Get registered users from localStorage
|
||||||
|
const users = JSON.parse(localStorage.getItem('registeredUsers') || '[]');
|
||||||
|
|
||||||
|
// Filter users to only include those with valid authentication keys
|
||||||
|
const validUsers = users.filter((user: string) => {
|
||||||
|
// Check if public key exists
|
||||||
|
const publicKey = localStorage.getItem(`${user}_publicKey`);
|
||||||
|
if (!publicKey) return false;
|
||||||
|
|
||||||
|
// Check if authentication data exists
|
||||||
|
const authData = localStorage.getItem(`${user}_authData`);
|
||||||
|
if (!authData) return false;
|
||||||
|
|
||||||
|
// Verify the auth data is valid JSON and has required fields
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(authData);
|
||||||
|
return parsed.challenge && parsed.signature && parsed.timestamp;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Invalid auth data for user ${user}:`, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setExistingUsers(validUsers);
|
||||||
|
|
||||||
|
// If there are valid users, suggest the first one for login
|
||||||
|
if (validUsers.length > 0) {
|
||||||
|
setSuggestedUsername(validUsers[0]);
|
||||||
|
setUsername(validUsers[0]); // Pre-fill the username field
|
||||||
|
setIsRegistering(false); // Default to login mode if users exist
|
||||||
|
} else {
|
||||||
|
setIsRegistering(true); // Default to registration mode if no users exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log for debugging
|
||||||
|
if (users.length !== validUsers.length) {
|
||||||
|
console.log(`Found ${users.length} registered users, but only ${validUsers.length} have valid keys`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking existing users:', error);
|
||||||
|
setExistingUsers([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkSupport();
|
||||||
|
checkExistingUsers();
|
||||||
|
}, [addNotification]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle form submission for both login and registration
|
||||||
|
*/
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!browserSupport.supported || !browserSupport.secure || !browserSupport.webcrypto) {
|
||||||
|
setError('Browser does not support cryptographic authentication');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRegistering) {
|
||||||
|
// Registration flow using CryptoAuthService
|
||||||
|
const result = await CryptoAuthService.register(username);
|
||||||
|
if (result.success && result.session) {
|
||||||
|
setSession(result.session);
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Registration failed');
|
||||||
|
addNotification('Registration failed. Please try again.', 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Login flow using CryptoAuthService
|
||||||
|
const result = await CryptoAuthService.login(username);
|
||||||
|
if (result.success && result.session) {
|
||||||
|
setSession(result.session);
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'User not found or authentication failed');
|
||||||
|
addNotification('Login failed. Please check your username.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Cryptographic authentication error:', err);
|
||||||
|
setError('An unexpected error occurred during authentication');
|
||||||
|
addNotification('Authentication error. Please try again later.', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!browserSupport.supported) {
|
||||||
|
return (
|
||||||
|
<div className="crypto-login-container">
|
||||||
|
<h2>Browser Not Supported</h2>
|
||||||
|
<p>Your browser does not support the required features for cryptographic authentication.</p>
|
||||||
|
<p>Please use a modern browser with WebCryptoAPI support.</p>
|
||||||
|
{onCancel && (
|
||||||
|
<button onClick={onCancel} className="cancel-button">
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!browserSupport.secure) {
|
||||||
|
return (
|
||||||
|
<div className="crypto-login-container">
|
||||||
|
<h2>Secure Context Required</h2>
|
||||||
|
<p>Cryptographic authentication requires a secure context (HTTPS).</p>
|
||||||
|
<p>Please access this application over HTTPS.</p>
|
||||||
|
{onCancel && (
|
||||||
|
<button onClick={onCancel} className="cancel-button">
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="crypto-login-container">
|
||||||
|
<h2>{isRegistering ? 'Create Cryptographic Account' : 'Cryptographic Sign In'}</h2>
|
||||||
|
|
||||||
|
{/* Show existing users if available */}
|
||||||
|
{existingUsers.length > 0 && !isRegistering && (
|
||||||
|
<div className="existing-users">
|
||||||
|
<h3>Available Accounts with Valid Keys</h3>
|
||||||
|
<div className="user-list">
|
||||||
|
{existingUsers.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user}
|
||||||
|
onClick={() => {
|
||||||
|
setUsername(user);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className={`user-option ${username === user ? 'selected' : ''}`}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<span className="user-icon">🔐</span>
|
||||||
|
<span className="user-name">{user}</span>
|
||||||
|
<span className="user-status">Cryptographic keys available</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="crypto-info">
|
||||||
|
<p>
|
||||||
|
{isRegistering
|
||||||
|
? 'Create a new account using WebCryptoAPI for secure authentication.'
|
||||||
|
: existingUsers.length > 0
|
||||||
|
? 'Select an account above or enter a different username to sign in.'
|
||||||
|
: 'Sign in using your cryptographic credentials.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<div className="crypto-features">
|
||||||
|
<span className="feature">✓ ECDSA P-256 Key Pairs</span>
|
||||||
|
<span className="feature">✓ Challenge-Response Authentication</span>
|
||||||
|
<span className="feature">✓ Secure Key Storage</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="username">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder={existingUsers.length > 0 ? "Enter username or select from above" : "Enter username"}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="username"
|
||||||
|
minLength={3}
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !username.trim()}
|
||||||
|
className="crypto-auth-button"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Processing...' : isRegistering ? 'Create Account' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="auth-toggle">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsRegistering(!isRegistering);
|
||||||
|
setError(null);
|
||||||
|
// Clear username when switching modes
|
||||||
|
if (!isRegistering) {
|
||||||
|
setUsername('');
|
||||||
|
} else if (existingUsers.length > 0) {
|
||||||
|
setUsername(existingUsers[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="toggle-button"
|
||||||
|
>
|
||||||
|
{isRegistering ? 'Already have an account? Sign in' : 'Need an account? Register'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onCancel && (
|
||||||
|
<button onClick={onCancel} className="cancel-button">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CryptoLogin;
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { CryptoAuthService } from '../../lib/auth/cryptoAuthService';
|
||||||
|
import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser';
|
||||||
|
import * as crypto from '../../lib/auth/crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test component to verify WebCryptoAPI authentication
|
||||||
|
*/
|
||||||
|
const CryptoTest: React.FC = () => {
|
||||||
|
const [testResults, setTestResults] = useState<string[]>([]);
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
|
||||||
|
const addResult = (message: string) => {
|
||||||
|
setTestResults(prev => [...prev, `${new Date().toLocaleTimeString()}: ${message}`]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTests = async () => {
|
||||||
|
setIsRunning(true);
|
||||||
|
setTestResults([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
addResult('Starting WebCryptoAPI authentication tests...');
|
||||||
|
|
||||||
|
// Test 1: Browser Support
|
||||||
|
addResult('Testing browser support...');
|
||||||
|
const browserSupported = checkBrowserSupport();
|
||||||
|
const secureContext = isSecureContext();
|
||||||
|
const webcryptoAvailable = typeof window !== 'undefined' &&
|
||||||
|
typeof window.crypto !== 'undefined' &&
|
||||||
|
typeof window.crypto.subtle !== 'undefined';
|
||||||
|
|
||||||
|
addResult(`Browser support: ${browserSupported ? '✓' : '✗'}`);
|
||||||
|
addResult(`Secure context: ${secureContext ? '✓' : '✗'}`);
|
||||||
|
addResult(`WebCryptoAPI available: ${webcryptoAvailable ? '✓' : '✗'}`);
|
||||||
|
|
||||||
|
if (!browserSupported || !secureContext || !webcryptoAvailable) {
|
||||||
|
addResult('❌ Browser does not meet requirements for cryptographic authentication');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Key Generation
|
||||||
|
addResult('Testing key pair generation...');
|
||||||
|
const keyPair = await crypto.generateKeyPair();
|
||||||
|
if (keyPair) {
|
||||||
|
addResult('✓ Key pair generated successfully');
|
||||||
|
} else {
|
||||||
|
addResult('❌ Key pair generation failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Public Key Export
|
||||||
|
addResult('Testing public key export...');
|
||||||
|
const publicKeyBase64 = await crypto.exportPublicKey(keyPair.publicKey);
|
||||||
|
if (publicKeyBase64) {
|
||||||
|
addResult('✓ Public key exported successfully');
|
||||||
|
} else {
|
||||||
|
addResult('❌ Public key export failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Public Key Import
|
||||||
|
addResult('Testing public key import...');
|
||||||
|
const importedPublicKey = await crypto.importPublicKey(publicKeyBase64);
|
||||||
|
if (importedPublicKey) {
|
||||||
|
addResult('✓ Public key imported successfully');
|
||||||
|
} else {
|
||||||
|
addResult('❌ Public key import failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Data Signing
|
||||||
|
addResult('Testing data signing...');
|
||||||
|
const testData = 'Hello, WebCryptoAPI!';
|
||||||
|
const signature = await crypto.signData(keyPair.privateKey, testData);
|
||||||
|
if (signature) {
|
||||||
|
addResult('✓ Data signed successfully');
|
||||||
|
} else {
|
||||||
|
addResult('❌ Data signing failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: Signature Verification
|
||||||
|
addResult('Testing signature verification...');
|
||||||
|
const isValid = await crypto.verifySignature(importedPublicKey, signature, testData);
|
||||||
|
if (isValid) {
|
||||||
|
addResult('✓ Signature verified successfully');
|
||||||
|
} else {
|
||||||
|
addResult('❌ Signature verification failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: User Registration
|
||||||
|
addResult('Testing user registration...');
|
||||||
|
const testUsername = `testuser_${Date.now()}`;
|
||||||
|
const registerResult = await CryptoAuthService.register(testUsername);
|
||||||
|
if (registerResult.success) {
|
||||||
|
addResult('✓ User registration successful');
|
||||||
|
} else {
|
||||||
|
addResult(`❌ User registration failed: ${registerResult.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: User Login
|
||||||
|
addResult('Testing user login...');
|
||||||
|
const loginResult = await CryptoAuthService.login(testUsername);
|
||||||
|
if (loginResult.success) {
|
||||||
|
addResult('✓ User login successful');
|
||||||
|
} else {
|
||||||
|
addResult(`❌ User login failed: ${loginResult.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: Credential Verification
|
||||||
|
addResult('Testing credential verification...');
|
||||||
|
const credentialsValid = await CryptoAuthService.verifyCredentials(testUsername);
|
||||||
|
if (credentialsValid) {
|
||||||
|
addResult('✓ Credential verification successful');
|
||||||
|
} else {
|
||||||
|
addResult('❌ Credential verification failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addResult('🎉 All WebCryptoAPI authentication tests passed!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
addResult(`❌ Test error: ${error}`);
|
||||||
|
} finally {
|
||||||
|
setIsRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearResults = () => {
|
||||||
|
setTestResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="crypto-test-container">
|
||||||
|
<h2>WebCryptoAPI Authentication Test</h2>
|
||||||
|
|
||||||
|
<div className="test-controls">
|
||||||
|
<button
|
||||||
|
onClick={runTests}
|
||||||
|
disabled={isRunning}
|
||||||
|
className="test-button"
|
||||||
|
>
|
||||||
|
{isRunning ? 'Running Tests...' : 'Run Tests'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={clearResults}
|
||||||
|
disabled={isRunning}
|
||||||
|
className="clear-button"
|
||||||
|
>
|
||||||
|
Clear Results
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="test-results">
|
||||||
|
<h3>Test Results:</h3>
|
||||||
|
{testResults.length === 0 ? (
|
||||||
|
<p>No test results yet. Click "Run Tests" to start.</p>
|
||||||
|
) : (
|
||||||
|
<div className="results-list">
|
||||||
|
{testResults.map((result, index) => (
|
||||||
|
<div key={index} className="result-item">
|
||||||
|
{result}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="test-info">
|
||||||
|
<h3>What's Being Tested:</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Browser WebCryptoAPI support</li>
|
||||||
|
<li>Secure context (HTTPS)</li>
|
||||||
|
<li>ECDSA P-256 key pair generation</li>
|
||||||
|
<li>Public key export/import</li>
|
||||||
|
<li>Data signing and verification</li>
|
||||||
|
<li>User registration with cryptographic keys</li>
|
||||||
|
<li>User login with challenge-response</li>
|
||||||
|
<li>Credential verification</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CryptoTest;
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { createAccountLinkingConsumer } from '../../lib/auth/linking'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import { useNotifications } from '../../context/NotificationContext'
|
||||||
|
|
||||||
|
const LinkDevice: React.FC = () => {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [displayPin, setDisplayPin] = useState('')
|
||||||
|
const [view, setView] = useState<'enter-username' | 'show-pin' | 'load-filesystem'>('enter-username')
|
||||||
|
const [accountLinkingConsumer, setAccountLinkingConsumer] = useState<any>(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { login } = useAuth()
|
||||||
|
const { addNotification } = useNotifications()
|
||||||
|
|
||||||
|
const initAccountLinkingConsumer = async () => {
|
||||||
|
try {
|
||||||
|
const consumer = await createAccountLinkingConsumer(username)
|
||||||
|
setAccountLinkingConsumer(consumer)
|
||||||
|
|
||||||
|
consumer.on('challenge', ({ pin }: { pin: number[] }) => {
|
||||||
|
setDisplayPin(pin.join(''))
|
||||||
|
setView('show-pin')
|
||||||
|
})
|
||||||
|
|
||||||
|
consumer.on('link', async ({ approved, username }: { approved: boolean, username: string }) => {
|
||||||
|
if (approved) {
|
||||||
|
setView('load-filesystem')
|
||||||
|
|
||||||
|
const success = await login(username)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
addNotification("You're now connected!", "success")
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
addNotification("Connection successful but login failed", "error")
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addNotification('The connection attempt was cancelled', "warning")
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing account linking consumer:', error)
|
||||||
|
addNotification('Failed to initialize device linking', "error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitUsername = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
initAccountLinkingConsumer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up consumer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (accountLinkingConsumer) {
|
||||||
|
accountLinkingConsumer.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [accountLinkingConsumer])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="link-device-container">
|
||||||
|
{view === 'enter-username' && (
|
||||||
|
<>
|
||||||
|
<h2>Link a New Device</h2>
|
||||||
|
<form onSubmit={handleSubmitUsername}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="username">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={!username}>Continue</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'show-pin' && (
|
||||||
|
<div className="pin-display">
|
||||||
|
<h2>Enter this PIN on your other device</h2>
|
||||||
|
<div className="pin-code">{displayPin}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'load-filesystem' && (
|
||||||
|
<div className="loading">
|
||||||
|
<h2>Loading your filesystem...</h2>
|
||||||
|
<p>Please wait while we connect to your account.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinkDevice
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface LoadingProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Loading: React.FC<LoadingProps> = ({ message = 'Loading...' }) => {
|
||||||
|
return (
|
||||||
|
<div className="loading-container">
|
||||||
|
<div className="loading-spinner">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
</div>
|
||||||
|
<p className="loading-message">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Loading;
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useNotifications } from '../../context/NotificationContext';
|
||||||
|
import CryptoLogin from './CryptoLogin';
|
||||||
|
|
||||||
|
interface LoginButtonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginButton: React.FC<LoginButtonProps> = ({ className = '' }) => {
|
||||||
|
const [showLogin, setShowLogin] = useState(false);
|
||||||
|
const { session } = useAuth();
|
||||||
|
const { addNotification } = useNotifications();
|
||||||
|
|
||||||
|
const handleLoginClick = () => {
|
||||||
|
setShowLogin(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoginSuccess = () => {
|
||||||
|
setShowLogin(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoginCancel = () => {
|
||||||
|
setShowLogin(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't show login button if user is already authenticated
|
||||||
|
if (session.authed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleLoginClick}
|
||||||
|
className={`login-button ${className}`}
|
||||||
|
title="Sign in to save your work and access additional features"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showLogin && (
|
||||||
|
<div className="login-overlay">
|
||||||
|
<div className="login-modal">
|
||||||
|
<CryptoLogin
|
||||||
|
onSuccess={handleLoginSuccess}
|
||||||
|
onCancel={handleLoginCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginButton;
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useBlockchain } from '../../context/BlockchainContext';
|
||||||
|
import { useNotifications } from '../../context/NotificationContext';
|
||||||
|
import { unlinkAccount } from '../../lib/auth/blockchainLinking';
|
||||||
|
|
||||||
|
interface ProfileProps {
|
||||||
|
onLogout?: () => void;
|
||||||
|
onOpenVaultBrowser?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Profile: React.FC<ProfileProps> = ({ onLogout, onOpenVaultBrowser }) => {
|
||||||
|
const { session, updateSession, clearSession } = useAuth();
|
||||||
|
const { wallet, linkedAccount, isConnecting, connect, linkWebCryptoAccount, disconnect } = useBlockchain();
|
||||||
|
const { addNotification } = useNotifications();
|
||||||
|
const [vaultPath, setVaultPath] = useState(session.obsidianVaultPath || '');
|
||||||
|
const [isEditingVault, setIsEditingVault] = useState(false);
|
||||||
|
const [isLinking, setIsLinking] = useState(false);
|
||||||
|
|
||||||
|
const handleVaultPathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setVaultPath(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveVaultPath = () => {
|
||||||
|
updateSession({ obsidianVaultPath: vaultPath });
|
||||||
|
setIsEditingVault(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelVaultEdit = () => {
|
||||||
|
setVaultPath(session.obsidianVaultPath || '');
|
||||||
|
setIsEditingVault(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnectVault = () => {
|
||||||
|
setVaultPath('');
|
||||||
|
updateSession({
|
||||||
|
obsidianVaultPath: undefined,
|
||||||
|
obsidianVaultName: undefined
|
||||||
|
});
|
||||||
|
setIsEditingVault(false);
|
||||||
|
console.log('🔧 Vault disconnected from profile');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeVault = () => {
|
||||||
|
if (onOpenVaultBrowser) {
|
||||||
|
onOpenVaultBrowser();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnectWallet = async () => {
|
||||||
|
try {
|
||||||
|
await connect();
|
||||||
|
addNotification('Wallet connected successfully', 'success');
|
||||||
|
} catch (error: any) {
|
||||||
|
addNotification(error.message || 'Failed to connect wallet', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkAccount = async () => {
|
||||||
|
if (!wallet) {
|
||||||
|
addNotification('Please connect your wallet first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLinking(true);
|
||||||
|
try {
|
||||||
|
const result = await linkWebCryptoAccount(session.username);
|
||||||
|
if (result.success) {
|
||||||
|
addNotification('Account linked successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
addNotification(result.error || 'Failed to link account', 'error');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
addNotification(error.message || 'Failed to link account', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsLinking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlinkAccount = () => {
|
||||||
|
if (unlinkAccount(session.username)) {
|
||||||
|
disconnect();
|
||||||
|
addNotification('Account unlinked successfully', 'success');
|
||||||
|
} else {
|
||||||
|
addNotification('Failed to unlink account', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
// Clear the session
|
||||||
|
clearSession();
|
||||||
|
|
||||||
|
// Update the auth context
|
||||||
|
updateSession({
|
||||||
|
username: '',
|
||||||
|
authed: false,
|
||||||
|
backupCreated: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call the onLogout callback if provided
|
||||||
|
if (onLogout) onLogout();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!session.authed || !session.username) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-container">
|
||||||
|
<div className="profile-header">
|
||||||
|
<h3>Welcome, {session.username}!</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-settings">
|
||||||
|
<h4>Obsidian Vault</h4>
|
||||||
|
|
||||||
|
{/* Current Vault Display */}
|
||||||
|
<div className="current-vault-section">
|
||||||
|
{session.obsidianVaultName ? (
|
||||||
|
<div className="vault-info">
|
||||||
|
<div className="vault-name">
|
||||||
|
<span className="vault-label">Current Vault:</span>
|
||||||
|
<span className="vault-name-text">{session.obsidianVaultName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="vault-path-info">
|
||||||
|
{session.obsidianVaultPath === 'folder-selected'
|
||||||
|
? 'Folder selected (path not available)'
|
||||||
|
: session.obsidianVaultPath}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="no-vault-info">
|
||||||
|
<span className="no-vault-text">No Obsidian vault configured</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Change Vault Button */}
|
||||||
|
<div className="vault-actions-section">
|
||||||
|
<button onClick={handleChangeVault} className="change-vault-button">
|
||||||
|
{session.obsidianVaultName ? 'Change Obsidian Vault' : 'Set Obsidian Vault'}
|
||||||
|
</button>
|
||||||
|
{session.obsidianVaultPath && (
|
||||||
|
<button onClick={handleDisconnectVault} className="disconnect-vault-button">
|
||||||
|
🔌 Disconnect Vault
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Settings (Collapsible) */}
|
||||||
|
<details className="advanced-vault-settings">
|
||||||
|
<summary>Advanced Settings</summary>
|
||||||
|
<div className="vault-settings">
|
||||||
|
{isEditingVault ? (
|
||||||
|
<div className="vault-edit-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={vaultPath}
|
||||||
|
onChange={handleVaultPathChange}
|
||||||
|
placeholder="Enter Obsidian vault path..."
|
||||||
|
className="vault-path-input"
|
||||||
|
/>
|
||||||
|
<div className="vault-edit-actions">
|
||||||
|
<button onClick={handleSaveVaultPath} className="save-button">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button onClick={handleCancelVaultEdit} className="cancel-button">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="vault-display">
|
||||||
|
<div className="vault-path-display">
|
||||||
|
{session.obsidianVaultPath ? (
|
||||||
|
<span className="vault-path-text" title={session.obsidianVaultPath}>
|
||||||
|
{session.obsidianVaultPath === 'folder-selected'
|
||||||
|
? 'Folder selected (path not available)'
|
||||||
|
: session.obsidianVaultPath}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="no-vault-text">No vault configured</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="vault-actions">
|
||||||
|
<button onClick={() => setIsEditingVault(true)} className="edit-button">
|
||||||
|
Edit Path
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Blockchain Wallet Section */}
|
||||||
|
<div className="profile-settings">
|
||||||
|
<h4>Blockchain Wallet</h4>
|
||||||
|
|
||||||
|
{/* Current Wallet Display */}
|
||||||
|
<div className="current-vault-section">
|
||||||
|
{wallet ? (
|
||||||
|
<div className="vault-info">
|
||||||
|
<div className="vault-name">
|
||||||
|
<span className="vault-label">Connected Wallet:</span>
|
||||||
|
<span className="vault-name-text">{wallet.address.slice(0, 6)}...{wallet.address.slice(-4)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="vault-path-info">
|
||||||
|
Chain ID: {wallet.chainId}
|
||||||
|
</div>
|
||||||
|
{linkedAccount && (
|
||||||
|
<div className="vault-path-info" style={{ marginTop: '8px', color: '#10b981' }}>
|
||||||
|
✓ Linked to Web Crypto account
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="no-vault-info">
|
||||||
|
<span className="no-vault-text">No wallet connected</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wallet Actions */}
|
||||||
|
<div className="vault-actions-section">
|
||||||
|
{!wallet ? (
|
||||||
|
<button
|
||||||
|
onClick={handleConnectWallet}
|
||||||
|
disabled={isConnecting}
|
||||||
|
className="change-vault-button"
|
||||||
|
>
|
||||||
|
{isConnecting ? 'Connecting...' : 'Connect Wallet'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!linkedAccount ? (
|
||||||
|
<button
|
||||||
|
onClick={handleLinkAccount}
|
||||||
|
disabled={isLinking}
|
||||||
|
className="change-vault-button"
|
||||||
|
>
|
||||||
|
{isLinking ? 'Linking...' : 'Link to Web Crypto Account'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleUnlinkAccount}
|
||||||
|
className="disconnect-vault-button"
|
||||||
|
>
|
||||||
|
🔌 Unlink Wallet
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={disconnect}
|
||||||
|
className="disconnect-vault-button"
|
||||||
|
style={{ marginLeft: '8px' }}
|
||||||
|
>
|
||||||
|
Disconnect Wallet
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Linked Account Details */}
|
||||||
|
{linkedAccount && (
|
||||||
|
<details className="advanced-vault-settings" style={{ marginTop: '16px' }}>
|
||||||
|
<summary>Wallet Details</summary>
|
||||||
|
<div className="vault-settings">
|
||||||
|
<div className="vault-display">
|
||||||
|
<div className="vault-path-display">
|
||||||
|
<p><strong>Ethereum Address:</strong></p>
|
||||||
|
<code style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: '8px',
|
||||||
|
background: '#f8f9fa',
|
||||||
|
borderRadius: '4px',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
{linkedAccount.ethereumAddress}
|
||||||
|
</code>
|
||||||
|
{linkedAccount.proxyContractAddress && (
|
||||||
|
<>
|
||||||
|
<p style={{ marginTop: '12px' }}><strong>Proxy Contract:</strong></p>
|
||||||
|
<code style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: '8px',
|
||||||
|
background: '#f8f9fa',
|
||||||
|
borderRadius: '4px',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
{linkedAccount.proxyContractAddress}
|
||||||
|
</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p style={{ marginTop: '12px', fontSize: '12px', color: '#6c757d' }}>
|
||||||
|
Linked on {new Date(linkedAccount.linkedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-actions">
|
||||||
|
<button onClick={handleLogout} className="logout-button">
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!session.backupCreated && (
|
||||||
|
<div className="backup-reminder">
|
||||||
|
<p>Remember to back up your encryption keys to prevent data loss!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useAuth } from '../../../src/context/AuthContext';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||||
|
const { session } = useAuth();
|
||||||
|
|
||||||
|
if (session.loading) {
|
||||||
|
// Show loading indicator while authentication is being checked
|
||||||
|
return (
|
||||||
|
<div className="auth-loading">
|
||||||
|
<p>Checking authentication...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For board routes, we'll allow access even if not authenticated
|
||||||
|
// The auth button in the toolbar will handle authentication
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { register } from '../../lib/auth/account'
|
||||||
|
|
||||||
|
const Register: React.FC = () => {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [checkingUsername, setCheckingUsername] = useState(false)
|
||||||
|
const [initializingFilesystem, setInitializingFilesystem] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (checkingUsername) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitializingFilesystem(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await register(username)
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setError('Registration failed. Username may be taken.')
|
||||||
|
setInitializingFilesystem(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('An error occurred during registration')
|
||||||
|
setInitializingFilesystem(false)
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="register-container">
|
||||||
|
<h2>Create an Account</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleRegister}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="username">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={initializingFilesystem}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={initializingFilesystem || !username}
|
||||||
|
>
|
||||||
|
{initializingFilesystem ? 'Creating Account...' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Register
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
// Component for building and authorizing blockchain transactions using Web Crypto API
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useBlockchain } from '../../context/BlockchainContext';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useNotifications } from '../../context/NotificationContext';
|
||||||
|
import { signTransactionAuthorization, createEIP712Domain, type TransactionAuthorization } from '../../lib/auth/cryptoBlockchain';
|
||||||
|
import { buildProxyExecuteTransaction, sendTransaction, type ProxyContractConfig } from '../../lib/blockchain/ethereum';
|
||||||
|
import { submitToRelayer, getRelayerConfig, type RelayerTransactionRequest } from '../../lib/blockchain/relayer';
|
||||||
|
import * as crypto from '../../lib/auth/crypto';
|
||||||
|
import { getUserPrivateKey } from '../../lib/auth/cryptoBlockchain';
|
||||||
|
|
||||||
|
export const TransactionBuilder: React.FC = () => {
|
||||||
|
const { wallet, linkedAccount } = useBlockchain();
|
||||||
|
const { session } = useAuth();
|
||||||
|
const { addNotification } = useNotifications();
|
||||||
|
|
||||||
|
const [to, setTo] = useState('');
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [data, setData] = useState('');
|
||||||
|
const [useGasless, setUseGasless] = useState(false);
|
||||||
|
const [isBuilding, setIsBuilding] = useState(false);
|
||||||
|
const [isAuthorizing, setIsAuthorizing] = useState(false);
|
||||||
|
|
||||||
|
const handleBuildTransaction = async () => {
|
||||||
|
if (!wallet || !linkedAccount) {
|
||||||
|
addNotification('Please connect and link your wallet first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.username) {
|
||||||
|
addNotification('Please log in first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!to || !to.match(/^0x[a-fA-F0-9]{40}$/)) {
|
||||||
|
addNotification('Invalid recipient address', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBuilding(true);
|
||||||
|
try {
|
||||||
|
// Get user's private key (in production, use secure key storage)
|
||||||
|
const privateKey = await getUserPrivateKey(session.username);
|
||||||
|
if (!privateKey) {
|
||||||
|
addNotification('Unable to access Web Crypto private key. Please re-authenticate.', 'error');
|
||||||
|
setIsBuilding(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create transaction authorization
|
||||||
|
const authorization: TransactionAuthorization = {
|
||||||
|
to: to as `0x${string}`,
|
||||||
|
value: value ? BigInt(value).toString(16) : '0',
|
||||||
|
data: data || '0x',
|
||||||
|
nonce: Date.now(), // In production, use a proper nonce from the contract
|
||||||
|
deadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create EIP-712 domain
|
||||||
|
const domain = createEIP712Domain(
|
||||||
|
wallet.chainId,
|
||||||
|
linkedAccount.proxyContractAddress || '0x0000000000000000000000000000000000000000'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sign with Web Crypto API
|
||||||
|
const signed = await signTransactionAuthorization(privateKey, authorization, domain);
|
||||||
|
if (!signed) {
|
||||||
|
addNotification('Failed to sign transaction', 'error');
|
||||||
|
setIsBuilding(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build transaction for proxy contract
|
||||||
|
// Note: You'll need the proxy contract ABI
|
||||||
|
const proxyConfig: ProxyContractConfig = {
|
||||||
|
address: linkedAccount.proxyContractAddress as `0x${string}` || '0x0000000000000000000000000000000000000000',
|
||||||
|
chainId: wallet.chainId,
|
||||||
|
abi: [
|
||||||
|
{
|
||||||
|
name: 'execute',
|
||||||
|
type: 'function',
|
||||||
|
inputs: [
|
||||||
|
{ name: 'to', type: 'address' },
|
||||||
|
{ name: 'value', type: 'uint256' },
|
||||||
|
{ name: 'data', type: 'bytes' },
|
||||||
|
{ name: 'nonce', type: 'uint256' },
|
||||||
|
{ name: 'deadline', type: 'uint256' },
|
||||||
|
{ name: 'signature', type: 'bytes' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if gasless transactions are available
|
||||||
|
const relayerConfig = getRelayerConfig(wallet.chainId);
|
||||||
|
const shouldUseGasless = useGasless && relayerConfig;
|
||||||
|
|
||||||
|
if (shouldUseGasless && relayerConfig) {
|
||||||
|
// Submit to relayer for gasless transaction
|
||||||
|
setIsAuthorizing(true);
|
||||||
|
const relayerRequest: RelayerTransactionRequest = {
|
||||||
|
authorization,
|
||||||
|
signature: signed.signature,
|
||||||
|
proxyContractAddress: linkedAccount.proxyContractAddress as `0x${string}` || '0x0000000000000000000000000000000000000000',
|
||||||
|
chainId: wallet.chainId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await submitToRelayer(relayerConfig, relayerRequest);
|
||||||
|
if (result.success && result.transactionHash) {
|
||||||
|
addNotification(`Gasless transaction submitted: ${result.transactionHash}`, 'success');
|
||||||
|
} else {
|
||||||
|
addNotification(result.error || 'Failed to submit gasless transaction', 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Submit through wallet (user pays gas)
|
||||||
|
const transaction = buildProxyExecuteTransaction(
|
||||||
|
proxyConfig,
|
||||||
|
authorization.to as `0x${string}`,
|
||||||
|
BigInt(authorization.value),
|
||||||
|
authorization.data as `0x${string}`,
|
||||||
|
BigInt(authorization.nonce),
|
||||||
|
BigInt(authorization.deadline),
|
||||||
|
signed.signature
|
||||||
|
);
|
||||||
|
|
||||||
|
setIsAuthorizing(true);
|
||||||
|
const hash = await sendTransaction(wallet.chainId, transaction);
|
||||||
|
addNotification(`Transaction submitted: ${hash}`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setTo('');
|
||||||
|
setValue('');
|
||||||
|
setData('');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error building transaction:', error);
|
||||||
|
addNotification(error.message || 'Failed to build transaction', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsBuilding(false);
|
||||||
|
setIsAuthorizing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!wallet || !linkedAccount) {
|
||||||
|
return (
|
||||||
|
<div className="transaction-builder">
|
||||||
|
<p>Please connect and link your wallet to build transactions.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="transaction-builder">
|
||||||
|
<h3>Build Transaction</h3>
|
||||||
|
<p>Create a transaction authorized by your Web Crypto account.</p>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
To Address:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={to}
|
||||||
|
onChange={(e) => setTo(e.target.value)}
|
||||||
|
placeholder="0x..."
|
||||||
|
className="form-control"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
Value (ETH):
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="form-control"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
Data (hex, optional):
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data}
|
||||||
|
onChange={(e) => setData(e.target.value)}
|
||||||
|
placeholder="0x..."
|
||||||
|
className="form-control"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useGasless}
|
||||||
|
onChange={(e) => setUseGasless(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Use gasless transaction (if relayer available)</span>
|
||||||
|
</label>
|
||||||
|
{useGasless && !getRelayerConfig(wallet.chainId) && (
|
||||||
|
<p style={{ fontSize: '12px', color: '#dc3545', marginTop: '4px' }}>
|
||||||
|
⚠️ Relayer not configured for this chain. Transaction will use wallet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleBuildTransaction}
|
||||||
|
disabled={isBuilding || isAuthorizing}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{isBuilding ? 'Building...' : isAuthorizing ? 'Authorizing...' : 'Build & Sign Transaction'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Component to display wallet connection status and linked account info
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useBlockchain } from '../../context/BlockchainContext';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
|
export const WalletStatus: React.FC = () => {
|
||||||
|
const { wallet, linkedAccount } = useBlockchain();
|
||||||
|
const { session } = useAuth();
|
||||||
|
|
||||||
|
if (!session.authed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wallet-status">
|
||||||
|
<h4>Blockchain Status</h4>
|
||||||
|
|
||||||
|
{wallet ? (
|
||||||
|
<div className="wallet-connected">
|
||||||
|
<p className="status-indicator connected">● Wallet Connected</p>
|
||||||
|
<div className="wallet-details">
|
||||||
|
<p><strong>Address:</strong> <code>{wallet.address}</code></p>
|
||||||
|
<p><strong>Chain ID:</strong> {wallet.chainId}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="wallet-disconnected">
|
||||||
|
<p className="status-indicator disconnected">○ Wallet Not Connected</p>
|
||||||
|
<p>Connect a wallet to enable blockchain transactions.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linkedAccount && (
|
||||||
|
<div className="linked-account-info">
|
||||||
|
<p className="status-indicator linked">✓ Account Linked</p>
|
||||||
|
<p><strong>Web Crypto:</strong> {session.username}</p>
|
||||||
|
<p><strong>Ethereum:</strong> {linkedAccount.ethereumAddress}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useAuth } from "@/context/AuthContext"
|
||||||
|
import { LocationStorageService, type LocationData } from "@/lib/location/locationStorage"
|
||||||
|
import type { GeolocationPosition } from "@/lib/location/types"
|
||||||
|
|
||||||
|
interface LocationCaptureProps {
|
||||||
|
onLocationCaptured?: (location: LocationData) => void
|
||||||
|
onError?: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LocationCapture: React.FC<LocationCaptureProps> = ({ onLocationCaptured, onError }) => {
|
||||||
|
const { session, fileSystem } = useAuth()
|
||||||
|
const [isCapturing, setIsCapturing] = useState(false)
|
||||||
|
const [permissionState, setPermissionState] = useState<"prompt" | "granted" | "denied">("prompt")
|
||||||
|
const [currentLocation, setCurrentLocation] = useState<GeolocationPosition | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Show loading state while auth is initializing
|
||||||
|
if (session.loading) {
|
||||||
|
return (
|
||||||
|
<div className="location-capture-loading flex items-center justify-center min-h-[200px]">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl mb-2 animate-spin">⏳</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Loading authentication...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permission status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if ("permissions" in navigator) {
|
||||||
|
navigator.permissions.query({ name: "geolocation" }).then((result) => {
|
||||||
|
setPermissionState(result.state as "prompt" | "granted" | "denied")
|
||||||
|
|
||||||
|
result.addEventListener("change", () => {
|
||||||
|
setPermissionState(result.state as "prompt" | "granted" | "denied")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const captureLocation = async () => {
|
||||||
|
// Don't proceed if still loading
|
||||||
|
if (session.loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.authed) {
|
||||||
|
const errorMsg = "You must be logged in to share your location. Please log in and try again."
|
||||||
|
setError(errorMsg)
|
||||||
|
onError?.(errorMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileSystem) {
|
||||||
|
const errorMsg = "File system not available. Please refresh the page and try again."
|
||||||
|
setError(errorMsg)
|
||||||
|
onError?.(errorMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCapturing(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request geolocation
|
||||||
|
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => resolve(pos as GeolocationPosition),
|
||||||
|
(err) => reject(err),
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 10000,
|
||||||
|
maximumAge: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
setCurrentLocation(position)
|
||||||
|
|
||||||
|
// Create location data
|
||||||
|
const locationData: LocationData = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: session.username,
|
||||||
|
latitude: position.coords.latitude,
|
||||||
|
longitude: position.coords.longitude,
|
||||||
|
accuracy: position.coords.accuracy,
|
||||||
|
timestamp: position.timestamp,
|
||||||
|
expiresAt: null, // Will be set when creating a share
|
||||||
|
precision: "exact",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to filesystem
|
||||||
|
const storageService = new LocationStorageService(fileSystem)
|
||||||
|
await storageService.initialize()
|
||||||
|
await storageService.saveLocation(locationData)
|
||||||
|
|
||||||
|
onLocationCaptured?.(locationData)
|
||||||
|
} catch (err: any) {
|
||||||
|
let errorMsg = "Failed to capture location"
|
||||||
|
|
||||||
|
if (err.code === 1) {
|
||||||
|
errorMsg = "Location permission denied. Please enable location access in your browser settings."
|
||||||
|
setPermissionState("denied")
|
||||||
|
} else if (err.code === 2) {
|
||||||
|
errorMsg = "Location unavailable. Please check your device settings."
|
||||||
|
} else if (err.code === 3) {
|
||||||
|
errorMsg = "Location request timed out. Please try again."
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(errorMsg)
|
||||||
|
onError?.(errorMsg)
|
||||||
|
} finally {
|
||||||
|
setIsCapturing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="location-capture">
|
||||||
|
<div className="capture-header">
|
||||||
|
<h2 className="text-2xl font-semibold text-balance">Share Your Location</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">Securely share your current location with others</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permission status */}
|
||||||
|
{permissionState === "denied" && (
|
||||||
|
<div className="permission-denied bg-destructive/10 border border-destructive/20 rounded-lg p-4 mt-4">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Location access is blocked. Please enable it in your browser settings to continue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current location display */}
|
||||||
|
{currentLocation && (
|
||||||
|
<div className="current-location bg-muted/50 rounded-lg p-4 mt-4">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Current Location</h3>
|
||||||
|
<div className="location-details text-xs space-y-1">
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Latitude:</span> {currentLocation.coords.latitude.toFixed(6)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Longitude:</span> {currentLocation.coords.longitude.toFixed(6)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Accuracy:</span> ±{Math.round(currentLocation.coords.accuracy)}m
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">Captured {new Date(currentLocation.timestamp).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div className="error-message bg-destructive/10 border border-destructive/20 rounded-lg p-4 mt-4">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Capture button */}
|
||||||
|
<button
|
||||||
|
onClick={captureLocation}
|
||||||
|
disabled={isCapturing || permissionState === "denied" || !session.authed}
|
||||||
|
className="capture-button w-full mt-6 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-6 py-3 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{isCapturing ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<span className="spinner" />
|
||||||
|
Capturing Location...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Capture My Location"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!session.authed && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center mt-3">Please log in to share your location</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useAuth } from "@/context/AuthContext"
|
||||||
|
import { LocationStorageService, type LocationData, type LocationShare } from "@/lib/location/locationStorage"
|
||||||
|
import { LocationMap } from "./LocationMap"
|
||||||
|
|
||||||
|
interface ShareWithLocation {
|
||||||
|
share: LocationShare
|
||||||
|
location: LocationData
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LocationDashboard: React.FC = () => {
|
||||||
|
const { session, fileSystem } = useAuth()
|
||||||
|
const [shares, setShares] = useState<ShareWithLocation[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedShare, setSelectedShare] = useState<ShareWithLocation | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadShares = async () => {
|
||||||
|
if (!fileSystem) {
|
||||||
|
setError("File system not available")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storageService = new LocationStorageService(fileSystem)
|
||||||
|
await storageService.initialize()
|
||||||
|
|
||||||
|
// Get all shares
|
||||||
|
const allShares = await storageService.getAllShares()
|
||||||
|
|
||||||
|
// Get locations for each share
|
||||||
|
const sharesWithLocations: ShareWithLocation[] = []
|
||||||
|
|
||||||
|
for (const share of allShares) {
|
||||||
|
const location = await storageService.getLocation(share.locationId)
|
||||||
|
if (location) {
|
||||||
|
sharesWithLocations.push({ share, location })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by creation date (newest first)
|
||||||
|
sharesWithLocations.sort((a, b) => b.share.createdAt - a.share.createdAt)
|
||||||
|
|
||||||
|
setShares(sharesWithLocations)
|
||||||
|
setLoading(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading shares:", err)
|
||||||
|
setError("Failed to load location shares")
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session.authed && fileSystem) {
|
||||||
|
loadShares()
|
||||||
|
}
|
||||||
|
}, [session.authed, fileSystem])
|
||||||
|
|
||||||
|
const handleCopyLink = async (shareToken: string) => {
|
||||||
|
const baseUrl = window.location.origin
|
||||||
|
const link = `${baseUrl}/location/${shareToken}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link)
|
||||||
|
alert("Link copied to clipboard!")
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy link:", err)
|
||||||
|
alert("Failed to copy link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpired = (share: LocationShare): boolean => {
|
||||||
|
return share.expiresAt ? share.expiresAt < Date.now() : false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMaxViewsReached = (share: LocationShare): boolean => {
|
||||||
|
return share.maxViews ? share.viewCount >= share.maxViews : false
|
||||||
|
}
|
||||||
|
|
||||||
|
const getShareStatus = (share: LocationShare): { label: string; color: string } => {
|
||||||
|
if (isExpired(share)) {
|
||||||
|
return { label: "Expired", color: "text-destructive" }
|
||||||
|
}
|
||||||
|
if (isMaxViewsReached(share)) {
|
||||||
|
return { label: "Max Views Reached", color: "text-destructive" }
|
||||||
|
}
|
||||||
|
return { label: "Active", color: "text-green-600" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.authed) {
|
||||||
|
return (
|
||||||
|
<div className="location-dashboard-auth flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-center max-w-md">
|
||||||
|
<div className="text-4xl mb-4">🔒</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Authentication Required</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Please log in to view your location shares</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="location-dashboard flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="spinner" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading your shares...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="location-dashboard flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-center max-w-md">
|
||||||
|
<div className="text-4xl mb-4">⚠️</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Error Loading Dashboard</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={loadShares}
|
||||||
|
className="mt-4 px-6 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="location-dashboard max-w-6xl mx-auto p-6">
|
||||||
|
<div className="dashboard-header mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-balance">Location Shares</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">Manage your shared locations and privacy settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shares.length === 0 ? (
|
||||||
|
<div className="empty-state flex flex-col items-center justify-center min-h-[400px] text-center">
|
||||||
|
<div className="text-6xl mb-4">📍</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">No Location Shares Yet</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||||
|
You haven't shared any locations yet. Create your first share to get started.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/share-location"
|
||||||
|
className="px-6 py-3 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Share Your Location
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dashboard-content">
|
||||||
|
{/* Stats Overview */}
|
||||||
|
<div className="stats-grid grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||||
|
<div className="stat-card bg-muted/50 rounded-lg p-4 border border-border">
|
||||||
|
<div className="stat-label text-sm text-muted-foreground mb-1">Total Shares</div>
|
||||||
|
<div className="stat-value text-3xl font-bold">{shares.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card bg-muted/50 rounded-lg p-4 border border-border">
|
||||||
|
<div className="stat-label text-sm text-muted-foreground mb-1">Active Shares</div>
|
||||||
|
<div className="stat-value text-3xl font-bold text-green-600">
|
||||||
|
{shares.filter((s) => !isExpired(s.share) && !isMaxViewsReached(s.share)).length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card bg-muted/50 rounded-lg p-4 border border-border">
|
||||||
|
<div className="stat-label text-sm text-muted-foreground mb-1">Total Views</div>
|
||||||
|
<div className="stat-value text-3xl font-bold">
|
||||||
|
{shares.reduce((sum, s) => sum + s.share.viewCount, 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shares List */}
|
||||||
|
<div className="shares-list space-y-4">
|
||||||
|
{shares.map(({ share, location }) => {
|
||||||
|
const status = getShareStatus(share)
|
||||||
|
const isSelected = selectedShare?.share.id === share.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={share.id}
|
||||||
|
className={`share-card bg-background rounded-lg border-2 transition-colors ${
|
||||||
|
isSelected ? "border-primary" : "border-border hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="share-card-header p-4 flex items-start justify-between gap-4">
|
||||||
|
<div className="share-info flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="font-semibold">Location Share</h3>
|
||||||
|
<span className={`text-xs font-medium ${status.color}`}>{status.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="share-meta text-xs text-muted-foreground space-y-1">
|
||||||
|
<p>Created: {new Date(share.createdAt).toLocaleString()}</p>
|
||||||
|
{share.expiresAt && <p>Expires: {new Date(share.expiresAt).toLocaleString()}</p>}
|
||||||
|
<p>
|
||||||
|
Views: {share.viewCount}
|
||||||
|
{share.maxViews && ` / ${share.maxViews}`}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Precision: <span className="capitalize">{share.precision}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="share-actions flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyLink(share.shareToken)}
|
||||||
|
disabled={isExpired(share) || isMaxViewsReached(share)}
|
||||||
|
className="px-4 py-2 rounded-lg border border-border hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Copy Link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedShare(isSelected ? null : { share, location })}
|
||||||
|
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{isSelected ? "Hide" : "View"} Map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSelected && (
|
||||||
|
<div className="share-card-body p-4 pt-0 border-t border-border mt-4">
|
||||||
|
<LocationMap location={location} precision={share.precision} showAccuracy={true} height="300px" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import type { LocationData } from "@/lib/location/locationStorage"
|
||||||
|
import { obfuscateLocation } from "@/lib/location/locationStorage"
|
||||||
|
import type { PrecisionLevel } from "@/lib/location/types"
|
||||||
|
|
||||||
|
// Leaflet types
|
||||||
|
interface LeafletMap {
|
||||||
|
setView: (coords: [number, number], zoom: number) => void
|
||||||
|
remove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeafletMarker {
|
||||||
|
addTo: (map: LeafletMap) => LeafletMarker
|
||||||
|
bindPopup: (content: string) => LeafletMarker
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeafletCircle {
|
||||||
|
addTo: (map: LeafletMap) => LeafletCircle
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeafletTileLayer {
|
||||||
|
addTo: (map: LeafletMap) => LeafletTileLayer
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Leaflet {
|
||||||
|
map: (element: HTMLElement, options?: any) => LeafletMap
|
||||||
|
marker: (coords: [number, number], options?: any) => LeafletMarker
|
||||||
|
circle: (coords: [number, number], options?: any) => LeafletCircle
|
||||||
|
tileLayer: (url: string, options?: any) => LeafletTileLayer
|
||||||
|
icon: (options: any) => any
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
L?: Leaflet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationMapProps {
|
||||||
|
location: LocationData
|
||||||
|
precision?: PrecisionLevel
|
||||||
|
showAccuracy?: boolean
|
||||||
|
height?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LocationMap: React.FC<LocationMapProps> = ({
|
||||||
|
location,
|
||||||
|
precision = "exact",
|
||||||
|
showAccuracy = true,
|
||||||
|
height = "400px",
|
||||||
|
}) => {
|
||||||
|
const mapContainer = useRef<HTMLDivElement>(null)
|
||||||
|
const mapInstance = useRef<LeafletMap | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load Leaflet CSS and JS
|
||||||
|
const loadLeaflet = async () => {
|
||||||
|
try {
|
||||||
|
// Load CSS
|
||||||
|
if (!document.querySelector('link[href*="leaflet.css"]')) {
|
||||||
|
const link = document.createElement("link")
|
||||||
|
link.rel = "stylesheet"
|
||||||
|
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
link.integrity = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
|
link.crossOrigin = ""
|
||||||
|
document.head.appendChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load JS
|
||||||
|
if (!window.L) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const script = document.createElement("script")
|
||||||
|
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||||
|
script.integrity = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||||
|
script.crossOrigin = ""
|
||||||
|
script.onload = () => resolve()
|
||||||
|
script.onerror = () => reject(new Error("Failed to load Leaflet"))
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false)
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to load map library")
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLeaflet()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapContainer.current || !window.L || isLoading) return
|
||||||
|
|
||||||
|
// Clean up existing map
|
||||||
|
if (mapInstance.current) {
|
||||||
|
mapInstance.current.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
const L = window.L!
|
||||||
|
|
||||||
|
// Get obfuscated location based on precision
|
||||||
|
const { lat, lng, radius } = obfuscateLocation(location.latitude, location.longitude, precision)
|
||||||
|
|
||||||
|
// Create map
|
||||||
|
const map = L.map(mapContainer.current, {
|
||||||
|
center: [lat, lng],
|
||||||
|
zoom: precision === "exact" ? 15 : precision === "street" ? 14 : precision === "neighborhood" ? 12 : 10,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add OpenStreetMap tiles
|
||||||
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
// Add marker
|
||||||
|
const marker = L.marker([lat, lng], {
|
||||||
|
icon: L.icon({
|
||||||
|
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||||
|
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||||
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
popupAnchor: [1, -34],
|
||||||
|
shadowSize: [41, 41],
|
||||||
|
}),
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
// Add popup with location info
|
||||||
|
const popupContent = `
|
||||||
|
<div style="font-family: system-ui, sans-serif;">
|
||||||
|
<strong>Shared Location</strong><br/>
|
||||||
|
<small style="color: #666;">
|
||||||
|
Precision: ${precision}<br/>
|
||||||
|
${new Date(location.timestamp).toLocaleString()}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
marker.bindPopup(popupContent)
|
||||||
|
|
||||||
|
// Add accuracy circle if showing accuracy
|
||||||
|
if (showAccuracy && radius > 0) {
|
||||||
|
L.circle([lat, lng], {
|
||||||
|
radius: radius,
|
||||||
|
color: "#3b82f6",
|
||||||
|
fillColor: "#3b82f6",
|
||||||
|
fillOpacity: 0.1,
|
||||||
|
weight: 2,
|
||||||
|
}).addTo(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
mapInstance.current = map
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
if (mapInstance.current) {
|
||||||
|
mapInstance.current.remove()
|
||||||
|
mapInstance.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [location, precision, showAccuracy, isLoading])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="map-error flex items-center justify-center bg-muted/50 rounded-lg border border-border"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="map-loading flex items-center justify-center bg-muted/50 rounded-lg border border-border"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="spinner" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading map...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="location-map-wrapper">
|
||||||
|
<div
|
||||||
|
ref={mapContainer}
|
||||||
|
className="location-map rounded-lg border border-border overflow-hidden"
|
||||||
|
style={{ height, width: "100%" }}
|
||||||
|
/>
|
||||||
|
<div className="map-info mt-3 text-xs text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Showing {precision} location • Last updated {new Date(location.timestamp).toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import {
|
||||||
|
TLUiDialogProps,
|
||||||
|
TldrawUiDialogBody,
|
||||||
|
TldrawUiDialogCloseButton,
|
||||||
|
TldrawUiDialogHeader,
|
||||||
|
TldrawUiDialogTitle,
|
||||||
|
} from "tldraw"
|
||||||
|
import React from "react"
|
||||||
|
import { ShareLocation } from "./ShareLocation"
|
||||||
|
|
||||||
|
export function LocationShareDialog({ onClose: _onClose }: TLUiDialogProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TldrawUiDialogHeader>
|
||||||
|
<TldrawUiDialogTitle>Share Location</TldrawUiDialogTitle>
|
||||||
|
<TldrawUiDialogCloseButton />
|
||||||
|
</TldrawUiDialogHeader>
|
||||||
|
<TldrawUiDialogBody style={{ maxWidth: 800, maxHeight: "90vh", overflow: "auto" }}>
|
||||||
|
<ShareLocation />
|
||||||
|
</TldrawUiDialogBody>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { LocationMap } from "./LocationMap"
|
||||||
|
import type { LocationData, LocationShare } from "@/lib/location/locationStorage"
|
||||||
|
import { LocationStorageService } from "@/lib/location/locationStorage"
|
||||||
|
import { useAuth } from "@/context/AuthContext"
|
||||||
|
|
||||||
|
interface LocationViewerProps {
|
||||||
|
shareToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LocationViewer: React.FC<LocationViewerProps> = ({ shareToken }) => {
|
||||||
|
const { fileSystem } = useAuth()
|
||||||
|
const [location, setLocation] = useState<LocationData | null>(null)
|
||||||
|
const [share, setShare] = useState<LocationShare | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSharedLocation = async () => {
|
||||||
|
if (!fileSystem) {
|
||||||
|
setError("File system not available")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storageService = new LocationStorageService(fileSystem)
|
||||||
|
await storageService.initialize()
|
||||||
|
|
||||||
|
// Get share by token
|
||||||
|
const shareData = await storageService.getShareByToken(shareToken)
|
||||||
|
if (!shareData) {
|
||||||
|
setError("Share not found or expired")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if share is expired
|
||||||
|
if (shareData.expiresAt && shareData.expiresAt < Date.now()) {
|
||||||
|
setError("This share has expired")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if max views reached
|
||||||
|
if (shareData.maxViews && shareData.viewCount >= shareData.maxViews) {
|
||||||
|
setError("This share has reached its maximum view limit")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get location data
|
||||||
|
const locationData = await storageService.getLocation(shareData.locationId)
|
||||||
|
if (!locationData) {
|
||||||
|
setError("Location data not found")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setShare(shareData)
|
||||||
|
setLocation(locationData)
|
||||||
|
|
||||||
|
// Increment view count
|
||||||
|
await storageService.incrementShareViews(shareData.id)
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading shared location:", err)
|
||||||
|
setError("Failed to load shared location")
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSharedLocation()
|
||||||
|
}, [shareToken, fileSystem])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="location-viewer flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="spinner" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading shared location...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="location-viewer flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-center max-w-md">
|
||||||
|
<div className="text-4xl mb-4">📍</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Unable to Load Location</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!location || !share) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="location-viewer max-w-4xl mx-auto p-6">
|
||||||
|
<div className="viewer-header mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-balance">Shared Location</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">Someone has shared their location with you</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="viewer-content space-y-6">
|
||||||
|
{/* Map Display */}
|
||||||
|
<LocationMap location={location} precision={share.precision} showAccuracy={true} height="500px" />
|
||||||
|
|
||||||
|
{/* Share Info */}
|
||||||
|
<div className="share-info bg-muted/50 rounded-lg p-4 space-y-2">
|
||||||
|
<div className="info-row flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Precision Level:</span>
|
||||||
|
<span className="font-medium capitalize">{share.precision}</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-row flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Views:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{share.viewCount} {share.maxViews ? `/ ${share.maxViews}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{share.expiresAt && (
|
||||||
|
<div className="info-row flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Expires:</span>
|
||||||
|
<span className="font-medium">{new Date(share.expiresAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="info-row flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Shared:</span>
|
||||||
|
<span className="font-medium">{new Date(share.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Privacy Notice */}
|
||||||
|
<div className="privacy-notice bg-primary/5 border border-primary/20 rounded-lg p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This location is shared securely and will expire based on the sender's privacy settings. The location data
|
||||||
|
is stored in a decentralized filesystem and is only accessible via this unique link.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useState } from "react"
|
||||||
|
import { LocationCapture } from "./LocationCapture"
|
||||||
|
import { ShareSettingsComponent } from "./ShareSettings"
|
||||||
|
import { LocationMap } from "./LocationMap"
|
||||||
|
import type { LocationData, LocationShare } from "@/lib/location/locationStorage"
|
||||||
|
import { LocationStorageService, generateShareToken } from "@/lib/location/locationStorage"
|
||||||
|
import type { ShareSettings } from "@/lib/location/types"
|
||||||
|
import { useAuth } from "@/context/AuthContext"
|
||||||
|
|
||||||
|
export const ShareLocation: React.FC = () => {
|
||||||
|
const { session, fileSystem } = useAuth()
|
||||||
|
const [step, setStep] = useState<"capture" | "settings" | "share">("capture")
|
||||||
|
const [capturedLocation, setCapturedLocation] = useState<LocationData | null>(null)
|
||||||
|
const [shareSettings, setShareSettings] = useState<ShareSettings>({
|
||||||
|
duration: 24 * 3600000, // 24 hours
|
||||||
|
maxViews: null,
|
||||||
|
precision: "street",
|
||||||
|
})
|
||||||
|
const [shareLink, setShareLink] = useState<string | null>(null)
|
||||||
|
const [isCreatingShare, setIsCreatingShare] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Show loading state while auth is initializing
|
||||||
|
if (session.loading) {
|
||||||
|
return (
|
||||||
|
<div className="share-location-loading flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-center max-w-md">
|
||||||
|
<div className="text-4xl mb-4 animate-spin">⏳</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Loading...</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Initializing authentication</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLocationCaptured = (location: LocationData) => {
|
||||||
|
setCapturedLocation(location)
|
||||||
|
setStep("settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateShare = async () => {
|
||||||
|
if (!capturedLocation || !fileSystem) {
|
||||||
|
setError("Location or filesystem not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreatingShare(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storageService = new LocationStorageService(fileSystem)
|
||||||
|
await storageService.initialize()
|
||||||
|
|
||||||
|
// Generate share token
|
||||||
|
const shareToken = generateShareToken()
|
||||||
|
|
||||||
|
// Calculate expiration
|
||||||
|
const expiresAt = shareSettings.duration ? Date.now() + shareSettings.duration : null
|
||||||
|
|
||||||
|
// Update location with expiration
|
||||||
|
const updatedLocation: LocationData = {
|
||||||
|
...capturedLocation,
|
||||||
|
expiresAt,
|
||||||
|
precision: shareSettings.precision,
|
||||||
|
}
|
||||||
|
|
||||||
|
await storageService.saveLocation(updatedLocation)
|
||||||
|
|
||||||
|
// Create share
|
||||||
|
const share: LocationShare = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
locationId: capturedLocation.id,
|
||||||
|
shareToken,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt,
|
||||||
|
maxViews: shareSettings.maxViews,
|
||||||
|
viewCount: 0,
|
||||||
|
precision: shareSettings.precision,
|
||||||
|
}
|
||||||
|
|
||||||
|
await storageService.createShare(share)
|
||||||
|
|
||||||
|
// Generate share link
|
||||||
|
const baseUrl = window.location.origin
|
||||||
|
const link = `${baseUrl}/location/${shareToken}`
|
||||||
|
|
||||||
|
setShareLink(link)
|
||||||
|
setStep("share")
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error creating share:", err)
|
||||||
|
setError("Failed to create share link")
|
||||||
|
} finally {
|
||||||
|
setIsCreatingShare(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyLink = async () => {
|
||||||
|
if (!shareLink) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareLink)
|
||||||
|
// Could add a toast notification here
|
||||||
|
alert("Link copied to clipboard!")
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy link:", err)
|
||||||
|
alert("Failed to copy link. Please copy manually.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setStep("capture")
|
||||||
|
setCapturedLocation(null)
|
||||||
|
setShareLink(null)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.authed) {
|
||||||
|
return (
|
||||||
|
<div className="share-location-auth flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-center max-w-md">
|
||||||
|
<div className="text-4xl mb-4">🔒</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Authentication Required</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Please log in to share your location securely</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="share-location max-w-4xl mx-auto p-6">
|
||||||
|
{/* Progress Steps */}
|
||||||
|
<div className="progress-steps flex items-center justify-center gap-4 mb-8">
|
||||||
|
{["capture", "settings", "share"].map((s, index) => (
|
||||||
|
<React.Fragment key={s}>
|
||||||
|
<div className="step-item flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`step-number w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
|
||||||
|
step === s
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: index < ["capture", "settings", "share"].indexOf(step)
|
||||||
|
? "bg-primary/20 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`step-label text-sm font-medium capitalize ${
|
||||||
|
step === s ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{index < 2 && (
|
||||||
|
<div
|
||||||
|
className={`step-connector h-0.5 w-12 ${
|
||||||
|
index < ["capture", "settings", "share"].indexOf(step) ? "bg-primary" : "bg-muted"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="error-message bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
<div className="step-content">
|
||||||
|
{step === "capture" && <LocationCapture onLocationCaptured={handleLocationCaptured} onError={setError} />}
|
||||||
|
|
||||||
|
{step === "settings" && capturedLocation && (
|
||||||
|
<div className="settings-step space-y-6">
|
||||||
|
<div className="location-preview">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Preview Your Location</h3>
|
||||||
|
<LocationMap
|
||||||
|
location={capturedLocation}
|
||||||
|
precision={shareSettings.precision}
|
||||||
|
showAccuracy={true}
|
||||||
|
height="300px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ShareSettingsComponent onSettingsChange={setShareSettings} initialSettings={shareSettings} />
|
||||||
|
|
||||||
|
<div className="settings-actions flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep("capture")}
|
||||||
|
className="flex-1 px-6 py-3 rounded-lg border border-border hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateShare}
|
||||||
|
disabled={isCreatingShare}
|
||||||
|
className="flex-1 px-6 py-3 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{isCreatingShare ? "Creating Share..." : "Create Share Link"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "share" && shareLink && capturedLocation && (
|
||||||
|
<div className="share-step space-y-6">
|
||||||
|
<div className="share-success text-center mb-6">
|
||||||
|
<div className="text-5xl mb-4">✓</div>
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Share Link Created!</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Your location is ready to share securely</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="share-link-box bg-muted/50 rounded-lg p-4 border border-border">
|
||||||
|
<label className="block text-sm font-medium mb-2">Share Link</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={shareLink}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-3 py-2 rounded-lg border border-border bg-background text-sm"
|
||||||
|
onClick={(e) => e.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Copy Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="share-preview">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Location Preview</h3>
|
||||||
|
<LocationMap
|
||||||
|
location={capturedLocation}
|
||||||
|
precision={shareSettings.precision}
|
||||||
|
showAccuracy={true}
|
||||||
|
height="300px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="share-details bg-muted/50 rounded-lg p-4 space-y-2">
|
||||||
|
<h4 className="font-medium mb-3">Share Settings</h4>
|
||||||
|
<div className="detail-row flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Precision:</span>
|
||||||
|
<span className="font-medium capitalize">{shareSettings.precision}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-row flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Duration:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{shareSettings.duration ? `${shareSettings.duration / 3600000} hours` : "No expiration"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-row flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Max Views:</span>
|
||||||
|
<span className="font-medium">{shareSettings.maxViews || "Unlimited"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="w-full px-6 py-3 rounded-lg border border-border hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Share Another Location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useState } from "react"
|
||||||
|
import type { ShareSettings, PrecisionLevel } from "@/lib/location/types"
|
||||||
|
|
||||||
|
interface ShareSettingsProps {
|
||||||
|
onSettingsChange: (settings: ShareSettings) => void
|
||||||
|
initialSettings?: Partial<ShareSettings>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShareSettingsComponent: React.FC<ShareSettingsProps> = ({ onSettingsChange, initialSettings = {} }) => {
|
||||||
|
const [duration, setDuration] = useState<string>(
|
||||||
|
initialSettings.duration ? String(initialSettings.duration / 3600000) : "24",
|
||||||
|
)
|
||||||
|
const [maxViews, setMaxViews] = useState<string>(
|
||||||
|
initialSettings.maxViews ? String(initialSettings.maxViews) : "unlimited",
|
||||||
|
)
|
||||||
|
const [precision, setPrecision] = useState<PrecisionLevel>(initialSettings.precision || "street")
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
const settings: ShareSettings = {
|
||||||
|
duration: duration === "unlimited" ? null : Number(duration) * 3600000,
|
||||||
|
maxViews: maxViews === "unlimited" ? null : Number(maxViews),
|
||||||
|
precision,
|
||||||
|
}
|
||||||
|
onSettingsChange(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
handleChange()
|
||||||
|
}, [duration, maxViews, precision])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="share-settings space-y-6">
|
||||||
|
<div className="settings-header">
|
||||||
|
<h3 className="text-lg font-semibold">Privacy Settings</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Control how your location is shared</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Precision Level */}
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="block text-sm font-medium mb-3">Location Precision</label>
|
||||||
|
<div className="precision-options space-y-2">
|
||||||
|
{[
|
||||||
|
{ value: "exact", label: "Exact Location", desc: "Share precise coordinates" },
|
||||||
|
{ value: "street", label: "Street Level", desc: "~100m radius" },
|
||||||
|
{ value: "neighborhood", label: "Neighborhood", desc: "~1km radius" },
|
||||||
|
{ value: "city", label: "City Level", desc: "~10km radius" },
|
||||||
|
].map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className={`precision-option flex items-start gap-3 p-3 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||||
|
precision === option.value ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="precision"
|
||||||
|
value={option.value}
|
||||||
|
checked={precision === option.value}
|
||||||
|
onChange={(e) => setPrecision(e.target.value as PrecisionLevel)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-sm">{option.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{option.desc}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="duration" className="block text-sm font-medium mb-2">
|
||||||
|
Share Duration
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="duration"
|
||||||
|
value={duration}
|
||||||
|
onChange={(e) => setDuration(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="1">1 hour</option>
|
||||||
|
<option value="6">6 hours</option>
|
||||||
|
<option value="24">24 hours</option>
|
||||||
|
<option value="168">1 week</option>
|
||||||
|
<option value="unlimited">No expiration</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Views */}
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="maxViews" className="block text-sm font-medium mb-2">
|
||||||
|
Maximum Views
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="maxViews"
|
||||||
|
value={maxViews}
|
||||||
|
onChange={(e) => setMaxViews(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="1">1 view</option>
|
||||||
|
<option value="5">5 views</option>
|
||||||
|
<option value="10">10 views</option>
|
||||||
|
<option value="unlimited">Unlimited</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Privacy Notice */}
|
||||||
|
<div className="privacy-notice bg-muted/50 rounded-lg p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Your location data is stored securely in your private filesystem. Only people with the share link can view
|
||||||
|
your location, and shares automatically expire based on your settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* Quartz Sync Configuration
|
||||||
|
* Centralized configuration for all Quartz sync methods
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface QuartzSyncSettings {
|
||||||
|
// GitHub Integration
|
||||||
|
github: {
|
||||||
|
enabled: boolean
|
||||||
|
token?: string
|
||||||
|
repository?: string
|
||||||
|
branch?: string
|
||||||
|
autoCommit?: boolean
|
||||||
|
commitMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloudflare Integration
|
||||||
|
cloudflare: {
|
||||||
|
enabled: boolean
|
||||||
|
apiKey?: string
|
||||||
|
accountId?: string
|
||||||
|
r2Bucket?: string
|
||||||
|
durableObjectId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct Quartz API
|
||||||
|
quartzApi: {
|
||||||
|
enabled: boolean
|
||||||
|
baseUrl?: string
|
||||||
|
apiKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhook Integration
|
||||||
|
webhook: {
|
||||||
|
enabled: boolean
|
||||||
|
url?: string
|
||||||
|
secret?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback Options
|
||||||
|
fallback: {
|
||||||
|
localStorage: boolean
|
||||||
|
download: boolean
|
||||||
|
console: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultQuartzSyncSettings: QuartzSyncSettings = {
|
||||||
|
github: {
|
||||||
|
enabled: true,
|
||||||
|
repository: 'Jeff-Emmett/quartz',
|
||||||
|
branch: 'main',
|
||||||
|
autoCommit: true,
|
||||||
|
commitMessage: 'Update note: {title}'
|
||||||
|
},
|
||||||
|
cloudflare: {
|
||||||
|
enabled: false // Disabled by default, enable if needed
|
||||||
|
},
|
||||||
|
quartzApi: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
webhook: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
fallback: {
|
||||||
|
localStorage: true,
|
||||||
|
download: true,
|
||||||
|
console: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Quartz sync settings from environment variables and localStorage
|
||||||
|
*/
|
||||||
|
export function getQuartzSyncSettings(): QuartzSyncSettings {
|
||||||
|
const settings = { ...defaultQuartzSyncSettings }
|
||||||
|
|
||||||
|
// GitHub settings
|
||||||
|
if (process.env.NEXT_PUBLIC_GITHUB_TOKEN) {
|
||||||
|
settings.github.token = process.env.NEXT_PUBLIC_GITHUB_TOKEN
|
||||||
|
}
|
||||||
|
if (process.env.NEXT_PUBLIC_QUARTZ_REPO) {
|
||||||
|
settings.github.repository = process.env.NEXT_PUBLIC_QUARTZ_REPO
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloudflare settings
|
||||||
|
if (process.env.NEXT_PUBLIC_CLOUDFLARE_API_KEY) {
|
||||||
|
settings.cloudflare.apiKey = process.env.NEXT_PUBLIC_CLOUDFLARE_API_KEY
|
||||||
|
}
|
||||||
|
if (process.env.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID) {
|
||||||
|
settings.cloudflare.accountId = process.env.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID
|
||||||
|
}
|
||||||
|
if (process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET) {
|
||||||
|
settings.cloudflare.r2Bucket = process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quartz API settings
|
||||||
|
if (process.env.NEXT_PUBLIC_QUARTZ_API_URL) {
|
||||||
|
settings.quartzApi.baseUrl = process.env.NEXT_PUBLIC_QUARTZ_API_URL
|
||||||
|
settings.quartzApi.enabled = true
|
||||||
|
}
|
||||||
|
if (process.env.NEXT_PUBLIC_QUARTZ_API_KEY) {
|
||||||
|
settings.quartzApi.apiKey = process.env.NEXT_PUBLIC_QUARTZ_API_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhook settings
|
||||||
|
if (process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL) {
|
||||||
|
settings.webhook.url = process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL
|
||||||
|
settings.webhook.enabled = true
|
||||||
|
}
|
||||||
|
if (process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET) {
|
||||||
|
settings.webhook.secret = process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load user preferences from localStorage
|
||||||
|
try {
|
||||||
|
const userSettings = localStorage.getItem('quartz_sync_settings')
|
||||||
|
if (userSettings) {
|
||||||
|
const parsed = JSON.parse(userSettings)
|
||||||
|
Object.assign(settings, parsed)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load user Quartz sync settings:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save Quartz sync settings to localStorage
|
||||||
|
*/
|
||||||
|
export function saveQuartzSyncSettings(settings: Partial<QuartzSyncSettings>): void {
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any sync methods are available
|
||||||
|
*/
|
||||||
|
export function hasAvailableSyncMethods(): boolean {
|
||||||
|
const settings = getQuartzSyncSettings()
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
(settings.github.enabled && settings.github.token && settings.github.repository) ||
|
||||||
|
(settings.cloudflare.enabled && settings.cloudflare.apiKey && settings.cloudflare.accountId) ||
|
||||||
|
(settings.quartzApi.enabled && settings.quartzApi.baseUrl) ||
|
||||||
|
(settings.webhook.enabled && settings.webhook.url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Environment-based worker URL configuration
|
||||||
|
// You can easily switch between environments by changing the WORKER_ENV variable
|
||||||
|
|
||||||
|
// Available environments:
|
||||||
|
// - '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: `http://${window.location.hostname}:5172`,
|
||||||
|
production: "https://jeffemmett-canvas.jeffemmett.workers.dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main worker URL - automatically switches based on environment
|
||||||
|
export const WORKER_URL = WORKER_URLS[WORKER_ENV as keyof typeof WORKER_URLS] || WORKER_URLS.dev
|
||||||
|
|
||||||
|
// Legacy support for existing code
|
||||||
|
export const LOCAL_WORKER_URL = WORKER_URLS.local
|
||||||
|
|
||||||
|
// Helper function to get current environment info
|
||||||
|
export const getWorkerInfo = () => ({
|
||||||
|
environment: WORKER_ENV,
|
||||||
|
url: WORKER_URL,
|
||||||
|
isLocal: WORKER_ENV === 'local',
|
||||||
|
isDev: WORKER_ENV === 'dev',
|
||||||
|
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`)
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react';
|
||||||
|
import type FileSystem from '@oddjs/odd/fs/index';
|
||||||
|
import { Session, SessionError } from '../lib/auth/types';
|
||||||
|
import { AuthService } from '../lib/auth/authService';
|
||||||
|
import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
session: Session;
|
||||||
|
setSession: (updatedSession: Partial<Session>) => void;
|
||||||
|
updateSession: (updatedSession: Partial<Session>) => void;
|
||||||
|
clearSession: () => void;
|
||||||
|
fileSystem: FileSystem | null;
|
||||||
|
setFileSystem: (fs: FileSystem | null) => void;
|
||||||
|
initialize: () => Promise<void>;
|
||||||
|
login: (username: string) => Promise<boolean>;
|
||||||
|
register: (username: string) => Promise<boolean>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialSession: Session = {
|
||||||
|
username: '',
|
||||||
|
authed: false,
|
||||||
|
loading: true,
|
||||||
|
backupCreated: null,
|
||||||
|
obsidianVaultPath: undefined,
|
||||||
|
obsidianVaultName: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [session, setSessionState] = useState<Session>(initialSession);
|
||||||
|
const [fileSystem, setFileSystemState] = useState<FileSystem | null>(null);
|
||||||
|
|
||||||
|
// Update session with partial data
|
||||||
|
const setSession = useCallback((updatedSession: Partial<Session>) => {
|
||||||
|
setSessionState(prev => {
|
||||||
|
const newSession = { ...prev, ...updatedSession };
|
||||||
|
|
||||||
|
// Save session to localStorage if authenticated
|
||||||
|
if (newSession.authed && newSession.username) {
|
||||||
|
saveSession(newSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSession;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set file system
|
||||||
|
const setFileSystem = useCallback((fs: FileSystem | null) => {
|
||||||
|
setFileSystemState(fs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the authentication state
|
||||||
|
*/
|
||||||
|
const initialize = useCallback(async (): Promise<void> => {
|
||||||
|
setSessionState(prev => ({ ...prev, loading: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { session: newSession, fileSystem: newFs } = await AuthService.initialize();
|
||||||
|
setSessionState(newSession);
|
||||||
|
setFileSystemState(newFs);
|
||||||
|
|
||||||
|
// Save session to localStorage if authenticated
|
||||||
|
if (newSession.authed && newSession.username) {
|
||||||
|
saveSession(newSession);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth initialization error:', error);
|
||||||
|
setSessionState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
authed: false,
|
||||||
|
error: error as SessionError
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with a username
|
||||||
|
*/
|
||||||
|
const login = useCallback(async (username: string): Promise<boolean> => {
|
||||||
|
setSessionState(prev => ({ ...prev, loading: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await AuthService.login(username);
|
||||||
|
|
||||||
|
if (result.success && result.session && result.fileSystem) {
|
||||||
|
setSessionState(result.session);
|
||||||
|
setFileSystemState(result.fileSystem);
|
||||||
|
|
||||||
|
// Save session to localStorage if authenticated
|
||||||
|
if (result.session.authed && result.session.username) {
|
||||||
|
saveSession(result.session);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
setSessionState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: result.error as SessionError
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
setSessionState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: error as SessionError
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
const register = useCallback(async (username: string): Promise<boolean> => {
|
||||||
|
setSessionState(prev => ({ ...prev, loading: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await AuthService.register(username);
|
||||||
|
|
||||||
|
if (result.success && result.session && result.fileSystem) {
|
||||||
|
setSessionState(result.session);
|
||||||
|
setFileSystemState(result.fileSystem);
|
||||||
|
|
||||||
|
// Save session to localStorage if authenticated
|
||||||
|
if (result.session.authed && result.session.username) {
|
||||||
|
saveSession(result.session);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
setSessionState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: result.error as SessionError
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Register error:', error);
|
||||||
|
setSessionState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: error as SessionError
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the current session
|
||||||
|
*/
|
||||||
|
const clearSession = useCallback((): void => {
|
||||||
|
clearStoredSession();
|
||||||
|
setSessionState({
|
||||||
|
username: '',
|
||||||
|
authed: false,
|
||||||
|
loading: false,
|
||||||
|
backupCreated: null,
|
||||||
|
obsidianVaultPath: undefined,
|
||||||
|
obsidianVaultName: undefined
|
||||||
|
});
|
||||||
|
setFileSystemState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout the current user
|
||||||
|
*/
|
||||||
|
const logout = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AuthService.logout();
|
||||||
|
clearSession();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
// Initialize on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
initialize();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth initialization error in useEffect:', error);
|
||||||
|
// Set a safe fallback state
|
||||||
|
setSessionState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
authed: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []); // Empty dependency array - only run once on mount
|
||||||
|
|
||||||
|
const contextValue: AuthContextType = useMemo(() => ({
|
||||||
|
session,
|
||||||
|
setSession,
|
||||||
|
updateSession: setSession,
|
||||||
|
clearSession,
|
||||||
|
fileSystem,
|
||||||
|
setFileSystem,
|
||||||
|
initialize,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout
|
||||||
|
}), [session, setSession, clearSession, fileSystem, setFileSystem, initialize, login, register, logout]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = (): AuthContextType => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import React, { createContext, useContext, ReactNode } from 'react'
|
||||||
|
import { DocHandle } from '@automerge/automerge-repo'
|
||||||
|
|
||||||
|
interface AutomergeHandleContextType {
|
||||||
|
handle: DocHandle<any> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const AutomergeHandleContext = createContext<AutomergeHandleContextType>({
|
||||||
|
handle: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const AutomergeHandleProvider: React.FC<{
|
||||||
|
handle: DocHandle<any> | null
|
||||||
|
children: ReactNode
|
||||||
|
}> = ({ handle, children }) => {
|
||||||
|
return (
|
||||||
|
<AutomergeHandleContext.Provider value={{ handle }}>
|
||||||
|
{children}
|
||||||
|
</AutomergeHandleContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAutomergeHandle = (): DocHandle<any> | null => {
|
||||||
|
const context = useContext(AutomergeHandleContext)
|
||||||
|
return context.handle
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
// Blockchain context for managing wallet connections and blockchain state
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { connectWallet, getWalletConnection, type WalletConnection } from '../lib/blockchain/ethereum';
|
||||||
|
import { getLinkedAccount, linkAccount, type LinkedAccount } from '../lib/auth/blockchainLinking';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
interface BlockchainContextType {
|
||||||
|
wallet: WalletConnection | null;
|
||||||
|
linkedAccount: LinkedAccount | null;
|
||||||
|
isConnecting: boolean;
|
||||||
|
connect: () => Promise<void>;
|
||||||
|
disconnect: () => void;
|
||||||
|
linkWebCryptoAccount: (username: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
refreshConnection: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlockchainContext = createContext<BlockchainContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function BlockchainProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [wallet, setWallet] = useState<WalletConnection | null>(null);
|
||||||
|
const [linkedAccount, setLinkedAccount] = useState<LinkedAccount | null>(null);
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const { session } = useAuth();
|
||||||
|
|
||||||
|
// Check for existing wallet connection on mount
|
||||||
|
useEffect(() => {
|
||||||
|
refreshConnection();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update linked account when wallet or session changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (wallet && session.username) {
|
||||||
|
const linked = getLinkedAccount(session.username);
|
||||||
|
setLinkedAccount(linked || null);
|
||||||
|
} else {
|
||||||
|
setLinkedAccount(null);
|
||||||
|
}
|
||||||
|
}, [wallet, session.username]);
|
||||||
|
|
||||||
|
const refreshConnection = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const connection = await getWalletConnection();
|
||||||
|
setWallet(connection);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing wallet connection:', error);
|
||||||
|
setWallet(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connect = useCallback(async () => {
|
||||||
|
setIsConnecting(true);
|
||||||
|
try {
|
||||||
|
const connection = await connectWallet();
|
||||||
|
setWallet(connection);
|
||||||
|
|
||||||
|
// Check if account is linked
|
||||||
|
if (session.username) {
|
||||||
|
const linked = getLinkedAccount(session.username);
|
||||||
|
setLinkedAccount(linked || null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error connecting wallet:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
|
}, [session.username]);
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
setWallet(null);
|
||||||
|
setLinkedAccount(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const linkWebCryptoAccount = useCallback(async (username: string): Promise<{ success: boolean; error?: string }> => {
|
||||||
|
if (!wallet) {
|
||||||
|
return { success: false, error: 'Wallet not connected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await linkAccount(username, wallet.address);
|
||||||
|
if (result.success && result.linkedAccount) {
|
||||||
|
setLinkedAccount(result.linkedAccount);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error linking account:', error);
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
}, [wallet]);
|
||||||
|
|
||||||
|
// Listen for wallet account changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && window.ethereum) {
|
||||||
|
const handleAccountsChanged = (accounts: string[]) => {
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
setWallet(null);
|
||||||
|
setLinkedAccount(null);
|
||||||
|
} else {
|
||||||
|
refreshConnection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChainChanged = () => {
|
||||||
|
refreshConnection();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.ethereum.on('accountsChanged', handleAccountsChanged);
|
||||||
|
window.ethereum.on('chainChanged', handleChainChanged);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
|
||||||
|
window.ethereum?.removeListener('chainChanged', handleChainChanged);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [refreshConnection]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BlockchainContext.Provider
|
||||||
|
value={{
|
||||||
|
wallet,
|
||||||
|
linkedAccount,
|
||||||
|
isConnecting,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
linkWebCryptoAccount,
|
||||||
|
refreshConnection,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</BlockchainContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBlockchain() {
|
||||||
|
const context = useContext(BlockchainContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useBlockchain must be used within a BlockchainProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend Window interface for TypeScript
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
ethereum?: {
|
||||||
|
request: (args: { method: string; params?: any[] }) => Promise<any>;
|
||||||
|
on: (event: string, handler: (...args: any[]) => void) => void;
|
||||||
|
removeListener: (event: string, handler: (...args: any[]) => void) => void;
|
||||||
|
isMetaMask?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
|
import * as webnative from 'webnative';
|
||||||
|
import type FileSystem from 'webnative/fs/index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File system context interface
|
||||||
|
*/
|
||||||
|
interface FileSystemContextType {
|
||||||
|
fs: FileSystem | null;
|
||||||
|
setFs: (fs: FileSystem | null) => void;
|
||||||
|
isReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context with a default undefined value
|
||||||
|
const FileSystemContext = createContext<FileSystemContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FileSystemProvider component
|
||||||
|
*
|
||||||
|
* Provides access to the webnative filesystem throughout the application.
|
||||||
|
*/
|
||||||
|
export const FileSystemProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [fs, setFs] = useState<FileSystem | null>(null);
|
||||||
|
|
||||||
|
// File system is ready when it's not null
|
||||||
|
const isReady = fs !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileSystemContext.Provider value={{ fs, setFs, isReady }}>
|
||||||
|
{children}
|
||||||
|
</FileSystemContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access the file system context
|
||||||
|
*
|
||||||
|
* @returns The file system context
|
||||||
|
* @throws Error if used outside of FileSystemProvider
|
||||||
|
*/
|
||||||
|
export const useFileSystem = (): FileSystemContextType => {
|
||||||
|
const context = useContext(FileSystemContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useFileSystem must be used within a FileSystemProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory paths used in the application
|
||||||
|
*/
|
||||||
|
export const DIRECTORIES = {
|
||||||
|
PUBLIC: {
|
||||||
|
ROOT: ['public'],
|
||||||
|
GALLERY: ['public', 'gallery'],
|
||||||
|
DOCUMENTS: ['public', 'documents']
|
||||||
|
},
|
||||||
|
PRIVATE: {
|
||||||
|
ROOT: ['private'],
|
||||||
|
GALLERY: ['private', 'gallery'],
|
||||||
|
SETTINGS: ['private', 'settings'],
|
||||||
|
DOCUMENTS: ['private', 'documents']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common filesystem operations
|
||||||
|
*
|
||||||
|
* @param fs The filesystem instance
|
||||||
|
* @returns An object with filesystem utility functions
|
||||||
|
*/
|
||||||
|
export const createFileSystemUtils = (fs: FileSystem) => {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Creates a directory if it doesn't exist
|
||||||
|
*
|
||||||
|
* @param path Array of path segments
|
||||||
|
*/
|
||||||
|
ensureDirectory: async (path: string[]): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const dirPath = webnative.path.directory(...path);
|
||||||
|
const exists = await fs.exists(dirPath as any);
|
||||||
|
if (!exists) {
|
||||||
|
await fs.mkdir(dirPath as any);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error ensuring directory:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a file to the filesystem
|
||||||
|
*
|
||||||
|
* @param path Array of path segments
|
||||||
|
* @param fileName The name of the file
|
||||||
|
* @param content The content to write
|
||||||
|
*/
|
||||||
|
writeFile: async (path: string[], fileName: string, content: Blob | string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const filePath = webnative.path.file(...path, fileName);
|
||||||
|
// Convert content to appropriate format for webnative
|
||||||
|
const contentToWrite = typeof content === 'string' ? new TextEncoder().encode(content) : content;
|
||||||
|
await fs.write(filePath as any, contentToWrite as any);
|
||||||
|
await fs.publish();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error writing file:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a file from the filesystem
|
||||||
|
*
|
||||||
|
* @param path Array of path segments
|
||||||
|
* @param fileName The name of the file
|
||||||
|
* @returns The file content
|
||||||
|
*/
|
||||||
|
readFile: async (path: string[], fileName: string): Promise<any> => {
|
||||||
|
try {
|
||||||
|
const filePath = webnative.path.file(...path, fileName);
|
||||||
|
const exists = await fs.exists(filePath as any);
|
||||||
|
if (!exists) {
|
||||||
|
throw new Error(`File doesn't exist: ${fileName}`);
|
||||||
|
}
|
||||||
|
return await fs.read(filePath as any);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading file:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a file exists
|
||||||
|
*
|
||||||
|
* @param path Array of path segments
|
||||||
|
* @param fileName The name of the file
|
||||||
|
* @returns Boolean indicating if the file exists
|
||||||
|
*/
|
||||||
|
fileExists: async (path: string[], fileName: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const filePath = webnative.path.file(...path, fileName);
|
||||||
|
return await fs.exists(filePath as any);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking file existence:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists files in a directory
|
||||||
|
*
|
||||||
|
* @param path Array of path segments
|
||||||
|
* @returns Object with file names as keys
|
||||||
|
*/
|
||||||
|
listDirectory: async (path: string[]): Promise<Record<string, any>> => {
|
||||||
|
try {
|
||||||
|
const dirPath = webnative.path.directory(...path);
|
||||||
|
const exists = await fs.exists(dirPath as any);
|
||||||
|
if (!exists) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return await fs.ls(dirPath as any);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing directory:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to use filesystem utilities
|
||||||
|
*
|
||||||
|
* @returns Filesystem utilities or null if filesystem is not ready
|
||||||
|
*/
|
||||||
|
export const useFileSystemUtils = () => {
|
||||||
|
const { fs, isReady } = useFileSystem();
|
||||||
|
|
||||||
|
if (!isReady || !fs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createFileSystemUtils(fs);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types of notifications supported by the system
|
||||||
|
*/
|
||||||
|
export type NotificationType = 'success' | 'error' | 'info' | 'warning';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification object structure
|
||||||
|
*/
|
||||||
|
export type Notification = {
|
||||||
|
id: string;
|
||||||
|
msg: string;
|
||||||
|
type: NotificationType;
|
||||||
|
timeout: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for the notification context
|
||||||
|
*/
|
||||||
|
interface NotificationContextType {
|
||||||
|
notifications: Notification[];
|
||||||
|
addNotification: (msg: string, type?: NotificationType, timeout?: number) => string;
|
||||||
|
removeNotification: (id: string) => void;
|
||||||
|
clearAllNotifications: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context with a default undefined value
|
||||||
|
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NotificationProvider component - provides notification functionality to the app
|
||||||
|
*/
|
||||||
|
export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a notification by ID
|
||||||
|
*/
|
||||||
|
const removeNotification = useCallback((id: string) => {
|
||||||
|
setNotifications(current => current.filter(notification => notification.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new notification
|
||||||
|
* @param msg The message to display
|
||||||
|
* @param type The type of notification (success, error, info, warning)
|
||||||
|
* @param timeout Time in ms before notification is automatically removed
|
||||||
|
* @returns The ID of the created notification
|
||||||
|
*/
|
||||||
|
const addNotification = useCallback(
|
||||||
|
(msg: string, type: NotificationType = 'info', timeout: number = 5000): string => {
|
||||||
|
// Create a unique ID for the notification
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
// Add notification to the array
|
||||||
|
setNotifications(current => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
msg,
|
||||||
|
type,
|
||||||
|
timeout,
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Set up automatic removal after timeout
|
||||||
|
if (timeout > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
removeNotification(id);
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the notification ID for reference
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
[removeNotification]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all current notifications
|
||||||
|
*/
|
||||||
|
const clearAllNotifications = useCallback(() => {
|
||||||
|
setNotifications([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Create the context value with all functions and state
|
||||||
|
const contextValue: NotificationContextType = {
|
||||||
|
notifications,
|
||||||
|
addNotification,
|
||||||
|
removeNotification,
|
||||||
|
clearAllNotifications
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access the notification context
|
||||||
|
*/
|
||||||
|
export const useNotifications = (): NotificationContextType => {
|
||||||
|
const context = useContext(NotificationContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useNotifications must be used within a NotificationProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
/* Authentication Page Styles */
|
||||||
|
.auth-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 30px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: #fee2e2;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-button {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-button:hover {
|
||||||
|
background-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-button:disabled {
|
||||||
|
background-color: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-toggle {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-toggle button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-toggle button:hover {
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-toggle button:disabled {
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container.loading,
|
||||||
|
.auth-container.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile Component Styles */
|
||||||
|
.profile-container {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button:hover {
|
||||||
|
background-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-reminder {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #fffbeb;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-reminder p {
|
||||||
|
margin: 0;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,695 @@
|
||||||
|
/* Cryptographic Authentication Styles */
|
||||||
|
|
||||||
|
.crypto-login-container {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid #e1e5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-login-container h2 {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-info {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-info p {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-features {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-features .feature {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #495057;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Existing Users Styles */
|
||||||
|
.existing-users {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.existing-users h3 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-option:hover:not(:disabled) {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #f8f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-option.selected {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #e7f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-option:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-auth-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-auth-button:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-auth-button:disabled {
|
||||||
|
background: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-toggle {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #007bff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button:hover:not(:disabled) {
|
||||||
|
color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button:disabled {
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.crypto-auth-button:disabled {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-auth-button:disabled::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: -8px 0 0 -8px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.crypto-login-container {
|
||||||
|
margin: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-login-container h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-features {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive positioning for toolbar buttons */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.toolbar-login-button {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust toolbar container position on mobile */
|
||||||
|
.toolbar-container {
|
||||||
|
right: 35px !important;
|
||||||
|
gap: 4px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.crypto-login-container {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-login-container h2 {
|
||||||
|
color: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-info {
|
||||||
|
background: #4a5568;
|
||||||
|
border-left-color: #63b3ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-info p {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
background: #4a5568;
|
||||||
|
border-color: #718096;
|
||||||
|
color: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
border-color: #63b3ed;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 179, 237, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled {
|
||||||
|
background-color: #2d3748;
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.existing-users {
|
||||||
|
background: #4a5568;
|
||||||
|
border-color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.existing-users h3 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-option {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-option:hover:not(:disabled) {
|
||||||
|
border-color: #63b3ed;
|
||||||
|
background: #2c5282;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-option.selected {
|
||||||
|
border-color: #63b3ed;
|
||||||
|
background: #2c5282;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-status {
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test Component Styles */
|
||||||
|
.crypto-test-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid #e1e5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-test-container h2 {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-button, .clear-button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-button {
|
||||||
|
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-button:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #218838 0%, #1ea085 100%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button:hover:not(:disabled) {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-button:disabled, .clear-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-results {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-results h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-info {
|
||||||
|
background: #e3f2fd;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 4px solid #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-info h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #1976d2;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-info ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
color: #424242;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-info li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Button Styles */
|
||||||
|
.login-button {
|
||||||
|
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
height: 22px;
|
||||||
|
min-height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:hover {
|
||||||
|
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-login-button {
|
||||||
|
margin-right: 0;
|
||||||
|
height: 22px;
|
||||||
|
min-height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-login-button:hover {
|
||||||
|
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Modal Overlay */
|
||||||
|
.login-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
animation: modalSlideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode for login button */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.login-button {
|
||||||
|
background: linear-gradient(135deg, #63b3ed 0%, #3182ce 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:hover {
|
||||||
|
background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-modal {
|
||||||
|
background: #2d3748;
|
||||||
|
border: 1px solid #4a5568;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Debug Component Styles */
|
||||||
|
.crypto-debug-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 1rem auto;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-debug-container h2 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-button:hover:not(:disabled) {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-results {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-results h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode for test component */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.crypto-test-container {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-test-container h2 {
|
||||||
|
color: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-results h3 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
background: #4a5568;
|
||||||
|
border-color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-bottom-color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-info {
|
||||||
|
background: #2c5282;
|
||||||
|
border-left-color: #63b3ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-info h3 {
|
||||||
|
color: #90cdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-info ul {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-debug-container {
|
||||||
|
background: #4a5568;
|
||||||
|
border-color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-debug-container h2 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-input {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #718096;
|
||||||
|
color: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-results h3 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue